diff --git a/.claude/backups/.env.example b/.claude/backups/.env.example new file mode 100644 index 0000000..05aa44f --- /dev/null +++ b/.claude/backups/.env.example @@ -0,0 +1,43 @@ +# Telegram +TG_BOT_TOKEN=123456:replace_me +TG_ALLOWED_USER_IDS=10001,10002 +# 调试可用 * 放开所有用户(生产环境不建议) +# TG_ALLOWED_USER_IDS=* +TG_REQUEST_TIMEOUT_SEC=30 +TG_POLLING_RETRY_DELAY_SEC=5 +# 如果网络受限可配置代理,例如: +# TG_PROXY_URL=socks5://127.0.0.1:7890 + +# Execution +DEFAULT_PROVIDER=claude_code +DEFAULT_TIMEOUT_SEC=600 +MAX_CONCURRENT_TASKS=2 +CLAUDE_TMUX_MODE=false +TMUX_BIN=tmux +TMUX_DATA_DIR=/tmp/tg-cli-gateway + +# CLI bin paths +CLAUDE_CLI_BIN=claude +CLAUDE_CONFIG_DIR= +CLAUDE_HOOK_SOCKET_PATH=/tmp/remote-coding-claude.sock +CLAUDE_INSTALL_HOOKS=true +CLAUDE_HOOK_MAX_MESSAGE_BYTES=1048576 +CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC=600 +CLAUDE_HOOK_MAX_PENDING_PERMISSIONS=64 +CLAUDE_JSONL_SYNC_DEBOUNCE_MS=100 +CLAUDE_PERIODIC_RECHECK_MS=500 +CODEX_CLI_BIN=codex +GEMINI_CLI_BIN=gemini + +# Security boundary +ALLOWED_WORKDIRS=/opt/tg-cli-gateway/workdir + +# Rate limit +RATE_LIMIT_MAX_REQUESTS=6 +RATE_LIMIT_WINDOW_SEC=20 + +# Message output +CHUNK_SIZE=3800 +CHUNK_FLUSH_INTERVAL_SEC=1.0 +TASK_OUTPUT_CHAR_LIMIT=120000 + diff --git a/.claude/backups/2026-05-23-memory-ttl-limits-design.md b/.claude/backups/2026-05-23-memory-ttl-limits-design.md new file mode 100644 index 0000000..e87210a --- /dev/null +++ b/.claude/backups/2026-05-23-memory-ttl-limits-design.md @@ -0,0 +1,233 @@ +# 内存结构 TTL 与上限设计 + +日期:2026-05-23 + +## 背景 + +当前项目是 Telegram CLI Gateway,长期运行时存在若干只增不减的内存结构: + +- `MemoryTaskStore._tasks`:任务记录常驻内存。 +- `RateLimitMiddleware._buckets`:访问过的用户桶会保留。 +- `PermissionService._permission_locks`:每个 `tool_use_id` 的锁会保留。 +- `AppContainer` 中的 `_jsonl_sync_locks` 与 `_session_event_locks`:每个 Claude session 的锁会保留。 + +目标是在不引入后台清理服务、不改变主要架构的前提下,为这些结构增加可配置的 TTL/容量约束,降低长期运行的内存膨胀风险。 + +## 目标 + +1. 任务记录默认保留 7 天且最多 1000 条。 +2. 限流桶、权限锁、session 锁不再因历史用户或历史 session 无限增长。 +3. 清理逻辑采用懒清理:在现有访问路径中顺手清理,不新增长期运行协程。 +4. 热路径清理必须有上限,避免在单个请求里扫描全部历史桶或全部历史锁。 +5. 清理行为配置化,默认启用。 +6. 不影响现有限流、权限响应、JSONL sync、session event dispatch 的语义。 + +## 非目标 + +- 不引入 SQLite 或其他持久化任务存储。 +- 不新增统一后台 `MemoryCleanupService`。 +- 不重构 `AppContainer`、`bootstrap_mixins.py` 或任务服务架构。 +- 不清理已经持久化到磁盘的 session 状态文件。 + +## 配置 + +新增配置项: + +- `TASK_STORE_TTL_HOURS=168` +- `TASK_STORE_MAX_RECORDS=1000` +- `RATE_LIMIT_BUCKET_TTL_SEC`:未设置时使用 `RATE_LIMIT_WINDOW_SEC` 的有效值。 +- `RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60` +- `RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50` +- `PERMISSION_LOCK_TTL_SEC`:未设置时使用 `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC` 的有效值。 +- `SESSION_LOCK_TTL_SEC=3600` +- `LOCK_CLEANUP_INTERVAL_SEC=60` +- `LOCK_CLEANUP_BATCH_SIZE=50` + +所有配置的有效值必须为正整数。`.env.example` 同步补充默认值说明。 + +`SESSION_LOCK_TTL_SEC` 使用独立默认值,不复用 `EXTERNAL_SESSION_STALE_TIMEOUT_SEC`。外部 session stale timeout 表示“多久没收到事件就认为外部 session 失活”,session lock TTL 表示“锁条目无人持有且无人等待后多久可以回收”,两者语义不同。 + +## 组件设计 + +### MemoryTaskStore + +`MemoryTaskStore` 构造函数增加: + +- `max_records: int` +- `ttl_hours: int` + +当前类已有 `self._lock: asyncio.Lock`。所有公开 async 方法继续先获取该锁,再读写 `_tasks`。淘汰 helper 命名为 `_evict_expired_and_overflow_locked()`;`locked` 后缀只表示“调用方已经持有 `self._lock`”。该 helper 是同步函数,执行过程中不允许 `await`,避免在遍历/删除 dict 时发生协程交错。 + +清理策略: + +1. 在 `add()`、`save()`、`list_by_user()`、`iter_all()` 中调用 `_evict_expired_and_overflow_locked()`。 +2. TTL 只删除 final 状态任务,即 `SUCCEEDED`、`FAILED`、`TIMEOUT`、`CANCELED`。 +3. TTL 起算点是 `ended_at`。如果 final 任务缺少 `ended_at`,使用 `created_at` 作为兼容兜底。 +4. 未 final 的任务不会因 TTL 或容量上限被删除。 +5. 容量超过 `max_records` 时,优先删除最旧的 final 任务,排序键为 `ended_at or created_at`。 +6. 如果 final 任务不足以降到上限以下,保留未 final 任务,允许短暂超过上限以避免破坏运行中任务查询。 + +TTL 与容量的关系: + +1. 每次淘汰先执行 TTL 删除,移除所有超过 `TASK_STORE_TTL_HOURS` 的 final 任务。 +2. 再执行容量删除;如果剩余记录数仍超过 `TASK_STORE_MAX_RECORDS`,继续删除最旧 final 任务,即使这些 final 任务尚未超过 TTL。 +3. 因此二者是独立约束:TTL 可能让记录数低于上限,容量也可能在 TTL 未到期时删除旧 final 任务。 +4. 未 final 任务始终受保护;当未 final 任务数量本身超过上限时,store 可以超过 `TASK_STORE_MAX_RECORDS`。 + +复杂度要求:TTL pass 是 O(N),容量删除通过一次候选收集和一次排序完成,避免嵌套循环导致 O(N²)。 + +### RateLimitMiddleware + +`RateLimitMiddleware` 增加: + +- `bucket_ttl_sec` +- `cleanup_interval_sec` +- `cleanup_batch_size` + +每个用户桶内的时间戳数量仍由 `limit` 约束。当前请求只无条件清理当前用户桶,因此单请求固定成本为 O(limit)。 + +全局陈旧桶清理必须节流并限量: + +1. middleware 维护 `_last_cleanup_ts`,只有距离上次全局清理超过 `cleanup_interval_sec` 时才启动一批全局清理。 +2. middleware 维护 `_cleanup_queue: deque[int]` 和 `_cleanup_queued: set[int]`;新 user_id 首次创建桶时入队一次。 +3. 每批从队列左侧最多弹出 `cleanup_batch_size` 个 user_id。 +4. 检查到空桶或最后一次请求已超过 `bucket_ttl_sec` 的桶时删除该 user_id,并从 `_cleanup_queued` 移除。 +5. 检查到仍活跃的桶时保留该桶,并把 user_id 重新放回队列尾部。 +6. 保持现有限流判断不变。 + +这样即使 `allow_all_users=true` 且历史用户很多,一个活跃用户的每次请求也不会遍历全部历史桶。 + +### PermissionService 锁 + +权限锁不能简单在使用后 `pop`,否则并发等待同一把锁的协程可能仍持有旧锁引用,而新请求会创建新锁,破坏串行化。 + +设计一个轻量锁条目: + +- `lock: asyncio.Lock` +- `ref_count: int` +- `last_used: float` + +`last_used` 使用事件循环单调时间,例如 `asyncio.get_running_loop().time()`,避免系统时间回拨影响 TTL 判断。 + +获取锁时增加 `ref_count`,退出临界区后减少 `ref_count` 并更新 `last_used`。懒清理只删除满足以下条件的条目: + +- `ref_count == 0` +- `lock.locked() is False` +- `last_used` 距今超过 `PERMISSION_LOCK_TTL_SEC` + +权限响应完成后必须尝试清理当前 `tool_use_id` 的锁。全局过期锁清理受 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 约束,不能在热路径中无上限扫描全表。 + +### JSONL sync locks + +`_jsonl_sync_locks` 使用与权限锁相同的轻量锁条目和获取/释放流程。 + +清理策略: + +1. `sync_claude_session()` 完成后更新该 session lock 的 `last_used`。 +2. `_debounced_sync_claude_session()` 结束且没有 pending sync request 时必须尝试清理当前 session 的 sync lock。 +3. 全局过期 sync lock 清理受 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 约束。 +4. 只删除无引用、未锁定、超过 `SESSION_LOCK_TTL_SEC` 的条目。 +5. `stop()` 路径继续清空所有 JSONL sync 相关字典。 + +活跃 session 不会因为创建时间超过 TTL 被清理;TTL 只从最后一次使用后开始计算。 + +### Session event locks + +`_session_event_locks` 使用与权限锁相同的轻量锁条目和获取/释放流程。 + +清理策略: + +1. `_dispatch_session_event()` 完成后更新 `last_used`。 +2. 收到 `SessionEnd` 事件后,必须立即尝试清理该 session 的 event lock,但仍必须满足无引用、未锁定条件。 +3. 全局过期 event lock 清理受 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 约束,不能在热路径中无上限扫描全表。 + +活跃 session 不会因为创建时间超过 TTL 被清理;TTL 只从最后一次使用后开始计算。 + +## 数据流 + +### 任务记录 + +1. 新任务进入 `MemoryTaskStore.add()`。 +2. store 在 `self._lock` 保护下执行同步淘汰。 +3. 先删除超过 TTL 的 final 任务。 +4. 写入或更新当前任务。 +5. 如果记录数超过上限,删除最旧 final 任务直到达到上限或没有可删除 final 任务。 +6. 查询最近任务时只返回清理后的记录。 + +### 限流桶 + +1. Telegram 事件进入 `RateLimitMiddleware`。 +2. middleware 清理当前用户桶的过期时间戳。 +3. 判断是否超过限流。 +4. 允许通过时写入当前时间戳。 +5. 如果距离上次全局清理超过 `RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC`,最多清理 `RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE` 个历史用户桶。 + +### 锁结构 + +1. 访问方按 key 获取锁条目。 +2. 条目 `ref_count += 1`。 +3. 进入 `async with lock`。 +4. 临界区结束后 `ref_count -= 1`,更新 `last_used`。 +5. 当前 key 在释放后必须尝试清理。 +6. 全局锁清理只有在距离上次清理超过 `LOCK_CLEANUP_INTERVAL_SEC` 时运行,且单批最多检查 `LOCK_CLEANUP_BATCH_SIZE` 个 key。 +7. 懒清理删除无引用、未锁定且过期的锁条目。 + +## 错误处理与并发约束 + +- 清理逻辑不得阻断主流程。 +- 清理中遇到异常时记录 warning,并继续执行原操作。 +- 配置非法时沿用现有启动期校验风格,直接抛出配置错误。 +- `MemoryTaskStore` 淘汰 helper 是同步函数,在 `self._lock` 内执行,内部不允许 `await`。 +- 锁注册表删除条目前必须确认 `ref_count == 0` 且 `lock.locked() is False`。 +- 任务存储优先保证运行中任务可查询,必要时允许短暂超过 `TASK_STORE_MAX_RECORDS`。 + +## 测试计划 + +新增或扩展测试: + +1. `MemoryTaskStore` + - 过期 final 任务会按 `ended_at` 被清理。 + - final 任务缺少 `ended_at` 时使用 `created_at` 兜底。 + - 运行中任务不会因 TTL 被清理。 + - 超过 `max_records` 时删除最旧 final 任务。 + - final 任务不足时不会删除 running 任务。 + - 10,000 条任务下执行一次淘汰,验证结果正确,避免实现退化成 O(N²)。该测试不使用严格耗时断言,重点验证大数据量路径可完成且排序/删除正确。 + - 使用 `asyncio.gather` 并发调用 `add()`、`save()`、`list_by_user()`,验证不会出现 dict mutation 异常且最终记录满足保护 running 任务的约束。 +2. `RateLimitMiddleware` + - 限流行为保持不变。 + - 窗口过后当前用户空桶会被删除。 + - 历史用户桶只在 cleanup interval 到期后清理。 + - 单次全局清理最多处理 `cleanup_batch_size` 个桶。 + - 在大量历史桶和单个活跃用户场景下,请求路径不会扫描全部历史桶。 +3. `PermissionService` + - 同一 `tool_use_id` 并发响应仍串行。 + - 当前 `tool_use_id` 锁在无引用且过期后可被懒清理。 + - 未过期或 `ref_count > 0` 的锁不会被清理。 + - 全局锁清理只在 cleanup interval 到期后运行,且单批最多处理 `LOCK_CLEANUP_BATCH_SIZE` 个 key。 +4. session locks + - JSONL sync 完成且无 pending request 后,过期 sync lock 可被清理。 + - `SessionEnd` 后 event lock 可被安全清理。 + - 活跃 session 的 lock 因 `last_used` 更新不会被 TTL 误删。 +5. 配置 + - 新配置项默认值正确。 + - 派生默认值正确:`RATE_LIMIT_BUCKET_TTL_SEC` 未设置时使用 `RATE_LIMIT_WINDOW_SEC`,`PERMISSION_LOCK_TTL_SEC` 未设置时使用 `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC`。 + - `SESSION_LOCK_TTL_SEC` 默认值独立为 3600,不受 `EXTERNAL_SESSION_STALE_TIMEOUT_SEC` 影响。 + - 非正整数配置启动校验失败。 + +验收命令: + +```bash +pytest -q +``` + +必要时先单跑: + +```bash +pytest -q tests/test_auth_settings.py tests/test_task_service.py tests/test_bootstrap_hooks.py +``` + +## 取舍 + +选择“配置化 + 懒清理”是为了在内存风险、可调节性和改动范围之间取得平衡。后台清理服务更完整,但会增加生命周期管理和测试复杂度;固定默认值改动更少,但后续调参需要改代码。 + +这版设计避免在请求热路径无上限扫描历史结构。任务记录仍可能在查询/写入时做 O(N) 或 O(N log N) 淘汰,但默认上限为 1000,且大数据量测试会防止实现出现 O(N²) 退化。 diff --git a/.claude/backups/2026-05-23-memory-ttl-limits.md b/.claude/backups/2026-05-23-memory-ttl-limits.md new file mode 100644 index 0000000..68a7a07 --- /dev/null +++ b/.claude/backups/2026-05-23-memory-ttl-limits.md @@ -0,0 +1,1559 @@ +# Memory TTL Limits Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为长期运行的内存结构增加可配置 TTL、容量上限和有界懒清理,避免任务记录、限流桶、权限锁、JSONL sync 锁、session event 锁无限增长。 + +**Architecture:** 增加一个通用 `RefCountedLockRegistry` 负责 `asyncio.Lock + ref_count + last_used + 有界清理队列`。任务记录和限流桶在各自组件内部做懒清理;配置通过 `Settings` 注入,`AppContainer` 负责把配置传给 store、middleware 和 registry 使用方。 + +**Tech Stack:** Python 3.11, asyncio, aiogram middleware, pydantic-settings, pytest, pytest-asyncio. + +--- + +## 执行安全约束 + +- 当前工作区已有无关变更:`app/bootstrap_mixins.py` 和 `_test_task21.py`。不要提交 `_test_task21.py`。如果在当前工作区执行本计划,修改 `app/bootstrap_mixins.py` 前先检查现有 diff,必须保留用户原有改动;更推荐在隔离 worktree 中执行。 +- 不使用 `git add .` 或 `git add -A`。每次提交只添加本任务列出的文件。 +- 运行 Python/pytest 前按用户全局要求确认 pyenv/pyenv-virtualenv 环境。若当前项目未绑定 pyenv virtualenv,先停止并按 pyenv-virtualenv 流程创建和绑定,不使用 venv、conda、poetry 自建环境。 +- 修改非代码文件前已经存在备份约束:本计划会修改 `.env.example` 和新增计划外实现文件;执行时如改已有非代码文件,先备份到 `.claude/backups/`,同一文件最多一个备份。 + +## 文件结构 + +- Create: `app/services/lock_registry.py` + 通用引用计数异步锁注册表,供权限、JSONL sync、session event dispatch 三处复用。 +- Create: `tests/test_lock_registry.py` + 覆盖 registry 串行化、TTL 清理、重建重新入队、批量清理上限、实例状态独立。 +- Create: `tests/test_memory_task_store.py` + 覆盖任务记录 TTL、容量、运行中任务保护、10,000 条压力路径、并发访问。 +- Modify: `app/adapters/storage/memory.py` + `MemoryTaskStore` 增加 TTL 和 max records 淘汰。 +- Modify: `app/bot/middleware/rate_limit.py` + 限流桶增加当前桶清理、有界全局清理队列和 cleanup interval。 +- Modify: `tests/test_auth_settings.py` + 增加 settings 默认值/派生值校验,以及 rate limit cleanup 测试。 +- Modify: `app/config/settings.py` + 增加新配置字段、正数校验、派生属性。 +- Modify: `deploy/env/.env.example` + 补充新配置默认值。 +- Modify: `app/services/permission_service.py` + 用 `RefCountedLockRegistry` 替换 `_permission_locks` dict。 +- Modify: `app/services/task_service.py` + 将 settings 中的 permission lock 清理配置传给 `PermissionService`。 +- Modify: `app/bootstrap.py` + 将 task store、rate limit、JSONL sync registry、session event registry 接入配置。 +- Modify: `app/bootstrap_base.py` + 更新 `_jsonl_sync_locks` / `_session_event_locks` 类型声明。 +- Modify: `app/bootstrap_mixins.py` + 用 registry 的 async context manager 替换 dict lock 访问;stop 时调用 registry `clear()`。 +- Modify: `tests/test_bootstrap_hooks.py` + 更新已有 JSONL lock 测试,并补充 session event lock 清理验证。 + +--- + +### Task 1: RefCountedLockRegistry + +**Files:** +- Create: `tests/test_lock_registry.py` +- Create: `app/services/lock_registry.py` + +- [ ] **Step 1: Write failing registry tests** + +Create `tests/test_lock_registry.py` with: + +```python +from __future__ import annotations + +import asyncio + +import pytest + +from app.services.lock_registry import RefCountedLockRegistry + + +@pytest.mark.asyncio +async def test_registry_serializes_same_key() -> None: + registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=60, cleanup_batch_size=50) + entered: list[str] = [] + release_first = asyncio.Event() + first_entered = asyncio.Event() + + async def worker(name: str) -> None: + async with registry.lock("tool-1"): + entered.append(name) + if name == "first": + first_entered.set() + await release_first.wait() + + first = asyncio.create_task(worker("first")) + await first_entered.wait() + second = asyncio.create_task(worker("second")) + await asyncio.sleep(0) + + assert entered == ["first"] + + release_first.set() + await first + await second + + assert entered == ["first", "second"] + + +@pytest.mark.asyncio +async def test_registry_keeps_referenced_key_during_cleanup() -> None: + now = 100.0 + + def clock() -> float: + return now + + registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock) + entered = await registry.lock("tool-1").__aenter__() + assert entered is None + + now = 200.0 + await registry.cleanup_expired() + + assert len(registry) == 1 + + await registry.lock("tool-1").__aexit__(None, None, None) + await registry.cleanup_expired() + + assert len(registry) == 0 + + +@pytest.mark.asyncio +async def test_registry_requeues_key_after_delete_and_recreate() -> None: + now = 100.0 + + def clock() -> float: + return now + + registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock) + + async with registry.lock("tool-1"): + pass + + now = 200.0 + await registry.cleanup_expired() + assert len(registry) == 0 + assert registry.queued_count == 0 + + async with registry.lock("tool-1"): + pass + + assert len(registry) == 1 + assert registry.queued_count == 1 + + +@pytest.mark.asyncio +async def test_registry_cleanup_batch_size_limits_work_per_pass() -> None: + now = 100.0 + + def clock() -> float: + return now + + registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=2, clock=clock) + for key in ("a", "b", "c"): + async with registry.lock(key): + pass + + now = 200.0 + await registry.cleanup_expired() + + assert len(registry) == 1 + assert registry.queued_count == 1 + + now = 202.0 + await registry.cleanup_expired() + + assert len(registry) == 0 + assert registry.queued_count == 0 + + +@pytest.mark.asyncio +async def test_registry_instances_keep_cleanup_state_independent() -> None: + now = 100.0 + + def clock() -> float: + return now + + first = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock) + second = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock) + + async with first.lock("a"): + pass + async with second.lock("b"): + pass + + now = 200.0 + await first.cleanup_expired() + + assert len(first) == 0 + assert len(second) == 1 + + await second.cleanup_expired() + assert len(second) == 0 +``` + +- [ ] **Step 2: Run registry tests and verify they fail** + +Run: + +```bash +pytest -q tests/test_lock_registry.py +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.lock_registry'`. + +- [ ] **Step 3: Implement RefCountedLockRegistry** + +Create `app/services/lock_registry.py` with: + +```python +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class _LockEntry: + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + ref_count: int = 0 + last_used: float = 0.0 + + +class RefCountedLockRegistry: + def __init__( + self, + *, + ttl_sec: int, + cleanup_interval_sec: int, + cleanup_batch_size: int, + clock: Callable[[], float] | None = None, + ) -> None: + if ttl_sec <= 0: + raise ValueError("ttl_sec must be positive") + if cleanup_interval_sec <= 0: + raise ValueError("cleanup_interval_sec must be positive") + if cleanup_batch_size <= 0: + raise ValueError("cleanup_batch_size must be positive") + self._ttl_sec = ttl_sec + self._cleanup_interval_sec = cleanup_interval_sec + self._cleanup_batch_size = cleanup_batch_size + self._clock = clock + self._entries: dict[str, _LockEntry] = {} + self._cleanup_queue: deque[str] = deque() + self._cleanup_queued: set[str] = set() + self._last_cleanup_ts = 0.0 + self._registry_lock = asyncio.Lock() + + def __len__(self) -> int: + return len(self._entries) + + @property + def queued_count(self) -> int: + return len(self._cleanup_queued) + + @asynccontextmanager + async def lock(self, key: str) -> AsyncIterator[None]: + entry = await self._acquire_entry(key) + try: + async with entry.lock: + yield None + finally: + await self._release_entry(key, entry) + + async def cleanup_key(self, key: str, *, require_expired: bool = True) -> None: + async with self._registry_lock: + self._cleanup_key_locked(key, now=self._now(), require_expired=require_expired) + + async def cleanup_expired(self) -> None: + async with self._registry_lock: + now = self._now() + if now - self._last_cleanup_ts < self._cleanup_interval_sec: + return + self._last_cleanup_ts = now + for _ in range(min(self._cleanup_batch_size, len(self._cleanup_queue))): + key = self._cleanup_queue.popleft() + self._cleanup_queued.discard(key) + entry = self._entries.get(key) + if entry is None: + continue + if self._can_delete_entry(entry, now=now, require_expired=True): + self._entries.pop(key, None) + continue + self._enqueue_locked(key) + + async def clear(self) -> None: + async with self._registry_lock: + self._entries.clear() + self._cleanup_queue.clear() + self._cleanup_queued.clear() + + async def _acquire_entry(self, key: str) -> _LockEntry: + async with self._registry_lock: + now = self._now() + entry = self._entries.get(key) + if entry is None: + entry = _LockEntry(last_used=now) + self._entries[key] = entry + entry.ref_count += 1 + self._enqueue_locked(key) + return entry + + async def _release_entry(self, key: str, entry: _LockEntry) -> None: + async with self._registry_lock: + current = self._entries.get(key) + if current is entry: + entry.ref_count = max(0, entry.ref_count - 1) + entry.last_used = self._now() + self._cleanup_key_locked(key, now=entry.last_used, require_expired=True) + await self.cleanup_expired() + + def _cleanup_key_locked(self, key: str, *, now: float, require_expired: bool) -> None: + entry = self._entries.get(key) + if entry is None: + self._cleanup_queued.discard(key) + return + if self._can_delete_entry(entry, now=now, require_expired=require_expired): + self._entries.pop(key, None) + self._cleanup_queued.discard(key) + + def _can_delete_entry(self, entry: _LockEntry, *, now: float, require_expired: bool) -> bool: + if entry.ref_count != 0 or entry.lock.locked(): + return False + if require_expired and now - entry.last_used < self._ttl_sec: + return False + return True + + def _enqueue_locked(self, key: str) -> None: + if key not in self._cleanup_queued: + self._cleanup_queue.append(key) + self._cleanup_queued.add(key) + + def _now(self) -> float: + if self._clock is not None: + return self._clock() + return asyncio.get_running_loop().time() +``` + +- [ ] **Step 4: Run registry tests and verify they pass** + +Run: + +```bash +pytest -q tests/test_lock_registry.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit registry** + +Run: + +```bash +git add app/services/lock_registry.py tests/test_lock_registry.py +git commit -m "$(cat <<'EOF' +feat: add ref-counted lock registry +EOF +)" +``` + +--- + +### Task 2: MemoryTaskStore TTL and capacity eviction + +**Files:** +- Create: `tests/test_memory_task_store.py` +- Modify: `app/adapters/storage/memory.py` + +- [ ] **Step 1: Write failing MemoryTaskStore tests** + +Create `tests/test_memory_task_store.py` with: + +```python +from __future__ import annotations + +import asyncio +from datetime import timedelta + +import pytest + +from app.adapters.storage.memory import MemoryTaskStore +from app.domain.models import TaskRecord, TaskStatus, utc_now + + +def _task(task_id: str, *, status: TaskStatus, created_offset_hours: int = 0, ended_offset_hours: int | None = None) -> TaskRecord: + now = utc_now() + created_at = now + timedelta(hours=created_offset_hours) + ended_at = None if ended_offset_hours is None else now + timedelta(hours=ended_offset_hours) + return TaskRecord( + task_id=task_id, + session_id=f"session-{task_id}", + user_id=1, + provider="claude_code", + prompt="hi", + workdir="/tmp", + timeout_sec=10, + status=status, + created_at=created_at, + ended_at=ended_at, + ) + + +@pytest.mark.asyncio +async def test_memory_task_store_evicts_expired_final_task_by_ended_at() -> None: + store = MemoryTaskStore(max_records=100, ttl_hours=24) + await store.add(_task("old", status=TaskStatus.SUCCEEDED, created_offset_hours=-200, ended_offset_hours=-25)) + await store.add(_task("new", status=TaskStatus.SUCCEEDED, created_offset_hours=-200, ended_offset_hours=-23)) + + assert await store.get("old") is None + assert await store.get("new") is not None + + +@pytest.mark.asyncio +async def test_memory_task_store_falls_back_to_created_at_when_final_task_has_no_ended_at() -> None: + store = MemoryTaskStore(max_records=100, ttl_hours=24) + await store.add(_task("old", status=TaskStatus.FAILED, created_offset_hours=-25, ended_offset_hours=None)) + + assert await store.get("old") is None + + +@pytest.mark.asyncio +async def test_memory_task_store_never_evicts_running_task_for_ttl_or_capacity() -> None: + store = MemoryTaskStore(max_records=1, ttl_hours=1) + await store.add(_task("running", status=TaskStatus.RUNNING, created_offset_hours=-100, ended_offset_hours=None)) + await store.add(_task("final", status=TaskStatus.SUCCEEDED, created_offset_hours=-1, ended_offset_hours=0)) + + assert await store.get("running") is not None + assert await store.get("final") is None + + +@pytest.mark.asyncio +async def test_memory_task_store_evicts_oldest_final_tasks_when_over_capacity() -> None: + store = MemoryTaskStore(max_records=2, ttl_hours=168) + await store.add(_task("old", status=TaskStatus.SUCCEEDED, created_offset_hours=-5, ended_offset_hours=-5)) + await store.add(_task("middle", status=TaskStatus.SUCCEEDED, created_offset_hours=-4, ended_offset_hours=-4)) + await store.add(_task("new", status=TaskStatus.SUCCEEDED, created_offset_hours=-3, ended_offset_hours=-3)) + + ids = [task.task_id for task in await store.list_by_user(user_id=1, limit=10)] + + assert ids == ["new", "middle"] + + +@pytest.mark.asyncio +async def test_memory_task_store_large_eviction_path_keeps_latest_records() -> None: + store = MemoryTaskStore(max_records=1000, ttl_hours=168) + now = utc_now() + for i in range(10_000): + await store.add( + TaskRecord( + task_id=f"task-{i}", + session_id=f"session-{i}", + user_id=1, + provider="claude_code", + prompt="hi", + workdir="/tmp", + timeout_sec=10, + status=TaskStatus.SUCCEEDED, + created_at=now + timedelta(seconds=i), + ended_at=now + timedelta(seconds=i), + ) + ) + + records = await store.list_by_user(user_id=1, limit=2000) + + assert len(records) == 1000 + assert records[0].task_id == "task-9999" + assert records[-1].task_id == "task-9000" + + +@pytest.mark.asyncio +async def test_memory_task_store_concurrent_access_keeps_running_tasks() -> None: + store = MemoryTaskStore(max_records=5, ttl_hours=1) + + async def add_running(index: int) -> None: + await store.add(_task(f"running-{index}", status=TaskStatus.RUNNING, created_offset_hours=-10, ended_offset_hours=None)) + + async def add_final(index: int) -> None: + await store.add(_task(f"final-{index}", status=TaskStatus.SUCCEEDED, created_offset_hours=-10, ended_offset_hours=-10)) + + await asyncio.gather(*(add_running(i) for i in range(10)), *(add_final(i) for i in range(10))) + records = list(await store.iter_all()) + + assert {record.task_id for record in records} == {f"running-{i}" for i in range(10)} +``` + +- [ ] **Step 2: Run MemoryTaskStore tests and verify they fail** + +Run: + +```bash +pytest -q tests/test_memory_task_store.py +``` + +Expected: FAIL with `TypeError: MemoryTaskStore.__init__() got an unexpected keyword argument 'max_records'`. + +- [ ] **Step 3: Implement MemoryTaskStore eviction** + +Replace the `MemoryTaskStore` class in `app/adapters/storage/memory.py` with: + +```python +class MemoryTaskStore: + def __init__(self, *, max_records: int = 1000, ttl_hours: int = 168) -> None: + if max_records <= 0: + raise ValueError("max_records must be positive") + if ttl_hours <= 0: + raise ValueError("ttl_hours must be positive") + self._tasks: dict[str, TaskRecord] = {} + self._max_records = max_records + self._ttl = timedelta(hours=ttl_hours) + self._lock = asyncio.Lock() + + async def add(self, record: TaskRecord) -> None: + async with self._lock: + self._evict_expired_and_overflow_locked() + self._tasks[record.task_id] = record + self._evict_expired_and_overflow_locked() + + async def get(self, task_id: str) -> TaskRecord | None: + async with self._lock: + self._evict_expired_and_overflow_locked() + return self._tasks.get(task_id) + + async def save(self, record: TaskRecord) -> None: + async with self._lock: + self._evict_expired_and_overflow_locked() + self._tasks[record.task_id] = record + self._evict_expired_and_overflow_locked() + + async def list_by_user(self, user_id: int, limit: int = 10) -> list[TaskRecord]: + async with self._lock: + self._evict_expired_and_overflow_locked() + items = [x for x in self._tasks.values() if x.user_id == user_id] + items.sort(key=lambda x: x.created_at, reverse=True) + return items[:limit] + + async def iter_all(self) -> Iterable[TaskRecord]: + async with self._lock: + self._evict_expired_and_overflow_locked() + return list(self._tasks.values()) + + def _evict_expired_and_overflow_locked(self) -> None: + now = utc_now() + expired_ids = [ + task_id + for task_id, record in self._tasks.items() + if record.is_final and now - self._retention_time(record) > self._ttl + ] + for task_id in expired_ids: + self._tasks.pop(task_id, None) + + overflow = len(self._tasks) - self._max_records + if overflow <= 0: + return + + final_records = sorted( + (record for record in self._tasks.values() if record.is_final), + key=self._retention_time, + ) + for record in final_records[:overflow]: + self._tasks.pop(record.task_id, None) + + def _retention_time(self, record: TaskRecord) -> datetime: + return record.ended_at or record.created_at +``` + +Also update imports at the top of `app/adapters/storage/memory.py`: + +```python +from datetime import datetime, timedelta + +from app.domain.models import SessionContext, TaskRecord, utc_now +``` + +- [ ] **Step 4: Run MemoryTaskStore tests and existing task tests** + +Run: + +```bash +pytest -q tests/test_memory_task_store.py tests/test_task_service.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit MemoryTaskStore eviction** + +Run: + +```bash +git add app/adapters/storage/memory.py tests/test_memory_task_store.py +git commit -m "$(cat <<'EOF' +feat: bound in-memory task store +EOF +)" +``` + +--- + +### Task 3: RateLimitMiddleware bounded bucket cleanup + +**Files:** +- Modify: `tests/test_auth_settings.py` +- Modify: `app/bot/middleware/rate_limit.py` + +- [ ] **Step 1: Add failing rate-limit cleanup tests** + +Append these tests after `test_rate_limit_middleware_limits_callback_query_user` in `tests/test_auth_settings.py`: + +```python +@pytest.mark.asyncio +async def test_rate_limit_middleware_deletes_empty_current_bucket_after_window() -> None: + middleware = RateLimitMiddleware(limit=1, window_sec=1, bucket_ttl_sec=1, cleanup_interval_sec=60, cleanup_batch_size=50) + callback = DummyCallbackQuery(user_id=1) + + first = await middleware(_passing_handler, callback, {}) + assert first == "ok" + assert 1 in middleware._buckets + + now = __import__("asyncio").get_running_loop().time() + middleware._buckets[1].clear() + middleware._buckets[1].append(now - 2) + + second = await middleware(_passing_handler, callback, {}) + + assert second == "ok" + assert list(middleware._buckets[1]) + + +@pytest.mark.asyncio +async def test_rate_limit_global_cleanup_is_interval_and_batch_limited() -> None: + middleware = RateLimitMiddleware(limit=2, window_sec=10, bucket_ttl_sec=10, cleanup_interval_sec=60, cleanup_batch_size=2) + loop = __import__("asyncio").get_running_loop() + now = loop.time() + for user_id in range(1, 6): + middleware._buckets[user_id].append(now - 100) + middleware._enqueue_bucket_locked(user_id) + middleware._last_cleanup_ts = now + + callback = DummyCallbackQuery(user_id=99) + await middleware(_passing_handler, callback, {}) + + assert {1, 2, 3, 4, 5}.issubset(middleware._buckets) + + middleware._last_cleanup_ts = now - 61 + await middleware(_passing_handler, callback, {}) + + remaining_old_ids = {user_id for user_id in range(1, 6) if user_id in middleware._buckets} + assert len(remaining_old_ids) == 3 + + +@pytest.mark.asyncio +async def test_rate_limit_bucket_recreated_after_delete_reenters_cleanup_queue() -> None: + middleware = RateLimitMiddleware(limit=2, window_sec=10, bucket_ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50) + loop = __import__("asyncio").get_running_loop() + now = loop.time() + middleware._buckets[1].append(now - 100) + middleware._enqueue_bucket_locked(1) + middleware._last_cleanup_ts = now - 2 + + await middleware(DummyCallbackQuery(user_id=2).__class__.answer, DummyCallbackQuery(user_id=None), {}) +``` + +Replace the last test above immediately with this corrected version, which uses the normal handler and verifies requeue behavior: + +```python +@pytest.mark.asyncio +async def test_rate_limit_bucket_recreated_after_delete_reenters_cleanup_queue() -> None: + middleware = RateLimitMiddleware(limit=2, window_sec=10, bucket_ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50) + loop = __import__("asyncio").get_running_loop() + now = loop.time() + middleware._buckets[1].append(now - 100) + middleware._enqueue_bucket_locked(1) + middleware._last_cleanup_ts = now - 2 + + await middleware(_passing_handler, DummyCallbackQuery(user_id=2), {}) + + assert 1 not in middleware._buckets + assert 1 not in middleware._cleanup_queued + + await middleware(_passing_handler, DummyCallbackQuery(user_id=1), {}) + + assert 1 in middleware._buckets + assert 1 in middleware._cleanup_queued +``` + +- [ ] **Step 2: Run rate-limit tests and verify they fail** + +Run: + +```bash +pytest -q tests/test_auth_settings.py::test_rate_limit_middleware_deletes_empty_current_bucket_after_window tests/test_auth_settings.py::test_rate_limit_global_cleanup_is_interval_and_batch_limited tests/test_auth_settings.py::test_rate_limit_bucket_recreated_after_delete_reenters_cleanup_queue +``` + +Expected: FAIL with `TypeError: RateLimitMiddleware.__init__() got an unexpected keyword argument 'bucket_ttl_sec'`. + +- [ ] **Step 3: Implement bounded bucket cleanup** + +Replace `app/bot/middleware/rate_limit.py` with: + +```python +from __future__ import annotations + +import asyncio +from collections import deque +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware + + +class RateLimitMiddleware(BaseMiddleware): + def __init__( + self, + *, + limit: int, + window_sec: int, + bucket_ttl_sec: int | None = None, + cleanup_interval_sec: int = 60, + cleanup_batch_size: int = 50, + ) -> None: + super().__init__() + if limit <= 0: + raise ValueError("limit must be positive") + if window_sec <= 0: + raise ValueError("window_sec must be positive") + effective_bucket_ttl_sec = bucket_ttl_sec if bucket_ttl_sec is not None else window_sec + if effective_bucket_ttl_sec <= 0: + raise ValueError("bucket_ttl_sec must be positive") + if cleanup_interval_sec <= 0: + raise ValueError("cleanup_interval_sec must be positive") + if cleanup_batch_size <= 0: + raise ValueError("cleanup_batch_size must be positive") + self._limit = limit + self._window_sec = window_sec + self._bucket_ttl_sec = effective_bucket_ttl_sec + self._cleanup_interval_sec = cleanup_interval_sec + self._cleanup_batch_size = cleanup_batch_size + self._buckets: dict[int, deque[float]] = {} + self._cleanup_queue: deque[int] = deque() + self._cleanup_queued: set[int] = set() + self._last_cleanup_ts = 0.0 + self._lock = asyncio.Lock() + + async def __call__( + self, + handler: Callable[[Any, dict], Awaitable], + event: Any, + data: dict, + ): + user = event.from_user + if user is None: + return await handler(event, data) + + limited = False + now = asyncio.get_running_loop().time() + async with self._lock: + bucket = self._buckets.get(user.id) + if bucket is None: + bucket = deque() + self._buckets[user.id] = bucket + self._enqueue_bucket_locked(user.id) + + self._prune_bucket_locked(bucket, now=now) + if len(bucket) >= self._limit: + limited = True + else: + bucket.append(now) + + self._maybe_cleanup_buckets_locked(now=now) + + if limited: + await event.answer("请求过于频繁,请稍后再试。") + return None + + return await handler(event, data) + + def _maybe_cleanup_buckets_locked(self, *, now: float) -> None: + if now - self._last_cleanup_ts < self._cleanup_interval_sec: + return + self._last_cleanup_ts = now + for _ in range(min(self._cleanup_batch_size, len(self._cleanup_queue))): + user_id = self._cleanup_queue.popleft() + self._cleanup_queued.discard(user_id) + bucket = self._buckets.get(user_id) + if bucket is None: + continue + self._prune_bucket_locked(bucket, now=now) + if not bucket or now - bucket[-1] > self._bucket_ttl_sec: + self._delete_bucket_locked(user_id) + continue + self._enqueue_bucket_locked(user_id) + + def _prune_bucket_locked(self, bucket: deque[float], *, now: float) -> None: + while bucket and now - bucket[0] > self._window_sec: + bucket.popleft() + + def _delete_bucket_locked(self, user_id: int) -> None: + self._buckets.pop(user_id, None) + self._cleanup_queued.discard(user_id) + + def _enqueue_bucket_locked(self, user_id: int) -> None: + if user_id not in self._cleanup_queued: + self._cleanup_queue.append(user_id) + self._cleanup_queued.add(user_id) +``` + +- [ ] **Step 4: Run rate-limit tests** + +Run: + +```bash +pytest -q tests/test_auth_settings.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit rate-limit cleanup** + +Run: + +```bash +git add app/bot/middleware/rate_limit.py tests/test_auth_settings.py +git commit -m "$(cat <<'EOF' +feat: bound rate limit buckets +EOF +)" +``` + +--- + +### Task 4: Settings and environment configuration + +**Files:** +- Modify: `tests/test_auth_settings.py` +- Modify: `app/config/settings.py` +- Modify: `deploy/env/.env.example` + +- [ ] **Step 1: Back up `.env.example` before editing** + +Run: + +```bash +mkdir -p /Users/jack/project/remote-coding/.claude/backups +cp /Users/jack/project/remote-coding/deploy/env/.env.example /Users/jack/project/remote-coding/.claude/backups/.env.example +``` + +Expected: backup exists at `.claude/backups/.env.example`. + +- [ ] **Step 2: Add failing settings tests** + +Add this test after `test_settings_parse_claude_hook_fields` in `tests/test_auth_settings.py`: + +```python +def test_settings_parse_memory_cleanup_fields_and_effective_defaults() -> None: + settings = Settings.model_validate( + { + "TG_BOT_TOKEN": "token", + "TG_ALLOWED_USER_IDS": "1", + "DEFAULT_PROVIDER": "claude_code", + "DEFAULT_TIMEOUT_SEC": 10, + "MAX_CONCURRENT_TASKS": 1, + "CLAUDE_TMUX_MODE": False, + "CLAUDE_CLI_BIN": "claude", + "CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC": 45, + "CODEX_CLI_BIN": "codex", + "GEMINI_CLI_BIN": "gemini", + "ALLOWED_WORKDIRS": "/tmp", + "RATE_LIMIT_WINDOW_SEC": 12, + } + ) + + assert settings.task_store_ttl_hours == 168 + assert settings.task_store_max_records == 1000 + assert settings.effective_rate_limit_bucket_ttl_sec == 12 + assert settings.rate_limit_bucket_cleanup_interval_sec == 60 + assert settings.rate_limit_bucket_cleanup_batch_size == 50 + assert settings.effective_permission_lock_ttl_sec == 45 + assert settings.session_lock_ttl_sec == 3600 + assert settings.lock_cleanup_interval_sec == 60 + assert settings.lock_cleanup_batch_size == 50 + + +def test_settings_accepts_explicit_memory_cleanup_overrides() -> None: + settings = Settings.model_validate( + { + "TG_BOT_TOKEN": "token", + "TG_ALLOWED_USER_IDS": "1", + "DEFAULT_PROVIDER": "claude_code", + "DEFAULT_TIMEOUT_SEC": 10, + "MAX_CONCURRENT_TASKS": 1, + "CLAUDE_TMUX_MODE": False, + "CLAUDE_CLI_BIN": "claude", + "CODEX_CLI_BIN": "codex", + "GEMINI_CLI_BIN": "gemini", + "ALLOWED_WORKDIRS": "/tmp", + "TASK_STORE_TTL_HOURS": 24, + "TASK_STORE_MAX_RECORDS": 10, + "RATE_LIMIT_BUCKET_TTL_SEC": 30, + "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC": 5, + "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE": 3, + "PERMISSION_LOCK_TTL_SEC": 40, + "SESSION_LOCK_TTL_SEC": 50, + "LOCK_CLEANUP_INTERVAL_SEC": 6, + "LOCK_CLEANUP_BATCH_SIZE": 4, + } + ) + + assert settings.task_store_ttl_hours == 24 + assert settings.task_store_max_records == 10 + assert settings.effective_rate_limit_bucket_ttl_sec == 30 + assert settings.rate_limit_bucket_cleanup_interval_sec == 5 + assert settings.rate_limit_bucket_cleanup_batch_size == 3 + assert settings.effective_permission_lock_ttl_sec == 40 + assert settings.session_lock_ttl_sec == 50 + assert settings.lock_cleanup_interval_sec == 6 + assert settings.lock_cleanup_batch_size == 4 + + +def test_settings_rejects_non_positive_memory_cleanup_fields() -> None: + base_payload = { + "TG_BOT_TOKEN": "token", + "TG_ALLOWED_USER_IDS": "1", + "DEFAULT_PROVIDER": "claude_code", + "DEFAULT_TIMEOUT_SEC": 10, + "MAX_CONCURRENT_TASKS": 1, + "CLAUDE_TMUX_MODE": False, + "CLAUDE_CLI_BIN": "claude", + "CODEX_CLI_BIN": "codex", + "GEMINI_CLI_BIN": "gemini", + "ALLOWED_WORKDIRS": "/tmp", + } + + for field in ( + "TASK_STORE_TTL_HOURS", + "TASK_STORE_MAX_RECORDS", + "RATE_LIMIT_BUCKET_TTL_SEC", + "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC", + "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE", + "PERMISSION_LOCK_TTL_SEC", + "SESSION_LOCK_TTL_SEC", + "LOCK_CLEANUP_INTERVAL_SEC", + "LOCK_CLEANUP_BATCH_SIZE", + ): + with pytest.raises(ValidationError): + Settings.model_validate({**base_payload, field: 0}) +``` + +Extend `test_env_example_matches_supported_claude_settings` with: + +```python + assert "TASK_STORE_TTL_HOURS=168" in content + assert "TASK_STORE_MAX_RECORDS=1000" in content + assert "RATE_LIMIT_BUCKET_TTL_SEC=" in content + assert "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60" in content + assert "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50" in content + assert "PERMISSION_LOCK_TTL_SEC=" in content + assert "SESSION_LOCK_TTL_SEC=3600" in content + assert "LOCK_CLEANUP_INTERVAL_SEC=60" in content + assert "LOCK_CLEANUP_BATCH_SIZE=50" in content +``` + +- [ ] **Step 3: Run settings tests and verify they fail** + +Run: + +```bash +pytest -q tests/test_auth_settings.py::test_settings_parse_memory_cleanup_fields_and_effective_defaults tests/test_auth_settings.py::test_settings_accepts_explicit_memory_cleanup_overrides tests/test_auth_settings.py::test_settings_rejects_non_positive_memory_cleanup_fields tests/test_auth_settings.py::test_env_example_matches_supported_claude_settings +``` + +Expected: FAIL with missing `task_store_ttl_hours` or missing `.env.example` entries. + +- [ ] **Step 4: Add settings fields and validators** + +In `app/config/settings.py`, add fields after `rate_limit_window_sec`: + +```python + task_store_ttl_hours: int = Field(168, alias="TASK_STORE_TTL_HOURS") + task_store_max_records: int = Field(1000, alias="TASK_STORE_MAX_RECORDS") + rate_limit_bucket_ttl_sec: int | None = Field(None, alias="RATE_LIMIT_BUCKET_TTL_SEC") + rate_limit_bucket_cleanup_interval_sec: int = Field(60, alias="RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC") + rate_limit_bucket_cleanup_batch_size: int = Field(50, alias="RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE") + permission_lock_ttl_sec: int | None = Field(None, alias="PERMISSION_LOCK_TTL_SEC") + session_lock_ttl_sec: int = Field(3600, alias="SESSION_LOCK_TTL_SEC") + lock_cleanup_interval_sec: int = Field(60, alias="LOCK_CLEANUP_INTERVAL_SEC") + lock_cleanup_batch_size: int = Field(50, alias="LOCK_CLEANUP_BATCH_SIZE") +``` + +Add these names to the existing `validate_positive_int` field list: + +```python + "task_store_ttl_hours", + "task_store_max_records", + "rate_limit_bucket_cleanup_interval_sec", + "rate_limit_bucket_cleanup_batch_size", + "session_lock_ttl_sec", + "lock_cleanup_interval_sec", + "lock_cleanup_batch_size", +``` + +Add this validator below `validate_positive_int`: + +```python + @field_validator("rate_limit_bucket_ttl_sec", "permission_lock_ttl_sec") + @classmethod + def validate_optional_positive_int(cls, value: int | None) -> int | None: + if value is not None and value <= 0: + raise ValueError("配置值必须大于 0") + return value +``` + +Add these properties near the existing properties: + +```python + @property + def effective_rate_limit_bucket_ttl_sec(self) -> int: + return self.rate_limit_bucket_ttl_sec or self.rate_limit_window_sec + + @property + def effective_permission_lock_ttl_sec(self) -> int: + return self.permission_lock_ttl_sec or self.claude_hook_pending_permission_ttl_sec +``` + +- [ ] **Step 5: Update `.env.example`** + +Insert after `RATE_LIMIT_WINDOW_SEC=20` in `deploy/env/.env.example`: + +```dotenv +RATE_LIMIT_BUCKET_TTL_SEC= +RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60 +RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50 + +# In-memory cleanup +TASK_STORE_TTL_HOURS=168 +TASK_STORE_MAX_RECORDS=1000 +PERMISSION_LOCK_TTL_SEC= +SESSION_LOCK_TTL_SEC=3600 +LOCK_CLEANUP_INTERVAL_SEC=60 +LOCK_CLEANUP_BATCH_SIZE=50 +``` + +- [ ] **Step 6: Run settings tests** + +Run: + +```bash +pytest -q tests/test_auth_settings.py +``` + +Expected: PASS. + +- [ ] **Step 7: Commit settings and env config** + +Run: + +```bash +git add app/config/settings.py deploy/env/.env.example tests/test_auth_settings.py .claude/backups/.env.example +git commit -m "$(cat <<'EOF' +feat: add memory cleanup settings +EOF +)" +``` + +--- + +### Task 5: Wire MemoryTaskStore and RateLimitMiddleware configuration + +**Files:** +- Modify: `app/bootstrap.py` +- Test: `tests/test_auth_settings.py`, `tests/test_memory_task_store.py` + +- [ ] **Step 1: Write failing AppContainer wiring test** + +Add this test to `tests/test_auth_settings.py`: + +```python +def test_app_container_uses_memory_cleanup_settings(tmp_path) -> None: + from app.bootstrap import AppContainer + + settings = Settings.model_validate( + { + "TG_BOT_TOKEN": "123456:TESTTOKEN", + "TG_ALLOWED_USER_IDS": "1", + "DEFAULT_PROVIDER": "claude_code", + "DEFAULT_TIMEOUT_SEC": 10, + "MAX_CONCURRENT_TASKS": 1, + "CLAUDE_TMUX_MODE": False, + "TMUX_DATA_DIR": str(tmp_path), + "CLAUDE_CLI_BIN": "claude", + "CLAUDE_INSTALL_HOOKS": False, + "CLAUDE_CONFIG_DIR": str(tmp_path / ".claude"), + "CLAUDE_HOOK_SOCKET_PATH": str(tmp_path / "hook.sock"), + "CODEX_CLI_BIN": "codex", + "GEMINI_CLI_BIN": "gemini", + "ALLOWED_WORKDIRS": str(tmp_path), + "TASK_STORE_TTL_HOURS": 24, + "TASK_STORE_MAX_RECORDS": 12, + } + ) + + container = AppContainer(settings) + + assert container.task_store._ttl.total_seconds() == 24 * 60 * 60 + assert container.task_store._max_records == 12 +``` + +- [ ] **Step 2: Run wiring test and verify it fails** + +Run: + +```bash +pytest -q tests/test_auth_settings.py::test_app_container_uses_memory_cleanup_settings +``` + +Expected: FAIL because `container.task_store._max_records` remains `1000`. + +- [ ] **Step 3: Wire settings in AppContainer** + +In `app/bootstrap.py`, replace: + +```python + self.task_store = MemoryTaskStore() +``` + +with: + +```python + self.task_store = MemoryTaskStore( + max_records=settings.task_store_max_records, + ttl_hours=settings.task_store_ttl_hours, + ) +``` + +In `app/bootstrap.py`, replace the `RateLimitMiddleware` construction with: + +```python + rate_limit_middleware = RateLimitMiddleware( + limit=self.settings.rate_limit_max_requests, + window_sec=self.settings.rate_limit_window_sec, + bucket_ttl_sec=self.settings.effective_rate_limit_bucket_ttl_sec, + cleanup_interval_sec=self.settings.rate_limit_bucket_cleanup_interval_sec, + cleanup_batch_size=self.settings.rate_limit_bucket_cleanup_batch_size, + ) +``` + +- [ ] **Step 4: Run wiring and related tests** + +Run: + +```bash +pytest -q tests/test_auth_settings.py tests/test_memory_task_store.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit bootstrap wiring for store and rate limit** + +Run: + +```bash +git add app/bootstrap.py tests/test_auth_settings.py +git commit -m "$(cat <<'EOF' +feat: wire memory cleanup settings +EOF +)" +``` + +--- + +### Task 6: PermissionService lock registry wiring + +**Files:** +- Modify: `app/services/permission_service.py` +- Modify: `app/services/task_service.py` +- Test: `tests/test_task_service.py`, `tests/test_lock_registry.py` + +- [ ] **Step 1: Add failing PermissionService wiring test** + +Add this test near the existing permission tests in `tests/test_task_service.py`: + +```python +@pytest.mark.asyncio +async def test_permission_service_uses_configured_lock_registry(tmp_path: Path) -> None: + adapter = StubAdapter(events=[]) + factory = StubFactory(adapter) + session_service = make_file_backed_session_service(tmp_path) + structured_store = SessionStore(FileSessionStore(str(tmp_path))) + hook_socket_server = DummyHookSocketServer() + service = TaskService( + settings=make_settings(tmp_path, claude_tmux_mode=True), + task_store=MemoryTaskStore(), + session_service=session_service, + cli_factory=factory, + semaphore=asyncio.Semaphore(2), + structured_session_store=structured_store, + hook_socket_server=hook_socket_server, + ) + + registry = service._permission_service._permission_locks + + assert registry._ttl_sec == service._settings.effective_permission_lock_ttl_sec + assert registry._cleanup_interval_sec == service._settings.lock_cleanup_interval_sec + assert registry._cleanup_batch_size == service._settings.lock_cleanup_batch_size +``` + +- [ ] **Step 2: Run PermissionService wiring test and verify it fails** + +Run: + +```bash +pytest -q tests/test_task_service.py::test_permission_service_uses_configured_lock_registry +``` + +Expected: FAIL because `_permission_locks` is still a `dict`. + +- [ ] **Step 3: Update PermissionService constructor and lock usage** + +In `app/services/permission_service.py`, import registry: + +```python +from app.services.lock_registry import RefCountedLockRegistry +``` + +Replace `_permission_locks` initialization in `PermissionService.__init__` with constructor parameters and registry: + +```python + permission_lock_ttl_sec: int = 600, + lock_cleanup_interval_sec: int = 60, + lock_cleanup_batch_size: int = 50, +``` + +and inside the body: + +```python + self._permission_locks = RefCountedLockRegistry( + ttl_sec=permission_lock_ttl_sec, + cleanup_interval_sec=lock_cleanup_interval_sec, + cleanup_batch_size=lock_cleanup_batch_size, + ) +``` + +Replace: + +```python + async with self._get_permission_lock(lock_tool_use_id): +``` + +with: + +```python + async with self._permission_locks.lock(lock_tool_use_id): +``` + +Delete the `_get_permission_lock()` method from `PermissionService`. + +- [ ] **Step 4: Pass settings from TaskService** + +In `app/services/task_service.py`, update `PermissionService(...)` construction to include: + +```python + permission_lock_ttl_sec=settings.effective_permission_lock_ttl_sec, + lock_cleanup_interval_sec=settings.lock_cleanup_interval_sec, + lock_cleanup_batch_size=settings.lock_cleanup_batch_size, +``` + +- [ ] **Step 5: Run permission and registry tests** + +Run: + +```bash +pytest -q tests/test_lock_registry.py tests/test_task_service.py::test_permission_service_uses_configured_lock_registry tests/test_task_service.py::test_respond_to_pending_permission_uses_resolved_structured_session_not_stale_context tests/test_task_service.py::test_respond_to_pending_permission_keeps_state_when_socket_response_fails +``` + +Expected: PASS. + +- [ ] **Step 6: Commit PermissionService registry wiring** + +Run: + +```bash +git add app/services/permission_service.py app/services/task_service.py tests/test_task_service.py +git commit -m "$(cat <<'EOF' +feat: bound permission locks +EOF +)" +``` + +--- + +### Task 7: JSONL sync and session event lock registry wiring + +**Files:** +- Modify: `app/bootstrap.py` +- Modify: `app/bootstrap_base.py` +- Modify: `app/bootstrap_mixins.py` +- Modify: `tests/test_bootstrap_hooks.py` + +- [ ] **Step 1: Add failing bootstrap lock tests** + +Update `tests/test_bootstrap_hooks.py::test_sync_claude_session_uses_per_session_lock` by replacing manual dict lock setup: + +```python + lock = asyncio.Lock() + await lock.acquire() + container._jsonl_sync_locks["claude-session-1"] = lock +``` + +with registry manual entry: + +```python + held_lock = container._jsonl_sync_locks.lock("claude-session-1") + await held_lock.__aenter__() +``` + +and replace: + +```python + lock.release() +``` + +with: + +```python + await held_lock.__aexit__(None, None, None) +``` + +Add this new test after `test_session_end_keeps_pending_sync_until_flushed`: + +```python +@pytest.mark.asyncio +async def test_session_end_cleans_event_lock_registry(tmp_path) -> None: + container = AppContainer(make_settings(tmp_path, install_hooks=False)) + + await container._dispatch_session_event( + container.structured_session_store.process.__globals__["SessionEvent"]( + session_id="claude-session-ended", + type=container.structured_session_store.process.__globals__["SessionEventType"].SESSION_ENDED, + payload={"cwd": str(tmp_path)}, + ) + ) + + assert len(container._session_event_locks) == 0 +``` + +- [ ] **Step 2: Run bootstrap lock tests and verify they fail** + +Run: + +```bash +pytest -q tests/test_bootstrap_hooks.py::test_sync_claude_session_uses_per_session_lock tests/test_bootstrap_hooks.py::test_session_end_cleans_event_lock_registry +``` + +Expected: FAIL because `_jsonl_sync_locks` is still a dict and `_session_event_locks` has no registry semantics. + +- [ ] **Step 3: Update AppContainer lock registry construction** + +In `app/bootstrap.py`, add import: + +```python +from app.services.lock_registry import RefCountedLockRegistry +``` + +Replace: + +```python + self._jsonl_sync_locks: dict[str, asyncio.Lock] = {} + self._session_event_locks: dict[str, asyncio.Lock] = {} +``` + +with: + +```python + self._jsonl_sync_locks = RefCountedLockRegistry( + ttl_sec=settings.session_lock_ttl_sec, + cleanup_interval_sec=settings.lock_cleanup_interval_sec, + cleanup_batch_size=settings.lock_cleanup_batch_size, + ) + self._session_event_locks = RefCountedLockRegistry( + ttl_sec=settings.session_lock_ttl_sec, + cleanup_interval_sec=settings.lock_cleanup_interval_sec, + cleanup_batch_size=settings.lock_cleanup_batch_size, + ) +``` + +- [ ] **Step 4: Update AppContainerBase type declarations** + +In `app/bootstrap_base.py`, remove the top-level `import asyncio` if it is no longer needed after this change. Add import: + +```python +from app.services.lock_registry import RefCountedLockRegistry +``` + +Replace type declarations: + +```python + _jsonl_sync_locks: dict[str, asyncio.Lock] + _session_event_locks: dict[str, asyncio.Lock] +``` + +with: + +```python + _jsonl_sync_locks: RefCountedLockRegistry + _session_event_locks: RefCountedLockRegistry +``` + +Keep `asyncio` imported if `_jsonl_sync_tasks: dict[str, asyncio.Task[None]]` still needs it. + +- [ ] **Step 5: Update JsonlSyncMixin** + +In `app/bootstrap_mixins.py`, replace `sync_claude_session()` lock acquisition: + +```python + lock = self._jsonl_sync_locks.setdefault(session_id, asyncio.Lock()) + async with lock: +``` + +with: + +```python + async with self._jsonl_sync_locks.lock(session_id): +``` + +In `_stop_jsonl_sync_tasks()`, replace: + +```python + self._jsonl_sync_locks.clear() +``` + +with: + +```python + await self._jsonl_sync_locks.clear() +``` + +In `_debounced_sync_claude_session()` finally block, after the existing task bookkeeping, add: + +```python + if session_id not in self._jsonl_sync_requests: + await self._jsonl_sync_locks.cleanup_key(session_id) +``` + +The final block should still preserve the existing behavior that reschedules when `session_id in self._jsonl_sync_requests`. + +- [ ] **Step 6: Update EventDispatchMixin** + +In `app/bootstrap_mixins.py`, replace `_dispatch_session_event()` with: + +```python + async def _dispatch_session_event(self, event: SessionEvent) -> None: + async with self._session_event_locks.lock(event.session_id): + self.structured_session_store.get_or_create( + session_id=event.session_id, + provider="claude_code", + workdir=str(event.payload.get("cwd", ".")), + claude_session_id=event.session_id, + ) + self.structured_session_store.process(event) + if event.type == SessionEventType.SESSION_ENDED: + await self._session_event_locks.cleanup_key(event.session_id, require_expired=False) +``` + +- [ ] **Step 7: Run bootstrap tests** + +Run: + +```bash +pytest -q tests/test_bootstrap_hooks.py +``` + +Expected: PASS. + +- [ ] **Step 8: Commit bootstrap lock registry wiring** + +Before committing, verify the unrelated pre-existing `app/bootstrap_mixins.py` changes were preserved and that only intended hunks are staged: + +```bash +git diff -- app/bootstrap_mixins.py +git add app/bootstrap.py app/bootstrap_base.py app/bootstrap_mixins.py tests/test_bootstrap_hooks.py +git diff --cached -- app/bootstrap_mixins.py +``` + +If the cached diff contains unrelated user changes, stop and ask for direction. If it contains only feature hunks, commit: + +```bash +git commit -m "$(cat <<'EOF' +feat: bound session lock registries +EOF +)" +``` + +--- + +### Task 8: Final verification and cleanup + +**Files:** +- Verify all files changed in Tasks 1-7. + +- [ ] **Step 1: Run targeted tests** + +Run: + +```bash +pytest -q tests/test_lock_registry.py tests/test_memory_task_store.py tests/test_auth_settings.py tests/test_task_service.py::test_permission_service_uses_configured_lock_registry tests/test_bootstrap_hooks.py +``` + +Expected: PASS. + +- [ ] **Step 2: Run full test suite** + +Run: + +```bash +pytest -q +``` + +Expected: PASS. + +- [ ] **Step 3: Run lint on touched Python files** + +Run: + +```bash +ruff check app/services/lock_registry.py app/adapters/storage/memory.py app/bot/middleware/rate_limit.py app/config/settings.py app/services/permission_service.py app/services/task_service.py app/bootstrap.py app/bootstrap_base.py app/bootstrap_mixins.py tests/test_lock_registry.py tests/test_memory_task_store.py tests/test_auth_settings.py tests/test_task_service.py tests/test_bootstrap_hooks.py +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: only intentional tracked changes are present. `_test_task21.py` remains untracked and must not be committed. + +- [ ] **Step 5: Remove backup if it is no longer needed** + +If `.claude/backups/.env.example` exists and the `.env.example` change is verified, remove the backup in a dedicated cleanup commit or include it in the final cleanup commit only if project policy says backups should not remain. If the backup is intentionally tracked, keep it. + +Run this check: + +```bash +git status --short .claude/backups/.env.example +``` + +Expected: decision is explicit; no accidental backup is left unstaged. + +--- + +## Self-review + +- Spec coverage: task store TTL/capacity is in Task 2; rate limit bounded queue is in Task 3; settings/env are in Task 4; AppContainer wiring is in Tasks 5 and 7; permission locks are in Task 6; JSONL/session event locks are in Task 7; tests include large task store, concurrent task store, queue reentry, registry state independence, and configuration semantics. +- Placeholder scan: no `TBD`, no empty test descriptions, no unspecified validation steps. +- Type consistency: `RefCountedLockRegistry.lock()` is used as an async context manager; registry exposes `cleanup_key()`, `cleanup_expired()`, `clear()`, `__len__()`, and `queued_count`; settings properties use `effective_rate_limit_bucket_ttl_sec` and `effective_permission_lock_ttl_sec` consistently. diff --git a/.claude/backups/2026-05-26-priority-fixes-design.md b/.claude/backups/2026-05-26-priority-fixes-design.md new file mode 100644 index 0000000..0d973e2 --- /dev/null +++ b/.claude/backups/2026-05-26-priority-fixes-design.md @@ -0,0 +1,161 @@ +# Priority Fixes Design + +## Scope + +This design covers the first repair batch approved by the user: + +1. Fix upload queue behavior and memory limits. +2. Replace permission callback `tool_use_id` truncation with short tokens. +3. Clean up pending permission state and session lock state. + +This batch intentionally does not include broad performance rewrites, AppContainer/TaskService decomposition, or persistent upload/permission state. + +## Goals + +- Files uploaded during an active task are actually processed after the task finishes. +- Oversized uploads are rejected before download when Telegram exposes file size metadata. +- Upload queues cannot grow without per-user bounds. +- Telegram permission buttons work for long `tool_use_id` values without truncation or prefix matching. +- Unbound permission pending entries and session lock entries do not accumulate indefinitely. +- Existing command behavior and user-facing flows remain compatible. + +## Non-goals + +- Persist queued uploads across process restarts. +- Persist callback tokens across process restarts. +- Redesign all pending state registries into a single generic framework. +- Replace filesystem scanning/diff behavior. +- Refactor the full bootstrap or task service architecture. + +## Design 1: Upload Queue Repair + +### Current problem + +`app/bot/handlers/file_upload.py` queues uploads in `_pending_uploads` when a user has a running task, but `process_pending_uploads()` has no current app-level caller. The queue stores raw `bytes`, has no count or byte limit, and files are downloaded before size checks in `FileReceiverService`. + +### Proposed behavior + +Introduce a small upload queue manager for queued uploads. It remains in memory and user-scoped, but enforces: + +- `UPLOAD_QUEUE_MAX_FILES_PER_USER`, default `5`. +- `UPLOAD_QUEUE_MAX_BYTES_PER_USER`, default `UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024`. +- FIFO processing order. + +The file upload handlers check Telegram file size metadata before downloading: + +- `document.file_size` for documents. +- `photo.file_size` for photos when present. + +If the size is over `UPLOAD_MAX_FILE_SIZE_MB`, the handler rejects the file before downloading. After download, the existing `FileReceiverService` validation still runs as the final authority. + +When a task reaches a final state, `RunEventStreamer.stream_events()` consumes queued uploads for that user. Each queued file is saved against the user's current session workdir by reusing the existing file-processing path; this matches existing direct-upload behavior, which always resolves the workdir from `SessionService` at processing time. A failed file does not stop later queued files. + +### User-facing behavior + +- If a task is running and the queue has capacity, the bot replies that the file was queued. +- If the queue is full or byte limit is exceeded, the bot rejects the file with a clear reason. +- After task completion, each queued file produces the same success/rejection message as direct upload. + +### Tests + +Add or update tests for: + +- Download is skipped when Telegram file size exceeds the configured limit. +- Queued uploads are processed after task completion. +- Queue count and byte limits reject additional files. +- One failed queued file does not prevent later files from being processed. + +## Design 2: Permission Callback Short Tokens + +### Current problem + +Permission callback data currently embeds `tool_use_id` and truncates it to fit Telegram's 64-byte callback data limit. The permission response path expects the full `tool_use_id`, so long IDs can make buttons fail as stale or expired. + +The same issue exists for external permission callbacks. + +### Proposed behavior + +Add `PermissionCallbackRegistry`, an in-memory TTL registry mapping short tokens to full `tool_use_id` values. + +Callback data changes to: + +- Normal permissions: `perm::`. +- External permissions: `ext_perm::`. + +Button builders register the real `tool_use_id`, receive a short token, and place only that token into `callback_data`. Callback handlers resolve the token before calling permission services. If the token is missing or expired, the user sees a stale-button message. + +The token TTL should use the existing hook pending permission TTL so callback tokens do not outlive the permission request. + +### Placement + +Place the registry in `app/services/permission_callback_registry.py`. Create one instance in `AppContainer` and inject it into: + +- Normal permission handlers. +- External permission handlers. +- Unbound permission handler button creation. + +This keeps token ownership explicit without introducing a broad state framework. + +### Tests + +Add or update tests for: + +- Long `tool_use_id` values are not truncated. +- Generated callback data stays under 64 bytes. +- Normal permission callbacks resolve tokens to the full ID. +- External permission callbacks resolve tokens to the full ID. +- Expired or unknown tokens produce a clear stale-button response. + +## Design 3: Pending and Lock Cleanup + +### Unbound permissions + +`UnboundPermissionHandler` should remove entries from `_pending` when: + +- A user response is accepted and forwarded. +- The TTL expiry path auto-denies the request. + +To preserve first-responder-wins semantics under concurrent callbacks, protect `_pending` and `_expiry_tasks` mutations with a small `asyncio.Lock`. + +### Tmux session locks + +`TmuxRunner` currently keeps `_session_locks` indefinitely. Replace this dictionary with the existing `RefCountedLockRegistry`, or wrap the persistent-session critical section with an equivalent ref-counted cleanup path. Reusing `RefCountedLockRegistry` is preferred because it is already configured and tested elsewhere. + +Only persistent terminal runs need per-session serialization. Ephemeral runs keep current behavior. + +### Agent file watcher locks + +`AgentFileWatcher.forget()` and the watcher `finally` block should remove the session's lock entry and stale mtime keys. This prevents sessions that end or are forgotten from leaving lock and mtime state behind. + +### Tests + +Add or update tests for: + +- Unbound permission response removes pending state and expiry task. +- Unbound permission expiry removes pending state. +- Tmux persistent session lock count does not grow after repeated runs for completed sessions. +- Agent watcher `forget()` clears the lock and mtime keys. + +## Error Handling + +- Upload queue processing logs per-file failures and continues. +- Callback token lookup failures return stale-button messages and do not call permission responders. +- Unbound permission response failures should not leave a request indefinitely pending; failed forwarding should be logged and the request should be removed only after the response attempt completes. +- Lock cleanup must never delete a lock while it is held or while another coroutine is waiting for it. + +## Implementation Notes + +- Keep changes focused and avoid changing unrelated command behavior. +- Prefer existing service injection patterns in `AppContainer.wire()` and router registration. +- Keep user-facing messages consistent with current Chinese bot messages where the surrounding handler already uses Chinese. +- Do not add persistence unless a later batch explicitly asks for restart-safe behavior. + +## Verification + +Run targeted tests for the changed modules, then the full test suite: + +- File upload handler tests. +- Permission handler tests. +- External permission/unbound permission tests. +- Tmux runner and agent watcher cleanup tests. +- Full `pytest -q`. diff --git a/.claude/backups/2026-05-26-priority-fixes.md b/.claude/backups/2026-05-26-priority-fixes.md new file mode 100644 index 0000000..51365d1 --- /dev/null +++ b/.claude/backups/2026-05-26-priority-fixes.md @@ -0,0 +1,2271 @@ +# Priority Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Repair upload queue behavior, permission callback routing, and stale in-memory state cleanup without broad refactors. + +**Architecture:** Add two focused in-memory services: `UploadQueueManager` for bounded per-user upload queues and `PermissionCallbackRegistry` for short callback tokens. Inject them through `AppContainer` and `create_router`, then keep behavior changes localized to upload handlers, run streaming completion hooks, permission handlers, and lock-owning services. + +**Tech Stack:** Python 3.11, asyncio, aiogram 3.x, pydantic-settings, pytest/pytest-asyncio, pyenv/pyenv-virtualenv (`.python-version` is `remote-coding`). + +--- + +## File Structure + +**Create:** +- `app/services/upload_queue.py` — bounded in-memory FIFO upload queue, per-user byte totals, drain API. +- `app/services/permission_callback_registry.py` — TTL token registry mapping short callback tokens to full `tool_use_id` values. +- `tests/test_upload_queue.py` — unit tests for queue limits, FIFO drain, disabled queue. +- `tests/test_permission_callback_registry.py` — unit tests for token length, TTL expiry, collision retry. +- `tests/test_run_event_streamer_upload_queue.py` — focused tests for scheduling queued uploads after the final task message. +- `tests/test_agent_file_watcher.py` — focused watcher cleanup tests. + +**Modify:** +- `app/config/settings.py` — upload queue settings and derived byte limit. +- `deploy/env/.env.example` — document upload queue environment variables. +- `app/bootstrap.py` — instantiate and inject `UploadQueueManager` and `PermissionCallbackRegistry`; pass tmux lock cleanup settings. +- `app/bot/router.py` — thread upload queue and permission callback registry through handler registration. +- `app/bot/handlers/file_upload.py` — remove global raw queue, reject oversize metadata before download, enqueue through `UploadQueueManager`, schedule tracked background processing. +- `app/bot/handlers/command_run.py` — accept a queued-upload scheduler and pass it to `RunEventStreamer`. +- `app/bot/handlers/run_event_streamer.py` — call the scheduler immediately after each final task result is displayed. +- `app/bot/handlers/command_permission.py` — build normal permission buttons with short tokens and resolve tokens on callback. +- `app/bot/handlers/external_permission.py` — resolve `ext_perm::` callbacks. +- `app/services/unbound_permission_handler.py` — build external permission buttons with short tokens and atomically remove pending state on response/expiry. +- `app/adapters/process/tmux_runner.py` — replace persistent-session lock dictionary with `RefCountedLockRegistry`. +- Existing tests under `tests/test_file_upload_handler.py`, `tests/test_session_handlers.py`, `tests/property/test_unbound_permission_properties.py`, `tests/integration/test_external_session_pipeline.py`, `tests/test_tmux_runner.py`, `tests/test_auth_settings.py`, and `tests/test_bootstrap_hooks.py`. + +--- + +### Task 0: Pre-flight environment check + +**Files:** +- Read: `.python-version` + +- [ ] **Step 1: Confirm pyenv environment** + +Run: + +```bash +pyenv version +``` + +Expected: output contains `remote-coding` and this repository path. If it does not, run this before any Python test command: + +```bash +pyenv local remote-coding +``` + +- [ ] **Step 2: Confirm the working tree before coding** + +Run: + +```bash +git status --short +``` + +Expected: only the uncommitted plan file may appear. Do not overwrite unrelated user changes. + +--- + +### Task 1: Settings and upload queue service + +**Files:** +- Create: `app/services/upload_queue.py` +- Create: `tests/test_upload_queue.py` +- Modify: `app/config/settings.py:113-121,232-273,293-299` +- Modify: `deploy/env/.env.example:52-57` +- Modify: `tests/test_auth_settings.py:149-237` + +- [ ] **Step 1: Write failing upload queue tests** + +Create `tests/test_upload_queue.py` with this content: + +```python +from __future__ import annotations + +import pytest + +from app.services.upload_queue import UploadQueueManager + + +@pytest.mark.asyncio +async def test_upload_queue_accepts_and_drains_fifo() -> None: + queue = UploadQueueManager(max_files_per_user=3, max_bytes_per_user=100) + + first = await queue.enqueue(user_id=1, filename="a.txt", data=b"a") + second = await queue.enqueue(user_id=1, filename="b.txt", data=b"bb") + + assert first.accepted is True + assert second.accepted is True + drained = await queue.drain(user_id=1) + assert [(item.filename, item.data, item.size_bytes) for item in drained] == [ + ("a.txt", b"a", 1), + ("b.txt", b"bb", 2), + ] + assert await queue.drain(user_id=1) == [] + + +@pytest.mark.asyncio +async def test_upload_queue_rejects_when_file_count_limit_is_reached() -> None: + queue = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=100) + + accepted = await queue.enqueue(user_id=1, filename="a.txt", data=b"a") + rejected = await queue.enqueue(user_id=1, filename="b.txt", data=b"b") + + assert accepted.accepted is True + assert rejected.accepted is False + assert "队列已满" in rejected.reason + drained = await queue.drain(user_id=1) + assert [item.filename for item in drained] == ["a.txt"] + + +@pytest.mark.asyncio +async def test_upload_queue_rejects_when_byte_limit_would_be_exceeded() -> None: + queue = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=3) + + accepted = await queue.enqueue(user_id=1, filename="a.txt", data=b"aa") + rejected = await queue.enqueue(user_id=1, filename="b.txt", data=b"bb") + + assert accepted.accepted is True + assert rejected.accepted is False + assert "队列容量" in rejected.reason + drained = await queue.drain(user_id=1) + assert [(item.filename, item.size_bytes) for item in drained] == [("a.txt", 2)] + + +@pytest.mark.asyncio +async def test_upload_queue_zero_file_limit_disables_queueing() -> None: + queue = UploadQueueManager(max_files_per_user=0, max_bytes_per_user=100) + + result = await queue.enqueue(user_id=1, filename="a.txt", data=b"a") + + assert result.accepted is False + assert "上传队列已关闭" in result.reason + assert await queue.drain(user_id=1) == [] +``` + +- [ ] **Step 2: Run upload queue tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_upload_queue.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.upload_queue'`. + +- [ ] **Step 3: Implement `UploadQueueManager`** + +Create `app/services/upload_queue.py` with this content: + +```python +from __future__ import annotations + +import asyncio +from collections import defaultdict, deque +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class QueuedUpload: + filename: str + data: bytes + size_bytes: int + + +@dataclass(frozen=True, slots=True) +class UploadQueueEnqueueResult: + accepted: bool + reason: str = "" + + +class UploadQueueManager: + def __init__(self, *, max_files_per_user: int, max_bytes_per_user: int) -> None: + if max_files_per_user < 0: + raise ValueError("max_files_per_user must be non-negative") + if max_bytes_per_user < 0: + raise ValueError("max_bytes_per_user must be non-negative") + self._max_files_per_user = max_files_per_user + self._max_bytes_per_user = max_bytes_per_user + self._queues: dict[int, deque[QueuedUpload]] = defaultdict(deque) + self._byte_totals: dict[int, int] = defaultdict(int) + self._lock = asyncio.Lock() + + async def enqueue(self, *, user_id: int, filename: str, data: bytes) -> UploadQueueEnqueueResult: + size_bytes = len(data) + async with self._lock: + if self._max_files_per_user == 0: + return UploadQueueEnqueueResult(False, "上传队列已关闭,请等待当前任务完成后重新上传。") + + queue = self._queues[user_id] + if len(queue) >= self._max_files_per_user: + return UploadQueueEnqueueResult(False, f"队列已满,最多允许排队 {self._max_files_per_user} 个文件。") + + current_total = self._byte_totals[user_id] + if current_total + size_bytes > self._max_bytes_per_user: + return UploadQueueEnqueueResult( + False, + f"队列容量不足,当前排队 {current_total} 字节,本文件 {size_bytes} 字节,上限 {self._max_bytes_per_user} 字节。", + ) + + queue.append(QueuedUpload(filename=filename, data=data, size_bytes=size_bytes)) + self._byte_totals[user_id] = current_total + size_bytes + return UploadQueueEnqueueResult(True) + + async def drain(self, *, user_id: int) -> list[QueuedUpload]: + async with self._lock: + queue = self._queues.pop(user_id, deque()) + self._byte_totals.pop(user_id, None) + return list(queue) + + async def queued_count(self, *, user_id: int) -> int: + async with self._lock: + return len(self._queues.get(user_id, ())) +``` + +- [ ] **Step 4: Run upload queue tests and verify they pass** + +Run: + +```bash +python -m pytest tests/test_upload_queue.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Write failing settings tests** + +In `tests/test_auth_settings.py`, extend `test_settings_new_fields_defaults`: + +```python + assert settings.upload_queue_max_files_per_user == 5 + assert settings.upload_queue_max_bytes_per_user is None + assert settings.effective_upload_queue_max_bytes_per_user == 5 * 20 * 1024 * 1024 +``` + +Extend `test_settings_explicit_override_new_fields` payload: + +```python + "UPLOAD_QUEUE_MAX_FILES_PER_USER": 2, + "UPLOAD_QUEUE_MAX_BYTES_PER_USER": 1234, +``` + +Extend the assertions in that same test: + +```python + assert settings.upload_queue_max_files_per_user == 2 + assert settings.upload_queue_max_bytes_per_user == 1234 + assert settings.effective_upload_queue_max_bytes_per_user == 1234 +``` + +Add this test near the other settings validation tests: + +```python +def test_settings_allows_upload_queue_disabled_with_zero_files() -> None: + settings = Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_FILES_PER_USER": 0}) + + assert settings.upload_queue_max_files_per_user == 0 + assert settings.effective_upload_queue_max_bytes_per_user == 0 + + +def test_settings_rejects_invalid_upload_queue_values() -> None: + with pytest.raises(ValidationError): + Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_FILES_PER_USER": -1}) + with pytest.raises(ValidationError): + Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_BYTES_PER_USER": 0}) +``` + +Extend `test_env_example_contains_new_entries`: + +```python + assert "UPLOAD_MAX_FILE_SIZE_MB=20" in content + assert "UPLOAD_QUEUE_MAX_FILES_PER_USER=5" in content + assert "UPLOAD_QUEUE_MAX_BYTES_PER_USER=" in content +``` + +- [ ] **Step 6: Run settings tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_auth_settings.py::test_settings_new_fields_defaults tests/test_auth_settings.py::test_settings_explicit_override_new_fields tests/test_auth_settings.py::test_settings_allows_upload_queue_disabled_with_zero_files tests/test_auth_settings.py::test_settings_rejects_invalid_upload_queue_values tests/test_auth_settings.py::test_env_example_contains_new_entries -q +``` + +Expected: FAIL because the new settings and `.env.example` entries do not exist yet. + +- [ ] **Step 7: Implement upload queue settings** + +In `app/config/settings.py`, add these fields after `upload_max_file_size_mb`: + +```python + upload_queue_max_files_per_user: int = Field(5, alias="UPLOAD_QUEUE_MAX_FILES_PER_USER") + upload_queue_max_bytes_per_user: int | None = Field(None, alias="UPLOAD_QUEUE_MAX_BYTES_PER_USER") +``` + +Add this validator after `validate_positive_int`: + +```python + @field_validator("upload_queue_max_files_per_user") + @classmethod + def validate_non_negative_int(cls, value: int) -> int: + if value < 0: + raise ValueError("配置值必须大于等于 0") + return value +``` + +Extend `validate_optional_positive_int` to include `upload_queue_max_bytes_per_user`: + +```python + @field_validator("rate_limit_bucket_ttl_sec", "permission_lock_ttl_sec", "upload_queue_max_bytes_per_user", mode="before") +``` + +Add this property after `effective_permission_lock_ttl_sec`: + +```python + @property + def effective_upload_queue_max_bytes_per_user(self) -> int: + if self.upload_queue_max_bytes_per_user is not None: + return self.upload_queue_max_bytes_per_user + return self.upload_queue_max_files_per_user * self.upload_max_file_size_mb * 1024 * 1024 +``` + +In `deploy/env/.env.example`, add this section after line `TASK_OUTPUT_CHAR_LIMIT=120000`: + +```dotenv + +# File upload +UPLOAD_MAX_FILE_SIZE_MB=20 +UPLOAD_QUEUE_MAX_FILES_PER_USER=5 +# Blank means UPLOAD_QUEUE_MAX_FILES_PER_USER * UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024 +UPLOAD_QUEUE_MAX_BYTES_PER_USER= +``` + +- [ ] **Step 8: Run settings and upload queue tests** + +Run: + +```bash +python -m pytest tests/test_upload_queue.py tests/test_auth_settings.py::test_settings_new_fields_defaults tests/test_auth_settings.py::test_settings_explicit_override_new_fields tests/test_auth_settings.py::test_settings_allows_upload_queue_disabled_with_zero_files tests/test_auth_settings.py::test_settings_rejects_invalid_upload_queue_values tests/test_auth_settings.py::test_env_example_contains_new_entries -q +``` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 1** + +```bash +git add app/services/upload_queue.py app/config/settings.py deploy/env/.env.example tests/test_upload_queue.py tests/test_auth_settings.py +git commit -m "feat: add bounded upload queue settings" +``` + +--- + +### Task 2: File upload handler queue limits and pre-download size rejection + +**Files:** +- Modify: `app/bot/handlers/file_upload.py:17-180` +- Modify: `app/bot/router.py:50-177` +- Modify: `app/bootstrap.py:134-139,279-296` +- Modify: `tests/test_file_upload_handler.py` +- Modify: `tests/test_auth_settings.py:425-457` + +- [ ] **Step 1: Write failing file upload handler tests** + +In `tests/test_file_upload_handler.py`, replace imports of `_pending_uploads` with `UploadQueueManager`: + +```python +from app.services.upload_queue import UploadQueueManager +``` + +Add this helper below `_make_services()`: + +```python +class DummyRouter: + def __init__(self) -> None: + self.handlers = [] + + def message(self, *args, **kwargs): + def decorator(fn): + self.handlers.append(fn) + return fn + + return decorator + + +def _register_upload_handlers(*, max_file_size_mb: int = 20, max_files: int = 5, max_bytes: int | None = None): + from app.bot.handlers.file_upload import register_file_upload_handler + + file_receiver, session_service, task_service = _make_services() + queue = UploadQueueManager( + max_files_per_user=max_files, + max_bytes_per_user=max_bytes if max_bytes is not None else max_files * max_file_size_mb * 1024 * 1024, + ) + router = DummyRouter() + register_file_upload_handler( + router, + file_receiver=file_receiver, + session_service=session_service, + task_service=task_service, + upload_queue=queue, + upload_max_file_size_mb=max_file_size_mb, + ) + return router.handlers[0], router.handlers[1], queue, file_receiver, session_service, task_service +``` + +Remove the `clear_pending_uploads` fixture and add these tests: + +```python +@pytest.mark.asyncio +async def test_document_size_metadata_rejects_before_download() -> None: + document_handler, _, _, _, _, _ = _register_upload_handlers(max_file_size_mb=1) + message = _make_message() + message.document = MagicMock() + message.document.file_name = "big.txt" + message.document.file_id = "file123" + message.document.file_size = 2 * 1024 * 1024 + bot = AsyncMock() + message.bot = bot + + await document_handler(message) + + bot.get_file.assert_not_called() + bot.download_file.assert_not_called() + assert message.answer.await_args_list + assert "文件被拒绝" in message.answer.await_args_list[0].args[0] + assert "1 MB" in message.answer.await_args_list[0].args[0] + + +@pytest.mark.asyncio +async def test_photo_size_metadata_rejects_before_download() -> None: + _, photo_handler, _, _, _, _ = _register_upload_handlers(max_file_size_mb=1) + message = _make_message() + photo = MagicMock() + photo.file_unique_id = "unique-photo" + photo.file_id = "photo123" + photo.file_size = 2 * 1024 * 1024 + message.photo = [photo] + bot = AsyncMock() + message.bot = bot + + await photo_handler(message) + + bot.get_file.assert_not_called() + bot.download_file.assert_not_called() + assert "文件被拒绝" in message.answer.await_args_list[0].args[0] + + +@pytest.mark.asyncio +async def test_running_task_queue_reply_mentions_restart_loss() -> None: + document_handler, _, queue, _, _, task_service = _register_upload_handlers(max_file_size_mb=1) + running_task = MagicMock(spec=TaskRecord) + running_task.status = TaskStatus.RUNNING + task_service.list_recent = AsyncMock(return_value=[running_task]) + + message = _make_message() + message.document = MagicMock() + message.document.file_name = "queued.txt" + message.document.file_id = "file123" + message.document.file_size = 10 + bot = AsyncMock() + message.bot = bot + file_obj = MagicMock() + file_obj.file_path = "documents/queued.txt" + bot.get_file = AsyncMock(return_value=file_obj) + bot.download_file = AsyncMock(return_value=io.BytesIO(b"queued")) + + await document_handler(message) + + assert await queue.queued_count(user_id=42) == 1 + reply = message.answer.await_args_list[0].args[0] + assert "已加入队列" in reply + assert "bot 重启" in reply + assert "丢失" in reply + + +@pytest.mark.asyncio +async def test_running_task_rejects_when_queue_count_limit_reached() -> None: + document_handler, _, queue, _, _, task_service = _register_upload_handlers(max_file_size_mb=1, max_files=1) + running_task = MagicMock(spec=TaskRecord) + running_task.status = TaskStatus.RUNNING + task_service.list_recent = AsyncMock(return_value=[running_task]) + await queue.enqueue(user_id=42, filename="existing.txt", data=b"x") + + message = _make_message() + message.document = MagicMock() + message.document.file_name = "second.txt" + message.document.file_id = "file456" + message.document.file_size = 10 + bot = AsyncMock() + message.bot = bot + file_obj = MagicMock() + file_obj.file_path = "documents/second.txt" + bot.get_file = AsyncMock(return_value=file_obj) + bot.download_file = AsyncMock(return_value=io.BytesIO(b"second")) + + await document_handler(message) + + assert await queue.queued_count(user_id=42) == 1 + reply = message.answer.await_args_list[0].args[0] + assert "文件未加入队列" in reply + assert "队列已满" in reply + + +@pytest.mark.asyncio +async def test_running_task_rejects_downloaded_file_over_size_limit_before_queueing() -> None: + document_handler, _, queue, _, _, task_service = _register_upload_handlers(max_file_size_mb=1) + running_task = MagicMock(spec=TaskRecord) + running_task.status = TaskStatus.RUNNING + task_service.list_recent = AsyncMock(return_value=[running_task]) + + message = _make_message() + message.document = MagicMock() + message.document.file_name = "big.txt" + message.document.file_id = "file789" + message.document.file_size = None + bot = AsyncMock() + message.bot = bot + file_obj = MagicMock() + file_obj.file_path = "documents/big.txt" + bot.get_file = AsyncMock(return_value=file_obj) + bot.download_file = AsyncMock(return_value=io.BytesIO(b"x" * (1024 * 1024 + 1))) + + await document_handler(message) + + assert await queue.queued_count(user_id=42) == 0 + reply = message.answer.await_args_list[0].args[0] + assert "文件被拒绝" in reply + assert "1 MB" in reply +``` + +- [ ] **Step 2: Run new file upload tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_file_upload_handler.py::test_document_size_metadata_rejects_before_download tests/test_file_upload_handler.py::test_photo_size_metadata_rejects_before_download tests/test_file_upload_handler.py::test_running_task_queue_reply_mentions_restart_loss tests/test_file_upload_handler.py::test_running_task_rejects_when_queue_count_limit_reached tests/test_file_upload_handler.py::test_running_task_rejects_downloaded_file_over_size_limit_before_queueing -q +``` + +Expected: FAIL because `register_file_upload_handler` does not accept `upload_queue` and `upload_max_file_size_mb`. + +- [ ] **Step 3: Implement file upload handler changes** + +In `app/bot/handlers/file_upload.py`, remove `_pending_uploads` and import the queue service: + +```python +from app.services.upload_queue import UploadQueueManager +``` + +Add these helpers after `_format_size`: + +```python +def _max_upload_size_bytes(upload_max_file_size_mb: int) -> int: + return upload_max_file_size_mb * 1024 * 1024 + + +def _metadata_exceeds_limit(file_size: int | None, *, max_size_bytes: int) -> bool: + return file_size is not None and file_size > max_size_bytes + + +async def _answer_oversized(message: Message, *, filename: str, size_bytes: int, upload_max_file_size_mb: int) -> None: + await message.answer( + f"❌ 文件被拒绝: {filename}\n原因: 文件大小 {_format_size(size_bytes)} 超过 {upload_max_file_size_mb} MB 限制。" + ) +``` + +Change `process_pending_uploads` to drain from the manager: + +```python +async def process_pending_uploads( + message: Message, + *, + file_receiver: FileReceiverService, + session_service: SessionService, + upload_queue: UploadQueueManager, + user_id: int, +) -> None: + """Process queued uploads for a user after their task completes.""" + pending = await upload_queue.drain(user_id=user_id) + for item in pending: + try: + await _process_upload( + message, + file_receiver=file_receiver, + session_service=session_service, + filename=item.filename, + data=item.data, + ) + except Exception: + logger.exception("queued upload processing failed", extra={"user_id": user_id, "filename": item.filename}) +``` + +Change `register_file_upload_handler` signature: + +```python +def register_file_upload_handler( + router: Router, + *, + file_receiver: FileReceiverService, + session_service: SessionService, + task_service: TaskService, + upload_queue: UploadQueueManager, + upload_max_file_size_mb: int, +) -> None: +``` + +At the start of `handle_document`, after `filename = ...`, add: + +```python + max_size_bytes = _max_upload_size_bytes(upload_max_file_size_mb) + if _metadata_exceeds_limit(document.file_size, max_size_bytes=max_size_bytes): + await _answer_oversized( + message, + filename=filename, + size_bytes=document.file_size or 0, + upload_max_file_size_mb=upload_max_file_size_mb, + ) + return +``` + +After downloading document data and before queueing/direct processing, add: + +```python + if len(data) > max_size_bytes: + await _answer_oversized( + message, + filename=filename, + size_bytes=len(data), + upload_max_file_size_mb=upload_max_file_size_mb, + ) + return +``` + +Replace document queueing logic with: + +```python + if await _user_has_running_task(task_service, user_id): + queued = await upload_queue.enqueue(user_id=user_id, filename=filename, data=data) + if not queued.accepted: + await message.answer(f"❌ 文件未加入队列: {filename}\n原因: {queued.reason}") + return + await message.answer( + f"⏳ 任务运行中,文件 {filename} 已加入队列,将在任务完成后处理。\n" + "注意:队列仅保存在内存中,如果 bot 在任务完成前重启,已排队文件会丢失。" + ) + return +``` + +In `handle_photo`, after `filename = ...`, add the same metadata and downloaded-size checks, using `photo.file_size` and `filename`. Replace the photo queueing block with the same `upload_queue.enqueue(...)` logic. + +- [ ] **Step 4: Inject upload queue through router and container** + +In `app/bot/router.py`, import `UploadQueueManager` for runtime type use: + +```python +from app.services.upload_queue import UploadQueueManager +``` + +Add `upload_queue` to `create_router` parameters: + +```python + upload_queue: UploadQueueManager | None = None, +``` + +Change the file upload registration condition and call: + +```python + if file_receiver is not None and upload_queue is not None: + register_file_upload_handler( + router, + file_receiver=file_receiver, + session_service=session_service, + task_service=task_service, + upload_queue=upload_queue, + upload_max_file_size_mb=settings.upload_max_file_size_mb, + ) +``` + +In `app/bootstrap.py`, import and create the queue: + +```python +from app.services.upload_queue import UploadQueueManager +``` + +After `self.file_receiver = FileReceiverService(...)`, add: + +```python + self.upload_queue = UploadQueueManager( + max_files_per_user=settings.upload_queue_max_files_per_user, + max_bytes_per_user=settings.effective_upload_queue_max_bytes_per_user, + ) +``` + +Pass it to `create_router`: + +```python + upload_queue=self.upload_queue, +``` + +- [ ] **Step 5: Add container wiring assertion** + +In `tests/test_auth_settings.py::test_container_wiring_passes_settings_to_task_store`, after `container = AppContainer(settings)`, add: + +```python + assert container.upload_queue._max_files_per_user == settings.upload_queue_max_files_per_user + assert container.upload_queue._max_bytes_per_user == settings.effective_upload_queue_max_bytes_per_user +``` + +- [ ] **Step 6: Run file upload and container tests** + +Run: + +```bash +python -m pytest tests/test_file_upload_handler.py tests/test_auth_settings.py::test_container_wiring_passes_settings_to_task_store -q +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add app/bot/handlers/file_upload.py app/bot/router.py app/bootstrap.py tests/test_file_upload_handler.py tests/test_auth_settings.py +git commit -m "fix: bound queued upload memory usage" +``` + +--- + +### Task 3: Background queued upload processing after final task messages + +**Files:** +- Modify: `app/bot/handlers/file_upload.py` +- Modify: `app/bot/handlers/run_event_streamer.py:85-113,224-298` +- Modify: `app/bot/handlers/command_run.py:43-180,183-209` +- Modify: `app/bot/router.py:107-177,218-228` +- Create: `tests/test_run_event_streamer_upload_queue.py` + +- [ ] **Step 1: Write failing streamer scheduling tests** + +Create `tests/test_run_event_streamer_upload_queue.py`: + +```python +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.bot.handlers.command_run import run_prompt_and_stream +from app.bot.presenters.chunk_sender import ChunkSender +from app.domain.file_models import FileUploadResult, FileValidationError +from app.domain.models import CLIEvent, EventType, TaskRecord, TaskStatus +from app.services.upload_queue import UploadQueueManager +from tests.fakes.telegram import DummyMessage + + +class DummyTaskService: + def __init__(self, events: list[CLIEvent], status: TaskRecord) -> None: + self._events = events + self._status = status + + async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None): + task = SimpleNamespace( + task_id="task-queued-1", + provider="claude_code", + session_id="session-1", + workdir=workdir or "/tmp/work", + started_at=None, + created_at=None, + ) + return SimpleNamespace(task=task, events=self._stream(), interactive=False) + + async def get_status(self, task_id: str, user_id: int): + return self._status + + async def get_structured_session(self, user_id: int, *, log_missing: bool = True): + return None + + async def get_structured_session_for_task(self, *, task_id: str, user_id: int, log_missing: bool = True): + return None + + async def get_structured_session_cursor(self, user_id: int, *, task_id: str | None = None) -> int: + return 0 + + async def get_structured_reply_cursor(self, user_id: int, *, task_id: str | None = None): + return None, None + + async def acknowledge_structured_reply(self, user_id: int, **kwargs) -> None: + return None + + async def get_structured_user_question_cursor(self, user_id: int, *, task_id: str | None = None): + return None + + async def acknowledge_structured_user_question(self, user_id: int, **kwargs) -> None: + return None + + async def wait_for_structured_session_update(self, **kwargs) -> bool: + await asyncio.sleep(0.01) + return False + + async def _stream(self): + for event in self._events: + yield event + + +@pytest.mark.asyncio +async def test_queued_upload_scheduler_runs_after_success_message_is_displayed(tmp_path: Path) -> None: + events = [CLIEvent(type=EventType.STARTED, task_id="task-queued-1"), CLIEvent(type=EventType.EXITED, task_id="task-queued-1", exit_code=0)] + status = TaskRecord( + task_id="task-queued-1", + session_id="session-1", + user_id=1, + provider="claude_code", + prompt="hello", + workdir=str(tmp_path), + timeout_sec=60, + status=TaskStatus.SUCCEEDED, + ) + service = DummyTaskService(events=events, status=status) + message = DummyMessage() + scheduler_calls: list[tuple[int, str]] = [] + + def queued_upload_scheduler(root_message, user_id: int) -> None: + scheduler_calls.append((user_id, root_message.sent_messages[0].text)) + + task = await run_prompt_and_stream( + message=message, + task_service=service, + sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01), + user_id=1, + provider="claude_code", + prompt="hello", + workdir=str(tmp_path), + queued_upload_scheduler=queued_upload_scheduler, + ) + assert task is not None + await task + + assert scheduler_calls == [(1, message.sent_messages[0].text)] + assert "✅ 完成" in scheduler_calls[0][1] + + +@pytest.mark.asyncio +async def test_queued_upload_processing_continues_after_failed_file(tmp_path: Path) -> None: + from app.bot.handlers.file_upload import schedule_pending_upload_processing + + queue = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=100) + await queue.enqueue(user_id=1, filename="bad.exe", data=b"bad") + await queue.enqueue(user_id=1, filename="good.txt", data=b"good") + + message = DummyMessage(user_id=1) + session_service = AsyncMock() + session = SimpleNamespace(workdir=str(tmp_path)) + session_service.get = AsyncMock(return_value=session) + file_receiver = AsyncMock() + file_receiver.receive_file = AsyncMock( + side_effect=[ + FileValidationError(filename="bad.exe", reason="Extension .exe is not allowed."), + FileUploadResult(filename="good.txt", size_bytes=4, path=tmp_path / ".tg-uploads" / "1" / "good.txt"), + ] + ) + + task = schedule_pending_upload_processing( + message, + file_receiver=file_receiver, + session_service=session_service, + upload_queue=queue, + user_id=1, + ) + await task + + assert [call.args[0] for call in message.answer.await_args_list] == [ + "❌ 文件被拒绝: bad.exe\n原因: Extension .exe is not allowed.", + "✅ 文件已接收: good.txt (4 B)", + ] + assert await queue.drain(user_id=1) == [] +``` + +- [ ] **Step 2: Run streamer scheduling tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_run_event_streamer_upload_queue.py -q +``` + +Expected: FAIL because `queued_upload_scheduler` and `schedule_pending_upload_processing` do not exist. + +- [ ] **Step 3: Add tracked upload processing scheduler** + +In `app/bot/handlers/file_upload.py`, add imports: + +```python +import asyncio +``` + +Add this module-level set after `logger = logging.getLogger(__name__)`: + +```python +_ACTIVE_UPLOAD_TASKS: set[asyncio.Task[None]] = set() +``` + +Add this function after `process_pending_uploads`: + +```python +def schedule_pending_upload_processing( + message: Message, + *, + file_receiver: FileReceiverService, + session_service: SessionService, + upload_queue: UploadQueueManager, + user_id: int, +) -> asyncio.Task[None]: + task = asyncio.create_task( + process_pending_uploads( + message, + file_receiver=file_receiver, + session_service=session_service, + upload_queue=upload_queue, + user_id=user_id, + ) + ) + _ACTIVE_UPLOAD_TASKS.add(task) + + def _on_done(done_task: asyncio.Task[None]) -> None: + _ACTIVE_UPLOAD_TASKS.discard(done_task) + if done_task.cancelled(): + return + exc = done_task.exception() + if exc is None: + return + logger.error( + "queued upload background task failed", + extra={"user_id": user_id, "error": str(exc)}, + exc_info=(type(exc), exc, exc.__traceback__), + ) + + task.add_done_callback(_on_done) + return task +``` + +- [ ] **Step 4: Thread scheduler into run streaming** + +In `app/bot/handlers/run_event_streamer.py`, import `Callable`: + +```python +from collections.abc import Callable +``` + +Add a constructor parameter and field: + +```python + queued_upload_scheduler: Callable[[], None] | None = None, +``` + +```python + self._queued_upload_scheduler = queued_upload_scheduler + self._queued_upload_scheduled = False +``` + +Add this method before `stream_events`: + +```python + def _schedule_queued_uploads_once(self) -> None: + if self._queued_upload_scheduled or self._queued_upload_scheduler is None: + return + self._queued_upload_scheduled = True + try: + self._queued_upload_scheduler() + except Exception: + logger.exception("failed to schedule queued upload processing", extra={"user_id": self._user_id}) +``` + +In the `EXITED` branch, immediately after sending/editing `success_msg`, add: + +```python + self._schedule_queued_uploads_once() +``` + +In the `{FAILED, TIMEOUT, CANCELED}` branch, immediately after sending/editing `error_msg`, add: + +```python + self._schedule_queued_uploads_once() +``` + +In `app/bot/handlers/command_run.py`, import `Callable` and add a parameter to `run_prompt_and_stream`: + +```python +from collections.abc import Callable +``` + +```python + queued_upload_scheduler: Callable[[Message, int], None] | None = None, +``` + +Pass this to `RunEventStreamer`: + +```python + queued_upload_scheduler=( + (lambda: queued_upload_scheduler(message, user_id)) if queued_upload_scheduler is not None else None + ), +``` + +Add `queued_upload_scheduler` to `register_run_handler` and pass it through to `run_prompt_and_stream`. + +In `app/bot/router.py`, import the scheduler: + +```python +from app.bot.handlers.file_upload import register_file_upload_handler, schedule_pending_upload_processing +``` + +Before `register_run_handler(...)`, build a scheduler only when upload dependencies exist: + +```python + queued_upload_scheduler = None + if file_receiver is not None and upload_queue is not None: + queued_upload_scheduler = lambda message, user_id: schedule_pending_upload_processing( + message, + file_receiver=file_receiver, + session_service=session_service, + upload_queue=upload_queue, + user_id=user_id, + ) +``` + +Pass it to `register_run_handler`: + +```python + queued_upload_scheduler=queued_upload_scheduler, +``` + +Pass it to the plain text Claude chat `run_prompt_and_stream(...)` call: + +```python + queued_upload_scheduler=queued_upload_scheduler, +``` + +- [ ] **Step 5: Run upload streaming tests** + +Run: + +```bash +python -m pytest tests/test_run_event_streamer_upload_queue.py tests/test_run_event_streamer_diff.py tests/test_command_run.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add app/bot/handlers/file_upload.py app/bot/handlers/run_event_streamer.py app/bot/handlers/command_run.py app/bot/router.py tests/test_run_event_streamer_upload_queue.py +git commit -m "fix: process queued uploads after task completion" +``` + +--- + +### Task 4: Permission callback token registry + +**Files:** +- Create: `app/services/permission_callback_registry.py` +- Create: `tests/test_permission_callback_registry.py` + +- [ ] **Step 1: Write failing registry tests** + +Create `tests/test_permission_callback_registry.py`: + +```python +from __future__ import annotations + +from app.services.permission_callback_registry import PermissionCallbackRegistry + + +def test_registry_resolves_full_tool_use_id_from_short_token() -> None: + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "abc12345", clock=lambda: 100.0) + tool_use_id = "toolu_" + "x" * 200 + + token = registry.register(tool_use_id) + + assert token == "abc12345" + assert registry.resolve(token) == tool_use_id + assert len(token.encode("utf-8")) < len(tool_use_id.encode("utf-8")) + + +def test_registry_expires_tokens() -> None: + now = 100.0 + + def clock() -> float: + return now + + registry = PermissionCallbackRegistry(ttl_sec=10, token_factory=lambda: "token001", clock=clock) + token = registry.register("tool-1") + + now = 111.0 + + assert registry.resolve(token) is None + + +def test_registry_retries_live_token_collision() -> None: + tokens = iter(["same001", "same001", "next002"]) + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: next(tokens), clock=lambda: 100.0) + + first = registry.register("tool-1") + second = registry.register("tool-2") + + assert first == "same001" + assert second == "next002" + assert registry.resolve(first) == "tool-1" + assert registry.resolve(second) == "tool-2" +``` + +- [ ] **Step 2: Run registry tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_permission_callback_registry.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.permission_callback_registry'`. + +- [ ] **Step 3: Implement registry** + +Create `app/services/permission_callback_registry.py`: + +```python +from __future__ import annotations + +import secrets +import time +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class _PermissionCallbackEntry: + tool_use_id: str + expires_at: float + + +class PermissionCallbackRegistry: + def __init__( + self, + *, + ttl_sec: int, + token_factory: Callable[[], str] | None = None, + clock: Callable[[], float] | None = None, + ) -> None: + if ttl_sec <= 0: + raise ValueError("ttl_sec must be positive") + self._ttl_sec = ttl_sec + self._token_factory = token_factory or (lambda: secrets.token_urlsafe(6)) + self._clock = clock or time.monotonic + self._entries: dict[str, _PermissionCallbackEntry] = {} + + def register(self, tool_use_id: str) -> str: + self._prune_expired() + for _ in range(16): + token = self._token_factory() + if token not in self._entries: + self._entries[token] = _PermissionCallbackEntry( + tool_use_id=tool_use_id, + expires_at=self._clock() + self._ttl_sec, + ) + return token + raise RuntimeError("failed to generate unique permission callback token") + + def resolve(self, token: str) -> str | None: + self._prune_expired() + entry = self._entries.get(token) + if entry is None: + return None + return entry.tool_use_id + + def _prune_expired(self) -> None: + now = self._clock() + expired = [token for token, entry in self._entries.items() if entry.expires_at <= now] + for token in expired: + self._entries.pop(token, None) +``` + +- [ ] **Step 4: Run registry tests** + +Run: + +```bash +python -m pytest tests/test_permission_callback_registry.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add app/services/permission_callback_registry.py tests/test_permission_callback_registry.py +git commit -m "feat: add permission callback token registry" +``` + +--- + +### Task 5: Normal permission callbacks use short tokens + +**Files:** +- Modify: `app/bot/handlers/command_permission.py:18-202` +- Modify: `app/bot/router.py:50-124` +- Modify: `app/bootstrap.py:101-107,199-204,279-296` +- Modify: `tests/test_session_handlers.py:250-397` +- Modify: `tests/test_bootstrap_hooks.py` + +- [ ] **Step 1: Write failing normal permission callback tests** + +In `tests/test_session_handlers.py`, import the registry: + +```python +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +Add this test before `test_permission_callback_handler_approves_pending_request`: + +```python +def test_permission_callback_data_uses_short_token_for_long_tool_use_id() -> None: + from app.bot.handlers.command_permission import build_permission_callback_data, build_permission_keyboard + + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0) + long_tool_use_id = "toolu_" + "x" * 200 + + keyboard = build_permission_keyboard(tool_use_id=long_tool_use_id, permission_callback_registry=registry) + callback_data = [button.callback_data for row in keyboard.inline_keyboard for button in row] + + assert callback_data == [ + "perm:allow:tok12345", + "perm:deny:tok12345", + "perm:auto_approve:tok12345", + ] + assert all(data is not None and len(data.encode("utf-8")) <= 64 for data in callback_data) + assert registry.resolve("tok12345") == long_tool_use_id + assert build_permission_callback_data(decision="allow", token="tok12345") == "perm:allow:tok12345" +``` + +Update `test_permission_callback_handler_approves_pending_request` registration and callback setup: + +```python + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0) + token = registry.register("tool-1") + router = DummyRouter() + register_permission_handlers(router, task_service=service, permission_callback_registry=registry) + callback_handler = router.callback_handlers[0] + message = DummyMessage("权限请求") + callback = DummyCallbackQuery(f"perm:allow:{token}", message=message) +``` + +Update `test_permission_callback_handler_rejects_stale_button` to pass an empty registry and assert the new recovery message: + +```python + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0) + router = DummyRouter() + register_permission_handlers(router, task_service=service, permission_callback_registry=registry) + callback_handler = router.callback_handlers[0] + message = DummyMessage("权限请求") + callback = DummyCallbackQuery("perm:allow:missing", message=message) +``` + +Replace its assertions: + +```python + assert hook_socket_server.calls == [] + assert "权限按钮已失效" in message.answers[0] + assert "重新触发" in message.answers[0] + assert message.edited_reply_markups == [] + assert callback.answers == [(message.answers[0], True)] +``` + +Update `test_permission_callback_handler_rejects_cross_user_button` to register `tool-1` and pass the token: + +```python + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0) + token = registry.register("tool-1") + router = DummyRouter() + register_permission_handlers(router, task_service=service, permission_callback_registry=registry) + callback_handler = router.callback_handlers[0] + message = DummyMessage("权限请求", user_id=2) + callback = DummyCallbackQuery(f"perm:allow:{token}", user_id=2, message=message) +``` + +- [ ] **Step 2: Run normal permission tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_session_handlers.py::test_permission_callback_data_uses_short_token_for_long_tool_use_id tests/test_session_handlers.py::test_permission_callback_handler_approves_pending_request tests/test_session_handlers.py::test_permission_callback_handler_rejects_stale_button tests/test_session_handlers.py::test_permission_callback_handler_rejects_cross_user_button -q +``` + +Expected: FAIL because handler signatures and callback parsing still use `tool_use_id` directly. + +- [ ] **Step 3: Implement normal callback token use** + +In `app/bot/handlers/command_permission.py`, import the registry: + +```python +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +Add stale text near constants: + +```python +_STALE_PERMISSION_CALLBACK_TEXT = "权限按钮已失效:请求可能已过期或 bot 已重启。请重新触发操作,或等待 Claude 再次请求权限。" +``` + +Replace `build_permission_callback_data`: + +```python +def build_permission_callback_data(*, decision: str, token: str) -> str: + return f"{_PERMISSION_CALLBACK_PREFIX}:{decision}:{token}" +``` + +Keep `parse_permission_callback_data` but rename the parsed third value in local variables to `token`: + +```python + decision, sep, token = rest.partition(":") + if not sep or decision not in {"allow", "deny", "auto_approve"} or not token: + return None + return decision, token +``` + +Change `build_permission_keyboard`: + +```python +def build_permission_keyboard(*, tool_use_id: str, permission_callback_registry: PermissionCallbackRegistry) -> InlineKeyboardMarkup: + token = permission_callback_registry.register(tool_use_id) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="允许", + callback_data=build_permission_callback_data(decision="allow", token=token), + ), + InlineKeyboardButton( + text="拒绝", + callback_data=build_permission_callback_data(decision="deny", token=token), + ), + ], + [ + InlineKeyboardButton( + text="不再询问,全部允许", + callback_data=build_permission_callback_data(decision="auto_approve", token=token), + ), + ], + ] + ) +``` + +Add `permission_callback_registry` to `register_permission_handlers` parameters: + +```python + permission_callback_registry: PermissionCallbackRegistry, +``` + +In `callback_permission`, resolve token before calling services: + +```python + decision, token = parsed + tool_use_id = permission_callback_registry.resolve(token) + if tool_use_id is None: + if callback.message is not None: + await callback.message.answer(_STALE_PERMISSION_CALLBACK_TEXT) + await callback.answer(_STALE_PERMISSION_CALLBACK_TEXT, show_alert=True) + return +``` + +Remove the prefix-matching loop from `_resolve_session_id_for_tool_use_id`; the fallback should only check the exact key: + +```python + if hook_socket_server is not None: + async with hook_socket_server._lock: + pending = hook_socket_server._pending_permissions.get(tool_use_id) + if pending is not None: + return pending.session_id +``` + +- [ ] **Step 4: Inject registry through router and container** + +In `app/bot/router.py`, import the registry and add a parameter: + +```python +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +```python + permission_callback_registry: PermissionCallbackRegistry | None = None, +``` + +Only register normal permission handlers when the registry exists: + +```python + if permission_callback_registry is not None: + register_permission_handlers( + router, + task_service=task_service, + auto_approve_service=auto_approve_service, + hook_socket_server=hook_socket_server, + structured_session_store=structured_session_store, + permission_callback_registry=permission_callback_registry, + ) +``` + +In `app/bootstrap.py`, import and instantiate: + +```python +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +After `self.hook_socket_server = HookSocketServer(...)`, add: + +```python + self.permission_callback_registry = PermissionCallbackRegistry( + ttl_sec=settings.claude_hook_pending_permission_ttl_sec, + ) +``` + +Pass it to `create_router`: + +```python + permission_callback_registry=self.permission_callback_registry, +``` + +- [ ] **Step 5: Update bootstrap hook tests** + +In `tests/test_bootstrap_hooks.py::test_container_uses_independent_session_lock_registries`, add: + +```python + assert container.permission_callback_registry._ttl_sec == settings.claude_hook_pending_permission_ttl_sec +``` + +- [ ] **Step 6: Run normal permission tests** + +Run: + +```bash +python -m pytest tests/test_permission_callback_registry.py tests/test_session_handlers.py::test_permission_callback_data_uses_short_token_for_long_tool_use_id tests/test_session_handlers.py::test_permission_callback_handler_approves_pending_request tests/test_session_handlers.py::test_permission_callback_handler_rejects_stale_button tests/test_session_handlers.py::test_permission_callback_handler_rejects_cross_user_button tests/test_bootstrap_hooks.py::test_container_uses_independent_session_lock_registries -q +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add app/bot/handlers/command_permission.py app/bot/router.py app/bootstrap.py tests/test_session_handlers.py tests/test_bootstrap_hooks.py +git commit -m "fix: route permission callbacks through short tokens" +``` + +--- + +### Task 6: External permission tokens and unbound pending cleanup + +**Files:** +- Modify: `app/services/unbound_permission_handler.py:22-247` +- Modify: `app/bot/handlers/external_permission.py:18-126` +- Modify: `app/bot/router.py:162-169` +- Modify: `app/bootstrap.py:199-204,279-296` +- Modify: `tests/property/test_unbound_permission_properties.py` +- Modify: `tests/integration/test_external_session_pipeline.py` + +- [ ] **Step 1: Write failing unbound cleanup and external token tests** + +In `tests/property/test_unbound_permission_properties.py`, import the registry: + +```python +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +Add this helper near the top: + +```python +def _registry(token: str = "tok12345") -> PermissionCallbackRegistry: + return PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: token, clock=lambda: 100.0) +``` + +Update every `UnboundPermissionHandler(...)` construction in this file to include: + +```python + permission_callback_registry=_registry(), +``` + +Add these tests near the first-responder tests: + +```python +@pytest.mark.asyncio +async def test_response_removes_unbound_pending_and_expiry_task() -> None: + bot = MagicMock() + bot.send_message = AsyncMock() + hook_socket_server = MagicMock() + hook_socket_server.respond_to_permission = AsyncMock(return_value=True) + handler = UnboundPermissionHandler( + bot=bot, + hook_socket_server=hook_socket_server, + allowed_user_ids={1}, + permission_callback_registry=_registry(), + ) + event = HookEvent(session_id="sess", cwd="/tmp/project", event="PermissionRequest", status="waiting_for_approval", tool="Bash", tool_use_id="tool-1") + + await handler.handle_unbound_permission(event) + result = await handler.handle_response(tool_use_id="tool-1", user_id=1, decision="allow") + + assert result.accepted is True + assert result.forwarded is True + assert handler.is_unbound_permission("tool-1") is False + assert handler._pending == {} + assert handler._expiry_tasks == {} + + +@pytest.mark.asyncio +async def test_expiry_removes_unbound_pending_and_expiry_task() -> None: + bot = MagicMock() + bot.send_message = AsyncMock() + hook_socket_server = MagicMock() + hook_socket_server.respond_to_permission = AsyncMock(return_value=True) + handler = UnboundPermissionHandler( + bot=bot, + hook_socket_server=hook_socket_server, + allowed_user_ids={1}, + permission_ttl_sec=0, + permission_callback_registry=_registry(), + ) + event = HookEvent(session_id="sess", cwd="/tmp/project", event="PermissionRequest", status="waiting_for_approval", tool="Bash", tool_use_id="tool-expire") + + await handler.handle_unbound_permission(event) + await asyncio.sleep(0.05) + + assert handler.is_unbound_permission("tool-expire") is False + assert handler._pending == {} + assert handler._expiry_tasks == {} + + +@pytest.mark.asyncio +async def test_concurrent_unbound_responses_preserve_first_responder_wins() -> None: + bot = MagicMock() + bot.send_message = AsyncMock() + release = asyncio.Event() + hook_socket_server = MagicMock() + + async def respond_to_permission(**kwargs): + await release.wait() + return True + + hook_socket_server.respond_to_permission = AsyncMock(side_effect=respond_to_permission) + handler = UnboundPermissionHandler( + bot=bot, + hook_socket_server=hook_socket_server, + allowed_user_ids={1, 2}, + permission_callback_registry=_registry(), + ) + event = HookEvent(session_id="sess", cwd="/tmp/project", event="PermissionRequest", status="waiting_for_approval", tool="Bash", tool_use_id="tool-race") + await handler.handle_unbound_permission(event) + + first = asyncio.create_task(handler.handle_response(tool_use_id="tool-race", user_id=1, decision="allow")) + second = asyncio.create_task(handler.handle_response(tool_use_id="tool-race", user_id=2, decision="deny")) + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(first, second) + + assert sum(1 for result in results if result.accepted) == 1 + assert hook_socket_server.respond_to_permission.await_count == 1 + assert handler._pending == {} +``` + +In `tests/integration/test_external_session_pipeline.py`, update handler construction to pass a registry, and add this test: + +```python +@pytest.mark.asyncio +async def test_unbound_permission_keyboard_uses_external_short_token() -> None: + mock_bot = AsyncMock() + mock_hook_socket = AsyncMock() + registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0) + handler = UnboundPermissionHandler( + bot=mock_bot, + hook_socket_server=mock_hook_socket, + allowed_user_ids={100}, + permission_callback_registry=registry, + ) + event = _make_hook_event( + session_id="sess-unbound01", + cwd="/tmp/project", + event="PermissionRequest", + status="waiting_for_approval", + tool="Write", + tool_use_id="toolu_" + "x" * 200, + ) + + await handler.handle_unbound_permission(event) + + markup = mock_bot.send_message.await_args.kwargs["reply_markup"] + callback_data = [button.callback_data for row in markup.inline_keyboard for button in row] + assert callback_data == [ + "ext_perm:tok12345:allow", + "ext_perm:tok12345:deny", + "ext_perm:tok12345:auto_approve", + ] + assert all(data is not None and len(data.encode("utf-8")) <= 64 for data in callback_data) + assert registry.resolve("tok12345") == event.tool_use_id +``` + +- [ ] **Step 2: Run unbound tests and verify they fail** + +Run: + +```bash +python -m pytest tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py::test_unbound_permission_keyboard_uses_external_short_token -q +``` + +Expected: FAIL because `UnboundPermissionHandler` does not accept `permission_callback_registry`, still returns bool from `handle_response`, and still embeds truncated IDs. + +- [ ] **Step 3: Implement unbound token keyboard and response result** + +In `app/services/unbound_permission_handler.py`, import dataclass and registry: + +```python +from dataclasses import dataclass +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +Add this result type above `UnboundPermissionHandler`: + +```python +@dataclass(frozen=True, slots=True) +class UnboundPermissionResponseResult: + accepted: bool + forwarded: bool +``` + +Add constructor parameter and fields: + +```python + permission_callback_registry: PermissionCallbackRegistry, +``` + +```python + self._permission_callback_registry = permission_callback_registry + self._state_lock = asyncio.Lock() +``` + +In `handle_unbound_permission`, wrap pending and expiry mutations: + +```python + async with self._state_lock: + self._pending[tool_use_id] = state + self._cancel_expiry_task_locked(tool_use_id) + self._expiry_tasks[tool_use_id] = asyncio.create_task(self._expire_permission(tool_use_id)) +``` + +Replace `handle_response`: + +```python + async def handle_response(self, *, tool_use_id: str, user_id: int, decision: str) -> UnboundPermissionResponseResult: + async with self._state_lock: + state = self._pending.pop(tool_use_id, None) + if state is None or state.responded: + return UnboundPermissionResponseResult(accepted=False, forwarded=False) + state.responded = True + state.responded_by = user_id + self._cancel_expiry_task_locked(tool_use_id) + + forwarded = await self._hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision=decision, + reason=f"responded by user {user_id}", + ) + if not forwarded: + logger.warning( + "unbound permission response forwarding failed after claim", + extra={"tool_use_id": tool_use_id, "user_id": user_id, "decision": decision, "session_id": state.session_id}, + ) + else: + logger.info( + "unbound permission responded", + extra={"tool_use_id": tool_use_id, "user_id": user_id, "decision": decision, "session_id": state.session_id}, + ) + return UnboundPermissionResponseResult(accepted=True, forwarded=forwarded) +``` + +Replace `_expire_permission` cleanup path: + +```python + async with self._state_lock: + state = self._pending.pop(tool_use_id, None) + self._expiry_tasks.pop(tool_use_id, None) + if state is None or state.responded: + return + state.responded = True + + await self._hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision="deny", + reason="no user responded within TTL", + ) +``` + +Replace `_build_permission_keyboard`: + +```python + def _build_permission_keyboard(self, tool_use_id: str) -> InlineKeyboardMarkup: + token = self._permission_callback_registry.register(tool_use_id) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="✅ Approve", callback_data=f"ext_perm:{token}:allow"), + InlineKeyboardButton(text="❌ Deny", callback_data=f"ext_perm:{token}:deny"), + ], + [ + InlineKeyboardButton(text="🟢 Auto-approve All", callback_data=f"ext_perm:{token}:auto_approve"), + ], + ] + ) +``` + +Replace `_cancel_expiry_task` with a locked helper and a public helper: + +```python + def _cancel_expiry_task_locked(self, tool_use_id: str) -> None: + task = self._expiry_tasks.pop(tool_use_id, None) + if task is not None: + task.cancel() + + def _cancel_expiry_task(self, tool_use_id: str) -> None: + task = self._expiry_tasks.pop(tool_use_id, None) + if task is not None: + task.cancel() +``` + +The public helper remains for existing synchronous call sites if any remain; new locked mutations use `_cancel_expiry_task_locked`. + +- [ ] **Step 4: Implement external callback token resolution** + +In `app/bot/handlers/external_permission.py`, import the registry: + +```python +from app.services.permission_callback_registry import PermissionCallbackRegistry +``` + +Add a parameter to `register_external_permission_handler`: + +```python + permission_callback_registry: PermissionCallbackRegistry, +``` + +Add stale text after `logger`: + +```python +_STALE_EXTERNAL_PERMISSION_CALLBACK_TEXT = "Permission button expired or bot restarted. Trigger the action again or wait for Claude to request permission again." +``` + +After parsing `parts`, resolve token: + +```python + _, token, decision = parts + if decision not in ("allow", "deny", "auto_approve"): + await callback.answer("Invalid decision", show_alert=True) + return + tool_use_id = permission_callback_registry.resolve(token) + if tool_use_id is None: + await callback.answer(_STALE_EXTERNAL_PERMISSION_CALLBACK_TEXT, show_alert=True) + return +``` + +Update unbound response handling for the new result object: + +```python + result = await unbound_permission_handler.handle_response( + tool_use_id=tool_use_id, + user_id=user_id, + decision="allow", + ) + if not result.accepted: + await callback.answer("Already responded by another user", show_alert=True) + return + if not result.forwarded: + await callback.answer("Permission request expired or not found", show_alert=True) + return +``` + +Apply the same `result.accepted` / `result.forwarded` checks in the non-auto-approve unbound path. + +- [ ] **Step 5: Inject registry into unbound and external handlers** + +In `app/bootstrap.py`, pass the existing registry to `UnboundPermissionHandler`: + +```python + permission_callback_registry=self.permission_callback_registry, +``` + +In `app/bot/router.py`, require the registry for external permission handler registration: + +```python + if hook_socket_server is not None and unbound_permission_handler is not None and permission_callback_registry is not None: + register_external_permission_handler( + router, + hook_socket_server=hook_socket_server, + unbound_permission_handler=unbound_permission_handler, + external_uq_state=external_uq_state, + auto_approve_service=auto_approve_service, + permission_callback_registry=permission_callback_registry, + ) +``` + +- [ ] **Step 6: Update existing tests for new response result** + +In `tests/property/test_unbound_permission_properties.py`, replace assertions like: + +```python +assert result_first is True +assert result is False +``` + +with: + +```python +assert result_first.accepted is True +assert result.accepted is False +``` + +In `tests/integration/test_external_session_pipeline.py`, update `first_response` and `second_response` assertions: + +```python + assert first_response.accepted is True + assert first_response.forwarded is True +``` + +```python + assert second_response.accepted is False +``` + +- [ ] **Step 7: Run external/unbound tests** + +Run: + +```bash +python -m pytest tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py -q +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 6** + +```bash +git add app/services/unbound_permission_handler.py app/bot/handlers/external_permission.py app/bot/router.py app/bootstrap.py tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py +git commit -m "fix: clean unbound permission state and tokenize external callbacks" +``` + +--- + +### Task 7: Tmux persistent session lock cleanup + +**Files:** +- Modify: `app/adapters/process/tmux_runner.py:71-100,204-212,569-589,712-717` +- Modify: `app/bootstrap.py:121-127` +- Modify: `tests/test_tmux_runner.py` + +- [ ] **Step 1: Write failing tmux lock cleanup test** + +In `tests/test_tmux_runner.py`, add: + +```python +@pytest.mark.asyncio +async def test_persistent_session_locks_are_ref_counted_and_cleaned(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + now = 0.0 + + def clock() -> float: + return now + + runner = TmuxRunner( + data_dir=str(tmp_path), + session_lock_ttl_sec=1, + lock_cleanup_interval_sec=1, + lock_cleanup_batch_size=10, + lock_clock=clock, + ) + + async def fake_run_task(*, meta, timeout_sec: int, env, workdir: str, command: str): + yield CLIEvent(type=EventType.STARTED, task_id=meta.task_id) + yield CLIEvent(type=EventType.EXITED, task_id=meta.task_id, exit_code=0) + + monkeypatch.setattr(runner, "_run_task", fake_run_task) + + nonlocal_now = {"value": now} + + def advance(seconds: float) -> None: + nonlocal now + now += seconds + nonlocal_now["value"] = now + + for idx in range(3): + events = await _collect_events( + runner.run( + task_id=f"task-{idx}", + argv=["echo", "ok"], + workdir=str(tmp_path), + timeout_sec=10, + terminal_key=f"user-{idx}", + ) + ) + assert events[-1].type == EventType.EXITED + advance(2.0) + + assert len(runner._session_locks) <= 1 +``` + +If Python rejects `nonlocal now` inside `advance`, use this simpler mutable clock instead: + +```python + current = {"now": 0.0} + + def clock() -> float: + return current["now"] + + def advance(seconds: float) -> None: + current["now"] += seconds +``` + +Use only the mutable-clock version in the final test file. + +- [ ] **Step 2: Run tmux lock test and verify it fails** + +Run: + +```bash +python -m pytest tests/test_tmux_runner.py::test_persistent_session_locks_are_ref_counted_and_cleaned -q +``` + +Expected: FAIL because `TmuxRunner` does not accept lock registry settings. + +- [ ] **Step 3: Implement ref-counted locks in `TmuxRunner`** + +In `app/adapters/process/tmux_runner.py`, import the registry and async context manager tools: + +```python +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from app.services.lock_registry import RefCountedLockRegistry +``` + +Add constructor parameters: + +```python + session_lock_ttl_sec: int = 3600, + lock_cleanup_interval_sec: int = 60, + lock_cleanup_batch_size: int = 50, + lock_clock: Callable[[], float] | None = None, +``` + +Replace `_session_locks` initialization: + +```python + self._session_locks = RefCountedLockRegistry( + ttl_sec=session_lock_ttl_sec, + cleanup_interval_sec=lock_cleanup_interval_sec, + cleanup_batch_size=lock_cleanup_batch_size, + clock=lock_clock, + ) +``` + +Replace `_get_session_lock` with: + +```python + @asynccontextmanager + async def _session_lock(self, session_name: str) -> AsyncIterator[None]: + async with self._session_locks.lock(session_name): + yield +``` + +Update persistent sections: + +```python + if persistent_terminal: + async with self._session_lock(session_name): + async for event in self._run_task(meta=meta, timeout_sec=timeout_sec, env=env, workdir=workdir, command=command): + yield event + return +``` + +Update `ensure_terminal`, `ensure_claude_interactive_session`, and `ensure_claude_resume_session` to use: + +```python + async with self._session_lock(session_name): +``` + +- [ ] **Step 4: Pass settings from container** + +In `app/bootstrap.py`, extend `TmuxRunner(...)` construction: + +```python + session_lock_ttl_sec=settings.session_lock_ttl_sec, + lock_cleanup_interval_sec=settings.lock_cleanup_interval_sec, + lock_cleanup_batch_size=settings.lock_cleanup_batch_size, +``` + +- [ ] **Step 5: Run tmux tests** + +Run: + +```bash +python -m pytest tests/test_tmux_runner.py tests/test_lock_registry.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 7** + +```bash +git add app/adapters/process/tmux_runner.py app/bootstrap.py tests/test_tmux_runner.py +git commit -m "fix: clean up tmux session locks" +``` + +--- + +### Task 8: Agent file watcher cleanup + +**Files:** +- Modify: `app/services/agent_file_watcher.py:28-78,120-140` +- Create: `tests/test_agent_file_watcher.py` + +- [ ] **Step 1: Write failing watcher cleanup tests** + +Create `tests/test_agent_file_watcher.py`: + +```python +from __future__ import annotations + +import asyncio + +import pytest + +from app.services.agent_file_watcher import AgentFileWatcher + + +class DummySessionStore: + def get(self, session_id: str): + return None + + +class DummyParser: + def subagent_file_path(self, *, session_id: str, agent_id: str, cwd: str): + raise AssertionError("subagent_file_path should not be called in these cleanup tests") + + def reset_state(self, session_id: str) -> None: + return None + + +@pytest.mark.asyncio +async def test_forget_clears_all_seen_mtime_keys_for_session() -> None: + watcher = AgentFileWatcher( + session_store=DummySessionStore(), + claude_jsonl_parser=DummyParser(), + on_update=lambda session_id, workdir: asyncio.sleep(0), + ) + watcher._seen_mtimes = { + "session-1:tool-a:agent-a": 1.0, + "session-1:tool-b:agent-b": 2.0, + "session-2:tool-c:agent-c": 3.0, + } + watcher._session_locks["session-1"] = asyncio.Lock() + + watcher.forget("session-1") + + assert watcher._seen_mtimes == {"session-2:tool-c:agent-c": 3.0} + assert "session-1" not in watcher._session_locks + + +@pytest.mark.asyncio +async def test_forget_defers_lock_cleanup_until_running_watcher_exits() -> None: + release_update = asyncio.Event() + update_started = asyncio.Event() + + async def on_update(session_id: str, workdir: str) -> None: + update_started.set() + await release_update.wait() + + watcher = AgentFileWatcher( + session_store=DummySessionStore(), + claude_jsonl_parser=DummyParser(), + on_update=on_update, + ) + + async def fake_watch_session(*, session_id: str, workdir: str) -> None: + lock = watcher._session_locks.setdefault(session_id, asyncio.Lock()) + task = asyncio.current_task() + try: + async with lock: + await on_update(session_id, workdir) + finally: + watcher._cleanup_finished_session(session_id=session_id, task=task) + + watcher._tasks["session-1"] = asyncio.create_task(fake_watch_session(session_id="session-1", workdir="/tmp/project")) + await update_started.wait() + watcher._seen_mtimes = {"session-1:tool-a:agent-a": 1.0} + + watcher.forget("session-1") + + assert "session-1:tool-a:agent-a" not in watcher._seen_mtimes + assert "session-1" in watcher._session_locks + + release_update.set() + with pytest.raises(asyncio.CancelledError): + await watcher._tasks.get("session-1", asyncio.create_task(asyncio.sleep(0))) + await asyncio.sleep(0) + + assert "session-1" not in watcher._session_locks +``` + +If the second test is brittle because the task is popped by `forget`, store the task before calling `forget`: + +```python + task = watcher._tasks["session-1"] + watcher.forget("session-1") + ... + with pytest.raises(asyncio.CancelledError): + await task +``` + +Use the stored-task version in the final test file. + +- [ ] **Step 2: Run watcher tests and verify they fail** + +Run: + +```bash +python -m pytest tests/test_agent_file_watcher.py -q +``` + +Expected: FAIL because lock cleanup helpers do not exist and `forget()` only pops an exact mtime key. + +- [ ] **Step 3: Implement watcher cleanup helpers** + +In `app/services/agent_file_watcher.py`, add these helpers after `stop_all`: + +```python + def _clear_seen_mtimes_for_session(self, session_id: str) -> None: + prefix = f"{session_id}:" + stale_keys = [key for key in self._seen_mtimes if key == session_id or key.startswith(prefix)] + for key in stale_keys: + self._seen_mtimes.pop(key, None) + + def _cleanup_finished_session(self, *, session_id: str, task: asyncio.Task[None] | None) -> None: + active_task = self._tasks.get(session_id) + if active_task is not None and active_task is not task: + return + if active_task is task: + self._tasks.pop(session_id, None) + self._clear_seen_mtimes_for_session(session_id) + self._session_locks.pop(session_id, None) +``` + +Replace `forget`: + +```python + def forget(self, session_id: str) -> None: + task = self._tasks.pop(session_id, None) + self._clear_seen_mtimes_for_session(session_id) + if task is None or task.done(): + self._session_locks.pop(session_id, None) + return + task.cancel() +``` + +At the end of `stop_all`, after awaiting all tasks, add: + +```python + self._session_locks.clear() +``` + +Replace the watcher `finally` block: + +```python + finally: + self._cleanup_finished_session(session_id=session_id, task=task) +``` + +- [ ] **Step 4: Run watcher tests** + +Run: + +```bash +python -m pytest tests/test_agent_file_watcher.py tests/test_bootstrap_hooks.py::test_agent_file_watcher_syncs_when_subagent_file_changes tests/test_bootstrap_hooks.py::test_start_restores_agent_file_watcher_for_existing_subagent_container -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 8** + +```bash +git add app/services/agent_file_watcher.py tests/test_agent_file_watcher.py +git commit -m "fix: clean agent watcher session state" +``` + +--- + +### Task 9: Final verification and cleanup + +**Files:** +- Verify all modified files. + +- [ ] **Step 1: Run targeted test groups** + +Run: + +```bash +python -m pytest tests/test_upload_queue.py tests/test_file_upload_handler.py tests/test_run_event_streamer_upload_queue.py tests/test_permission_callback_registry.py tests/test_session_handlers.py tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py tests/test_tmux_runner.py tests/test_agent_file_watcher.py tests/test_auth_settings.py tests/test_bootstrap_hooks.py -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run lint and formatting checks** + +Run: + +```bash +python -m ruff check app tests && python -m ruff format --check app tests +``` + +Expected: both commands PASS. + +- [ ] **Step 3: Run full test suite** + +Run: + +```bash +python -m pytest -q +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect final diff** + +Run: + +```bash +git status --short && git diff --stat +``` + +Expected: only intentional files are modified. No temporary files or debug artifacts remain. + +- [ ] **Step 5: Final commit if verification changed files** + +If formatting changed files in Step 2, commit those changes: + +```bash +git add app tests deploy/env/.env.example +git commit -m "chore: format priority stability fixes" +``` + +If Step 2 did not change files, skip this commit. + +--- + +## Self-Review + +**Spec coverage:** +- Upload queue behavior, metadata size rejection, count/byte bounds, restart-loss wording, and background post-final processing are covered by Tasks 1-3. +- Permission short tokens, callback length, stale recovery, token expiry, collision retry, and normal/external callback resolution are covered by Tasks 4-6. +- Unbound pending cleanup, first-responder-wins, tmux lock cleanup, and agent watcher cleanup are covered by Tasks 6-8. +- Configuration summary and rollback-oriented off switch are covered by Task 1 settings and `.env.example` changes. + +**No-placeholder scan:** This plan contains concrete paths, code snippets, commands, and expected outcomes for each implementation step. + +**Type consistency:** `UploadQueueManager`, `PermissionCallbackRegistry`, `UnboundPermissionResponseResult`, `queued_upload_scheduler`, and `effective_upload_queue_max_bytes_per_user` use the same names across tests, implementation steps, and wiring steps. diff --git a/app/adapters/claude/hook_socket_server.py b/app/adapters/claude/hook_socket_server.py index 8865c4b..b6fbce5 100644 --- a/app/adapters/claude/hook_socket_server.py +++ b/app/adapters/claude/hook_socket_server.py @@ -157,6 +157,12 @@ async def get_pending_permission(self, *, session_id: str) -> tuple[str | None, await self._expire_pending_permissions(expired) return found + async def get_session_id_for_tool_use_id(self, tool_use_id: str) -> str | None: + """Look up the session_id for a pending permission by tool_use_id.""" + async with self._lock: + pending = self._pending_permissions.get(tool_use_id) + return pending.session_id if pending is not None else None + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: raw = await reader.read(self._max_message_bytes + 1) if not raw: diff --git a/app/adapters/cli/factory.py b/app/adapters/cli/factory.py index e2e336e..022b627 100644 --- a/app/adapters/cli/factory.py +++ b/app/adapters/cli/factory.py @@ -68,6 +68,11 @@ async def ensure_claude_interactive_session(self, *, terminal_key: str, workdir: return False, "claude adapter 不可用" + async def ensure_claude_resume_session(self, *, terminal_key: str, workdir: str, session_id: str) -> tuple[bool, str]: + if not self._claude_tmux_enabled or self._tmux_runner is None: + return False, "CLAUDE_TMUX_MODE 未开启或 tmux 未配置" + return await self._tmux_runner.ensure_claude_resume_session(terminal_key=terminal_key, workdir=workdir, session_id=session_id) + async def reveal_terminal(self, terminal_key: str) -> tuple[bool, str]: if not self._claude_tmux_enabled or self._tmux_runner is None: return False, "CLAUDE_TMUX_MODE 未开启或 tmux 未配置" diff --git a/app/adapters/process/pty_injector.py b/app/adapters/process/pty_injector.py new file mode 100644 index 0000000..a6a11ed --- /dev/null +++ b/app/adapters/process/pty_injector.py @@ -0,0 +1,198 @@ +"""PTY/TTY injection for external Claude sessions. + +Finds the tmux pane containing an external Claude process and injects +keystrokes via `tmux send-keys`. This enables fully automated Telegram-driven +answers to AskUserQuestion prompts in external sessions. +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil + +logger = logging.getLogger(__name__) + +_TMUX_BIN = "tmux" + + +async def find_tmux_pane_for_pid(pid: int) -> str | None: + """Walk the process tree from *pid* upward, looking for a tmux pane whose + shell PID matches an ancestor. Returns the pane ID (e.g. ``%3``) or None. + """ + tmux_bin = shutil.which(_TMUX_BIN) + if tmux_bin is None: + return None + + # Get all tmux panes and their shell PIDs + try: + proc = await asyncio.create_subprocess_exec( + tmux_bin, + "list-panes", + "-a", + "-F", + "#{pane_id} #{pane_pid}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0: + return None + except (FileNotFoundError, OSError): + return None + + pane_pids: dict[int, str] = {} + for line in stdout.decode(errors="replace").splitlines(): + parts = line.split() + if len(parts) == 2: + try: + pane_pids[int(parts[1])] = parts[0] + except ValueError: + continue + + if not pane_pids: + return None + + # Walk up the process tree from pid + current = pid + visited: set[int] = set() + for _ in range(30): # max depth + if current in pane_pids: + return pane_pids[current] + if current in visited or current <= 1: + break + visited.add(current) + parent = await _get_ppid(current) + if parent is None or parent <= 1 or parent == current: + break + current = parent + + return None + + +async def inject_keys_via_tmux(pane_id: str, *keys: str) -> tuple[bool, str]: + """Send keystrokes to a tmux pane via ``tmux send-keys``.""" + tmux_bin = shutil.which(_TMUX_BIN) + if tmux_bin is None: + return False, "tmux not found" + if not keys: + return True, "" + try: + proc = await asyncio.create_subprocess_exec( + tmux_bin, + "send-keys", + "-t", + pane_id, + *keys, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode == 0: + return True, "" + err = stderr.decode(errors="replace").strip() or "unknown error" + return False, f"tmux send-keys failed: {err}" + except (FileNotFoundError, OSError) as exc: + return False, f"tmux send-keys error: {exc}" + + +async def inject_option_selection( + pane_id: str, + *, + option_index: int, + submit_after: bool = False, + enter_delay_sec: float = 0.15, +) -> tuple[bool, str]: + """Select an option in the Claude TUI by moving cursor down and pressing Enter. + + Assumes cursor starts at the first option (index 0). Sends Down arrow + *option_index* times, then Enter. + """ + # Move cursor to the target option + if option_index > 0: + for _ in range(option_index): + ok, err = await inject_keys_via_tmux(pane_id, "Down") + if not ok: + return False, err + await asyncio.sleep(0.05) + + # Select the option + ok, err = await inject_keys_via_tmux(pane_id, "C-m") + if not ok: + return False, err + + # Submit if this is the final question + if submit_after: + await asyncio.sleep(enter_delay_sec) + ok, err = await inject_keys_via_tmux(pane_id, "C-m") + if not ok: + return False, err + + return True, "" + + +async def inject_text_answer( + pane_id: str, + *, + text: str, + option_count: int, + submit_after: bool = False, + enter_delay_sec: float = 0.15, +) -> tuple[bool, str]: + """Navigate past options to the text input field, type text, and submit. + + Moves cursor down past all options to reach "Other (type answer)", selects it, + then types the text. + """ + # Move to "Other" option (after all regular options) + for _ in range(option_count): + ok, err = await inject_keys_via_tmux(pane_id, "Down") + if not ok: + return False, err + await asyncio.sleep(0.05) + + # Select "Other" + ok, err = await inject_keys_via_tmux(pane_id, "C-m") + if not ok: + return False, err + await asyncio.sleep(enter_delay_sec) + + # Type the text (use tmux send-keys with literal text) + # Escape special characters for tmux + ok, err = await inject_keys_via_tmux(pane_id, text) + if not ok: + return False, err + + # Submit + ok, err = await inject_keys_via_tmux(pane_id, "C-m") + if not ok: + return False, err + + if submit_after: + await asyncio.sleep(enter_delay_sec) + ok, err = await inject_keys_via_tmux(pane_id, "C-m") + if not ok: + return False, err + + return True, "" + + +async def _get_ppid(pid: int) -> int | None: + """Get parent PID of a process.""" + try: + proc = await asyncio.create_subprocess_exec( + "ps", + "-p", + str(pid), + "-o", + "ppid=", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + text = stdout.decode(errors="replace").strip() + if text and text.isdigit(): + return int(text) + return None + except (FileNotFoundError, OSError): + return None diff --git a/app/adapters/process/tmux_commands.py b/app/adapters/process/tmux_commands.py index cac4e86..977afdf 100644 --- a/app/adapters/process/tmux_commands.py +++ b/app/adapters/process/tmux_commands.py @@ -42,8 +42,18 @@ def _build_interactive_claude_command(self, *, workdir: str) -> str: system_prompt = shlex.quote(_INTERACTIVE_SYSTEM_PROMPT) return f"cd {workdir_target} && exec {claude_bin} --append-system-prompt {system_prompt}" + def _build_interactive_claude_resume_command(self, *, workdir: str, session_id: str) -> str: + workdir_target = shlex.quote(str(Path(workdir).resolve())) + claude_bin = shlex.quote(self._claude_cli_bin) + system_prompt = shlex.quote(_INTERACTIVE_SYSTEM_PROMPT) + safe_session_id = shlex.quote(session_id) + return f"cd {workdir_target} && exec {claude_bin} --append-system-prompt {system_prompt} --resume {safe_session_id}" + def _wrap_interactive_prompt(self, *, prompt: str) -> str: safe_prompt = prompt.replace("\r", "").strip() if not safe_prompt: raise ValueError("prompt 不能为空") + # Claude Code TUI may not submit multi-line pastes with C-m; + # collapse to a single line to ensure reliable submission. + safe_prompt = " ".join(safe_prompt.split("\n")) return safe_prompt diff --git a/app/adapters/process/tmux_runner.py b/app/adapters/process/tmux_runner.py index 4889461..babc926 100644 --- a/app/adapters/process/tmux_runner.py +++ b/app/adapters/process/tmux_runner.py @@ -3,6 +3,8 @@ import asyncio import logging import shlex +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -13,6 +15,7 @@ from app.adapters.storage.file_session_store import FileSessionStore from app.domain.models import CLIEvent, EventType, utc_now from app.domain.session_models import ConversationTurn, SessionEvent, SessionEventType, SessionPhase, SessionState, ToolStatus +from app.services.lock_registry import RefCountedLockRegistry from app.services.session_store import SessionStore, is_claude_session_id CCB_BEGIN_PREFIX = "TGCLI_BEGIN" @@ -65,6 +68,7 @@ class _InteractiveWatchState: last_interactive_revision: int | None = None completion_candidate_key: tuple[object, ...] | None = None completion_candidate_seen_at: float | None = None + last_progress_at: float | None = None class TmuxRunner(TmuxSessionMixin, TmuxCommandMixin, TmuxLogMixin): @@ -81,6 +85,10 @@ def __init__( claude_cli_bin: str = "claude", file_store: FileSessionStore | None = None, session_store: SessionStore | None = None, + session_lock_ttl_sec: int = 3600, + lock_cleanup_interval_sec: int = 60, + lock_cleanup_batch_size: int = 50, + lock_clock: Callable[[], float] | None = None, ) -> None: self._tmux_bin = tmux_bin self._data_dir = Path(data_dir) @@ -89,9 +97,15 @@ def __init__( self._enter_delay_sec = max(0.0, enter_delay_sec) self._partial_flush_sec = max(0.0, partial_flush_sec) self._interactive_completion_grace_sec = max(0.0, interactive_completion_grace_sec) + self._interactive_idle_check_sec = 5.0 self._claude_cli_bin = claude_cli_bin self._tasks: dict[str, _TmuxTaskMeta] = {} - self._session_locks: dict[str, asyncio.Lock] = {} + self._session_locks = RefCountedLockRegistry( + ttl_sec=session_lock_ttl_sec, + cleanup_interval_sec=lock_cleanup_interval_sec, + cleanup_batch_size=lock_cleanup_batch_size, + clock=lock_clock, + ) self._lock = asyncio.Lock() self._file_store = file_store or FileSessionStore(str(self._data_dir)) self._session_store = session_store or SessionStore(self._file_store) @@ -199,9 +213,8 @@ async def run( terminal_id=session_id, ) - session_lock = self._get_session_lock(session_name) if persistent_terminal else None - if session_lock is not None: - async with session_lock: + if persistent_terminal: + async with self._session_lock(session_name): async for event in self._run_task(meta=meta, timeout_sec=timeout_sec, env=env, workdir=workdir, command=command): yield event return @@ -375,13 +388,31 @@ async def _watch_task(self, *, meta: _TmuxTaskMeta, timeout_sec: int): tick_result, active_state = self._tick_interactive_watch(meta=meta, watch_state=watch_state, now=now) if tick_result is not None: exit_code = tick_result + # If session was interrupted (Esc), mark as canceled so the + # lifecycle message shows "中断" instead of "完成". + if active_state is not None and active_state.interrupted: + meta.cancel_requested = True break if active_state is not None: if watch_state.last_interactive_revision is None: watch_state.last_interactive_revision = active_state.revision elif active_state.revision != watch_state.last_interactive_revision: watch_state.last_interactive_revision = active_state.revision + watch_state.last_progress_at = now timeout_anchor = now + # Fallback: if we have been idle for a while, check pane content + # to detect manual Esc cancellation that did not produce a hook + # event (including fast Esc before any progress was observed). + # Skip the first few seconds to avoid false positives during + # initial prompt submission. + idle_anchor = watch_state.last_progress_at or started_at + if (now - started_at) >= self._interactive_idle_check_sec and (now - idle_anchor) >= self._interactive_idle_check_sec: + if await self._is_claude_idle_in_pane(meta.session_name): + meta.cancel_requested = True + exit_code = 0 + break + # Don't check again for another interval + watch_state.last_progress_at = now if meta.exit_file.exists(): exit_code = self._read_exit_code(meta.exit_file) @@ -536,6 +567,11 @@ def get_session_state(self, terminal_key: str) -> SessionState | None: async def close_terminal(self, terminal_key: str) -> bool: session_name = self._build_session_name(terminal_key) + # Cancel any tasks running on this terminal so they release the session lock. + async with self._lock: + for meta in self._tasks.values(): + if meta.session_name == session_name and not meta.cancel_requested: + meta.cancel_requested = True exists = await self._session_exists(session_name) if not exists: return False @@ -543,18 +579,23 @@ async def close_terminal(self, terminal_key: str) -> bool: async def ensure_terminal(self, *, terminal_key: str, workdir: str, env: dict[str, str] | None = None) -> tuple[bool, str]: session_name = self._build_session_name(terminal_key) - session_lock = self._get_session_lock(session_name) - async with session_lock: + async with self._session_lock(session_name): return await self._ensure_persistent_session(session_name, workdir=workdir, env=env) async def ensure_claude_interactive_session( self, *, terminal_key: str, workdir: str, env: dict[str, str] | None = None ) -> tuple[bool, str]: session_name = self._build_session_name(terminal_key) - session_lock = self._get_session_lock(session_name) - async with session_lock: + async with self._session_lock(session_name): return await self._ensure_claude_interactive_session(session_name=session_name, workdir=workdir, env=env) + async def ensure_claude_resume_session( + self, *, terminal_key: str, workdir: str, session_id: str, env: dict[str, str] | None = None + ) -> tuple[bool, str]: + session_name = self._build_session_name(terminal_key) + async with self._session_lock(session_name): + return await self._ensure_claude_resume_session(session_name=session_name, workdir=workdir, session_id=session_id, env=env) + async def send_interactive_input(self, *, terminal_key: str, workdir: str, text: str) -> tuple[bool, str]: session_name = self._build_session_name(terminal_key) prompt = self._wrap_interactive_prompt(prompt=text) @@ -676,12 +717,37 @@ async def _is_cancel_requested(self, task_id: str) -> bool: meta = self._tasks.get(task_id) return bool(meta and meta.cancel_requested) - def _get_session_lock(self, session_name: str) -> asyncio.Lock: - lock = self._session_locks.get(session_name) - if lock is None: - lock = asyncio.Lock() - self._session_locks[session_name] = lock - return lock + @asynccontextmanager + async def _session_lock(self, session_name: str) -> AsyncIterator[None]: + async with self._session_locks.lock(session_name): + yield + + async def _is_claude_idle_in_pane(self, session_name: str) -> bool: + """Check if Claude Code TUI is showing an input prompt (idle state). + + This is a fallback detection for when Esc cancellation doesn't produce + a hook event. Claude Code shows a specific pattern when idle: + - A prompt line starting with ❯ or › + - Followed by a horizontal rule (────) separator + - Followed by a status bar (model | branch | files) + """ + pane_text = await self._capture_pane_text(session_name, start_line=-15) + if not pane_text: + return False + lines = pane_text.splitlines() + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped.startswith(("›", "❯")): + continue + # Found a prompt line; check if the next non-empty line is a separator + for j in range(i + 1, min(i + 3, len(lines))): + next_stripped = lines[j].strip() + if not next_stripped: + continue + if next_stripped.startswith("─") and len(next_stripped) >= 10: + return True + break + return False def _capture_interactive_baseline(self, *, meta: _TmuxTaskMeta) -> None: resolved_session_id = self._session_store.resolve_interactive_session_id( @@ -907,6 +973,17 @@ def _tick_interactive_watch( ) if completion_phase is not None and watch_state.saw_interactive_progress and completion_ready: return 0, active_state + # Detect manual cancellation (Esc in Claude Code): session returned to + # WAITING_FOR_INPUT after we observed processing activity, but no new + # completed turn was produced for the current run (i.e. the request was + # interrupted before producing a response). + if ( + completion_phase == SessionPhase.WAITING_FOR_INPUT + and watch_state.saw_interactive_progress + and not latest_completed_turn_is_current + ): + meta.cancel_requested = True + return 0, active_state if latest_completed_turn is not None and latest_completed_turn_is_current: if active_state is not None and self._is_interactive_completion_turn_ready( active_state, latest_completed_turn, watch_state, observed_at=now diff --git a/app/adapters/process/tmux_session.py b/app/adapters/process/tmux_session.py index 735f6dc..60cb063 100644 --- a/app/adapters/process/tmux_session.py +++ b/app/adapters/process/tmux_session.py @@ -104,6 +104,18 @@ async def _ensure_claude_interactive_session(self, *, session_name: str, workdir return False, respawn_err return True, "" + async def _ensure_claude_resume_session( + self, *, session_name: str, workdir: str, session_id: str, env: dict[str, str] | None + ) -> tuple[bool, str]: + ready, err = await self._ensure_persistent_session(session_name, workdir=workdir, env=env) + if not ready: + return False, err + command = self._build_interactive_claude_resume_command(workdir=workdir, session_id=session_id) + respawned, respawn_err = await self._respawn_and_send_command(session_name=session_name, command=command, workdir=workdir) + if not respawned: + return False, respawn_err + return True, "" + async def _send_command( self, session_name: str, command: str, *, workdir: str, env: dict[str, str] | None, interactive: bool = False ) -> tuple[bool, str]: @@ -155,7 +167,8 @@ async def _send_command( return False, self._format_send_failure( base="tmux 执行命令失败", raw_err=err_text, session_name=session_name, rebuilt=True ) - await self._run_tmux("send-keys", "-t", session_name, "C-u") + if not interactive: + await self._run_tmux("send-keys", "-t", session_name, "C-u") return True, "" finally: await self._run_tmux("delete-buffer", "-b", buffer_name) diff --git a/app/adapters/storage/file_session_context_store.py b/app/adapters/storage/file_session_context_store.py index 34bfdf3..ae96cb0 100644 --- a/app/adapters/storage/file_session_context_store.py +++ b/app/adapters/storage/file_session_context_store.py @@ -10,6 +10,8 @@ class FileSessionContextStore: def __init__(self, file_store: FileSessionStore) -> None: self._file_store = file_store self._lock = asyncio.Lock() + self._list_cache: list[SessionContext] | None = None + self._claude_session_index: dict[str, SessionContext] = {} async def get(self, user_id: int) -> SessionContext | None: async with self._lock: @@ -17,8 +19,30 @@ async def get(self, user_id: int) -> SessionContext | None: async def list_all(self) -> list[SessionContext]: async with self._lock: - return self._file_store.list_session_contexts() + if self._list_cache is not None: + return list(self._list_cache) + contexts = self._file_store.list_session_contexts() + self._list_cache = contexts + # Rebuild index from loaded contexts + self._claude_session_index = {ctx.claude_session_id: ctx for ctx in contexts if ctx.claude_session_id} + return list(contexts) + + async def get_by_claude_session_id(self, claude_session_id: str) -> SessionContext | None: + """O(1) index lookup by claude_session_id. Returns None on miss.""" + async with self._lock: + return self._claude_session_index.get(claude_session_id) async def save(self, session: SessionContext) -> None: async with self._lock: + # Remove stale index entry if claude_session_id changed or was cleared + old = self._file_store.load_session_context(session.user_id) + if old is not None and old.claude_session_id and old.claude_session_id != session.claude_session_id: + self._claude_session_index.pop(old.claude_session_id, None) + + # Persist self._file_store.save_session_context(session) + self._list_cache = None # invalidate cache on write + + # Update index (last-writer-wins when two contexts share the same id) + if session.claude_session_id: + self._claude_session_index[session.claude_session_id] = session diff --git a/app/adapters/storage/memory.py b/app/adapters/storage/memory.py index df92063..a4e6461 100644 --- a/app/adapters/storage/memory.py +++ b/app/adapters/storage/memory.py @@ -2,36 +2,72 @@ import asyncio from collections.abc import Iterable +from datetime import datetime, timedelta +from heapq import nsmallest from typing import Protocol -from app.domain.models import SessionContext, TaskRecord +from app.domain.models import SessionContext, TaskRecord, utc_now class MemoryTaskStore: - def __init__(self) -> None: + def __init__(self, max_records: int = 1000, ttl_hours: int = 168) -> None: + if max_records <= 0: + raise ValueError(f"max_records must be positive, got {max_records}") + if ttl_hours <= 0: + raise ValueError(f"ttl_hours must be positive, got {ttl_hours}") + self._max_records = max_records + self._ttl = timedelta(hours=ttl_hours) self._tasks: dict[str, TaskRecord] = {} self._lock = asyncio.Lock() + def _evict_expired_and_overflow_locked(self) -> None: + now = utc_now() + expired_ids = [ + task_id for task_id, record in self._tasks.items() if record.is_final and now - self._retention_time(record) > self._ttl + ] + for task_id in expired_ids: + self._tasks.pop(task_id, None) + + overflow = len(self._tasks) - self._max_records + if overflow <= 0: + return + + final_records = nsmallest( + overflow, + (record for record in self._tasks.values() if record.is_final), + key=self._retention_time, + ) + for record in final_records: + self._tasks.pop(record.task_id, None) + + def _retention_time(self, record: TaskRecord) -> datetime: + return record.ended_at or record.created_at + async def add(self, record: TaskRecord) -> None: async with self._lock: self._tasks[record.task_id] = record + self._evict_expired_and_overflow_locked() async def get(self, task_id: str) -> TaskRecord | None: async with self._lock: + self._evict_expired_and_overflow_locked() return self._tasks.get(task_id) async def save(self, record: TaskRecord) -> None: async with self._lock: self._tasks[record.task_id] = record + self._evict_expired_and_overflow_locked() async def list_by_user(self, user_id: int, limit: int = 10) -> list[TaskRecord]: async with self._lock: + self._evict_expired_and_overflow_locked() items = [x for x in self._tasks.values() if x.user_id == user_id] items.sort(key=lambda x: x.created_at, reverse=True) return items[:limit] async def iter_all(self) -> Iterable[TaskRecord]: async with self._lock: + self._evict_expired_and_overflow_locked() return list(self._tasks.values()) @@ -42,6 +78,8 @@ async def list_all(self) -> list[SessionContext]: ... async def save(self, session: SessionContext) -> None: ... + async def get_by_claude_session_id(self, claude_session_id: str) -> SessionContext | None: ... + class MemorySessionStore: def __init__(self) -> None: @@ -56,6 +94,13 @@ async def list_all(self) -> list[SessionContext]: async with self._lock: return list(self._sessions.values()) + async def get_by_claude_session_id(self, claude_session_id: str) -> SessionContext | None: + async with self._lock: + for session in self._sessions.values(): + if session.claude_session_id == claude_session_id: + return session + return None + async def save(self, session: SessionContext) -> None: async with self._lock: self._sessions[session.user_id] = session diff --git a/app/bootstrap.py b/app/bootstrap.py index 228412c..5700d6d 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -2,6 +2,7 @@ import asyncio import logging +from pathlib import Path from aiogram import Bot, Dispatcher from aiogram.client.session.aiohttp import AiohttpSession @@ -34,14 +35,26 @@ from app.services.claude_jsonl_parser import ClaudeJSONLParser from app.services.context_builder import ContextBuilderService from app.services.diff_generator import DiffGeneratorService +from app.services.external_binding_store import ExternalBindingStore +from app.services.external_session_binder import ExternalSessionBinder +from app.services.external_session_discovery import ExternalSessionDiscoveryService +from app.services.external_session_push_notifier import ExternalSessionPushNotifier from app.services.file_receiver import FileReceiverService +from app.services.file_sender import FileSenderService from app.services.interrupt_watcher import InterruptWatcher +from app.services.lock_registry import RefCountedLockRegistry from app.services.result_exporter import ResultExporterService +from app.services.session_ownership_resolver import SessionOwnershipResolver from app.services.session_service import SessionService from app.services.session_registry import SessionRegistryService +from app.services.session_scanner import SessionScanner from app.services.session_store import SessionStore from app.services.task_service import TaskService +from app.services.auto_approve_service import AutoApproveService +from app.services.permission_callback_registry import PermissionCallbackRegistry +from app.services.unbound_permission_handler import UnboundPermissionHandler from app.services.upload_cleanup import UploadCleanupService +from app.services.upload_queue import UploadQueueManager logger = logging.getLogger(__name__) @@ -75,7 +88,10 @@ def __init__(self, settings: Settings) -> None: self.bot = Bot(token=settings.tg_bot_token, session=session) self.dispatcher = Dispatcher() - self.task_store = MemoryTaskStore() + self.task_store = MemoryTaskStore( + max_records=settings.task_store_max_records, + ttl_hours=settings.task_store_ttl_hours, + ) self.runner = SubprocessRunner() self.claude_paths = ClaudePaths.resolve(settings.claude_config_dir) @@ -91,6 +107,9 @@ def __init__(self, settings: Settings) -> None: pending_permission_ttl_sec=settings.claude_hook_pending_permission_ttl_sec, max_pending_permissions=settings.claude_hook_max_pending_permissions, ) + self.permission_callback_registry = PermissionCallbackRegistry( + ttl_sec=settings.claude_hook_pending_permission_ttl_sec, + ) self.file_session_store = FileSessionStore(settings.tmux_data_dir) self.session_context_store = FileSessionContextStore(self.file_session_store) self.claude_jsonl_parser = ClaudeJSONLParser(self.claude_paths) @@ -110,6 +129,9 @@ def __init__(self, settings: Settings) -> None: claude_cli_bin=settings.claude_cli_bin, file_store=self.file_session_store, session_store=self.structured_session_store, + session_lock_ttl_sec=settings.session_lock_ttl_sec, + lock_cleanup_interval_sec=settings.lock_cleanup_interval_sec, + lock_cleanup_batch_size=settings.lock_cleanup_batch_size, ) self.cli_factory = CLIAdapterFactory( settings=settings, @@ -123,6 +145,18 @@ def __init__(self, settings: Settings) -> None: allowed_extensions=set(settings.allowed_file_extensions), max_file_size_bytes=settings.upload_max_file_size_mb * 1024 * 1024, ) + self.upload_queue = UploadQueueManager( + max_files_per_user=settings.upload_queue_max_files_per_user, + max_bytes_per_user=settings.effective_upload_queue_max_bytes_per_user, + ttl_sec=settings.upload_queue_ttl_sec, + cleanup_interval_sec=settings.upload_queue_cleanup_interval_sec, + ) + self.file_sender = FileSenderService( + bot=self.bot, + enabled=settings.auto_file_send_enabled, + extensions=set(settings.auto_file_send_extensions), + image_extensions={".png", ".jpg", ".jpeg", ".gif", ".webp"}, + ) self.context_builder = ContextBuilderService(upload_store=self.upload_store) self.result_exporter = ResultExporterService(settings=settings) self.diff_generator = DiffGeneratorService() @@ -143,23 +177,78 @@ def __init__(self, settings: Settings) -> None: hook_socket_server=self.hook_socket_server, context_builder=self.context_builder, ) + self.auto_approve_service = AutoApproveService() self.session_registry = SessionRegistryService( session_service=self.session_service, lookup=self.structured_session_store._lookup, tmux_runner=self.tmux_runner, repository=self.structured_session_store._repository, + auto_approve_service=self.auto_approve_service, health_check_interval_sec=settings.session_health_check_interval_sec, ) + + # External session services + self.external_binding_store = ExternalBindingStore( + data_dir=Path(settings.tmux_data_dir), + ) + self.external_discovery = ExternalSessionDiscoveryService( + stale_timeout_sec=settings.external_session_stale_timeout_sec, + title_resolver=lambda sid, cwd: self.claude_jsonl_parser.extract_session_title(session_id=sid, cwd=cwd), + ) + self.ownership_resolver = SessionOwnershipResolver( + session_service=self.session_service, + binding_store=self.external_binding_store, + ) + self.external_binder = ExternalSessionBinder( + discovery=self.external_discovery, + binding_store=self.external_binding_store, + projects_dir=Path("~/.claude/projects").expanduser(), + sync_callback=self.sync_claude_session, + ) + self.push_notifier = ExternalSessionPushNotifier( + bot=self.bot, + binding_store=self.external_binding_store, + retry_count=settings.push_notification_retry_count, + ) + self.unbound_permission_handler = UnboundPermissionHandler( + bot=self.bot, + hook_socket_server=self.hook_socket_server, + allowed_user_ids=settings.allowed_user_id_set, + permission_callback_registry=self.permission_callback_registry, + title_resolver=lambda sid, cwd: self.claude_jsonl_parser.extract_session_title(session_id=sid, cwd=cwd), + ) + + # External user question state for PTY injection + from app.services.external_user_question_state import ExternalUserQuestionState + + self.external_uq_state = ExternalUserQuestionState() + self._jsonl_sync_tasks: dict[str, asyncio.Task[None]] = {} self._jsonl_sync_requests: dict[str, str] = {} - self._jsonl_sync_locks: dict[str, asyncio.Lock] = {} - self._session_event_locks: dict[str, asyncio.Lock] = {} + self._jsonl_sync_locks = RefCountedLockRegistry( + ttl_sec=settings.session_lock_ttl_sec, + cleanup_interval_sec=settings.lock_cleanup_interval_sec, + cleanup_batch_size=settings.lock_cleanup_batch_size, + ) + self._session_event_locks = RefCountedLockRegistry( + ttl_sec=settings.session_lock_ttl_sec, + cleanup_interval_sec=settings.lock_cleanup_interval_sec, + cleanup_batch_size=settings.lock_cleanup_batch_size, + ) self._periodic_recheck_task: asyncio.Task[None] | None = None + self._background_tasks: set[asyncio.Task[None]] = set() self._started = False async def start(self) -> None: if self._started: return + # Register command menu (best-effort) + try: + from app.bot.commands import BOT_COMMANDS + + await self.bot.set_my_commands(BOT_COMMANDS) + except Exception as exc: + logger.warning("Failed to register bot commands: %s", exc) if self.settings.claude_install_hooks: self.hook_installer.install() await self.hook_socket_server.start(self._handle_hook_event, self._handle_permission_failure) @@ -168,6 +257,7 @@ async def start(self) -> None: self._start_agent_file_watchers() self._periodic_recheck_task = asyncio.create_task(self._periodic_recheck_loop()) await self.session_registry.start_health_check() + await self.upload_queue.start_cleanup() await self.upload_cleanup.start() self._started = True @@ -176,6 +266,7 @@ async def stop(self) -> None: await self.bot.session.close() return await self.upload_cleanup.stop() + await self.upload_queue.stop_cleanup() await self.session_registry.stop_health_check() await self._stop_periodic_recheck_task() await self._stop_jsonl_sync_tasks() @@ -193,6 +284,9 @@ def wire(self) -> None: rate_limit_middleware = RateLimitMiddleware( limit=self.settings.rate_limit_max_requests, window_sec=self.settings.rate_limit_window_sec, + bucket_ttl_sec=self.settings.effective_rate_limit_bucket_ttl_sec, + cleanup_interval_sec=self.settings.rate_limit_bucket_cleanup_interval_sec, + cleanup_batch_size=self.settings.rate_limit_bucket_cleanup_batch_size, ) self.dispatcher.message.middleware(auth_middleware) self.dispatcher.callback_query.middleware(auth_middleware) @@ -205,7 +299,18 @@ def wire(self) -> None: session_service=self.session_service, registry_service=self.session_registry, file_receiver=self.file_receiver, + upload_queue=self.upload_queue, result_exporter=self.result_exporter, diff_generator=self.diff_generator, + external_discovery=self.external_discovery, + external_binder=self.external_binder, + structured_session_store=self.structured_session_store, + hook_socket_server=self.hook_socket_server, + unbound_permission_handler=self.unbound_permission_handler, + external_uq_state=self.external_uq_state, + auto_approve_service=self.auto_approve_service, + permission_callback_registry=self.permission_callback_registry, + session_scanner=SessionScanner(), + claude_paths=self.claude_paths, ) self.dispatcher.include_router(router) diff --git a/app/bootstrap_base.py b/app/bootstrap_base.py index c308afc..1697fa4 100644 --- a/app/bootstrap_base.py +++ b/app/bootstrap_base.py @@ -16,6 +16,7 @@ from app.services.agent_file_watcher import AgentFileWatcher from app.services.claude_jsonl_parser import ClaudeJSONLParser from app.services.interrupt_watcher import InterruptWatcher +from app.services.lock_registry import RefCountedLockRegistry from app.services.session_service import SessionService from app.services.session_registry import SessionRegistryService from app.services.session_store import SessionStore @@ -49,7 +50,8 @@ class AppContainerBase: session_registry: SessionRegistryService _jsonl_sync_tasks: dict[str, asyncio.Task[None]] _jsonl_sync_requests: dict[str, str] - _jsonl_sync_locks: dict[str, asyncio.Lock] - _session_event_locks: dict[str, asyncio.Lock] + _jsonl_sync_locks: RefCountedLockRegistry + _session_event_locks: RefCountedLockRegistry _periodic_recheck_task: asyncio.Task[None] | None + _background_tasks: set[asyncio.Task[None]] _started: bool diff --git a/app/bootstrap_mixins.py b/app/bootstrap_mixins.py index fa9d0cc..72d1a62 100644 --- a/app/bootstrap_mixins.py +++ b/app/bootstrap_mixins.py @@ -2,24 +2,42 @@ import asyncio import logging +from collections.abc import Awaitable from contextlib import suppress +from datetime import datetime, timezone from pathlib import Path +from typing import TYPE_CHECKING from app.config.settings import is_workdir_allowed from app.domain.hook_models import HookEvent from app.domain.models import SessionContext, TaskStatus from app.domain.session_models import SessionEvent, SessionEventType, SessionPhase, SessionState +from app.domain.user_question_models import extract_user_question_prompts from app.bootstrap_base import AppContainerBase +if TYPE_CHECKING: + from app.domain.external_session_models import OwnershipResult + logger = logging.getLogger(__name__) +class _StageShortCircuit(Exception): + """Raised by a pipeline stage to terminate the rest of the stage list. + + The orchestration loop catches this, logs at INFO level, closes unawaited + coroutines, and stops further stage execution. Not treated as an error. + """ + + def __init__(self, *, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + class JsonlSyncMixin(AppContainerBase): """JSONL sync: debounced incremental parsing and event dispatch.""" async def sync_claude_session(self, session_id: str, cwd: str) -> None: - lock = self._jsonl_sync_locks.setdefault(session_id, asyncio.Lock()) - async with lock: + async with self._jsonl_sync_locks.lock(session_id): snapshot = self.claude_jsonl_parser.parse_incremental(session_id=session_id, cwd=cwd) logger.info( "claude session synced", @@ -46,7 +64,7 @@ async def _stop_jsonl_sync_tasks(self) -> None: tasks = list(self._jsonl_sync_tasks.values()) self._jsonl_sync_tasks.clear() self._jsonl_sync_requests.clear() - self._jsonl_sync_locks.clear() + await self._jsonl_sync_locks.clear() for task in tasks: task.cancel() for task in tasks: @@ -87,18 +105,14 @@ async def _debounced_sync_claude_session(self, session_id: str) -> None: self._jsonl_sync_tasks.pop(session_id, None) if session_id in self._jsonl_sync_requests: self._jsonl_sync_tasks[session_id] = asyncio.create_task(self._debounced_sync_claude_session(session_id)) + else: + await self._jsonl_sync_locks.cleanup_key(session_id) class HookHandlingMixin(AppContainerBase): """Hook event handling: validate, bind session, dispatch events.""" async def _handle_hook_event(self, event: HookEvent) -> None: - if not is_workdir_allowed(event.cwd, self.settings.allowed_workdirs): - logger.warning( - "hook event rejected by workdir allowlist", - extra={"session_id": event.session_id, "cwd": event.cwd, "event": event.event}, - ) - return logger.debug( "hook event received", extra={ @@ -108,15 +122,403 @@ async def _handle_hook_event(self, event: HookEvent) -> None: "tool": event.tool, }, ) - await self._bind_hook_session(event) - await self._dispatch_session_event( - SessionEvent( + + # Stage 1: Ownership resolution (gate — failure halts pipeline) + ownership = await self._resolve_ownership_stage(event) + if ownership is None: + return + + # Stages 2+: each wrapped independently in error boundaries. + # A stage may raise _StageShortCircuit to terminate the pipeline early. + stages = self._build_stage_list(event, ownership) + executed_up_to = -1 + for i, (stage_name, stage_coro) in enumerate(stages): + try: + await stage_coro + executed_up_to = i + except _StageShortCircuit as sc: + logger.info( + "hook pipeline short-circuited", + extra={ + "stage_name": stage_name, + "reason": sc.reason, + "session_id": event.session_id, + "event_type": event.event, + }, + ) + executed_up_to = i + break + except Exception: + logger.exception( + "hook pipeline stage failed", + extra={ + "stage_name": stage_name, + "session_id": getattr(event, "session_id", None), + "event_type": getattr(event, "event", None), + "hook_cwd": getattr(event, "cwd", None), + }, + ) + + # Close un-awaited coroutines from skipped stages + for j in range(executed_up_to + 1, len(stages)): + coro = stages[j][1] + if hasattr(coro, "close"): + coro.close() + + async def _resolve_ownership_stage(self, event: HookEvent) -> OwnershipResult | None: + """Gate stage: workdir check, SessionEnd cleanup, and ownership resolution. + + Returns the OwnershipResult on success, or None if the event should be + skipped (rejected by workdir allowlist or handled via legacy fallback). + Exceptions are logged as ERROR with stage_name="ownership_resolution" and + the method returns None so the pipeline halts gracefully. + """ + try: + # Workdir allowlist check + if not is_workdir_allowed(event.cwd, self.settings.allowed_workdirs): + logger.warning( + "hook event rejected by workdir allowlist", + extra={"session_id": event.session_id, "cwd": event.cwd, "event": event.event}, + ) + return None + + # Clear auto-approve state on session end + if event.event == "SessionEnd" and hasattr(self, "auto_approve_service"): + self.auto_approve_service.clear_session(event.session_id) + + # Remove external binding on session end so /list doesn't show stale entries + if event.event == "SessionEnd" and hasattr(self, "external_binding_store"): + self.external_binding_store.remove_binding(event.session_id) + + # If ownership_resolver is not wired (e.g. in tests), fall back to old behavior + if not hasattr(self, "ownership_resolver"): + await self._bind_hook_session(event) + await self._dispatch_session_event( + SessionEvent( + session_id=event.session_id, + type=SessionEventType.HOOK_RECEIVED, + payload=event.to_dict(), + ) + ) + self._schedule_jsonl_sync(event.session_id, event.cwd) + return None + + # Resolve ownership + ownership = await self.ownership_resolver.resolve(event.session_id) + logger.info( + "hook event ownership resolved", + extra={ + "session_id": event.session_id, + "ownership_state": ownership.ownership_state, + "origin": ownership.origin.value, + "owner_user_id": ownership.owner_user_id, + }, + ) + return ownership + except Exception: + logger.exception( + "hook pipeline stage failed", + extra={ + "stage_name": "ownership_resolution", + "session_id": event.session_id, + "event_type": event.event, + "hook_cwd": event.cwd, + }, + ) + return None + + def _build_stage_list(self, event: HookEvent, ownership: OwnershipResult) -> list[tuple[str, Awaitable[None]]]: + """Build the ordered list of pipeline stages based on ownership state. + + Returns a list of (stage_name, coroutine) tuples. Each coroutine is a + zero-arg awaitable that captures the needed context from event/ownership. + """ + stages: list[tuple[str, Awaitable[None]]] = [] + + if ownership.ownership_state == "owned": + # Session binding MUST run before auto-approve check so that + # structured_session_store is updated even when short-circuited. + stages.append( + ( + "session_binding", + self._bind_hook_session(event), + ) + ) + # Event dispatch + stages.append( + ( + "event_dispatch", + self._dispatch_session_event( + SessionEvent( + session_id=event.session_id, + type=SessionEventType.HOOK_RECEIVED, + payload=event.to_dict(), + ) + ), + ) + ) + + # JSONL sync scheduling (sync, not async — wrap in a trivial coroutine) + async def _schedule_jsonl_owned() -> None: + self._schedule_jsonl_sync(event.session_id, event.cwd) + + stages.append(("jsonl_sync_scheduling", _schedule_jsonl_owned())) + + # Auto-approve check — may short-circuit, skipping only auto_file_send + stages.append( + ( + "auto_approve_check", + self._run_auto_approve_check(event), + ) + ) + + # Auto-file-send (sync — wrap in a trivial coroutine) + async def _auto_file_send_owned() -> None: + self._maybe_auto_file_send(event, ownership.owner_user_id) + + stages.append(("auto_file_send", _auto_file_send_owned())) + + elif ownership.ownership_state == "bound": + # Event dispatch MUST run before auto-approve check + stages.append( + ( + "event_dispatch", + self._dispatch_session_event( + SessionEvent( + session_id=event.session_id, + type=SessionEventType.HOOK_RECEIVED, + payload=event.to_dict(), + ) + ), + ) + ) + + # JSONL sync scheduling + async def _schedule_jsonl_bound() -> None: + self._schedule_jsonl_sync(event.session_id, event.cwd) + + stages.append(("jsonl_sync_scheduling", _schedule_jsonl_bound())) + + # Auto-approve check — may short-circuit, skipping push_notification + stages.append( + ( + "auto_approve_check", + self._run_auto_approve_check(event), + ) + ) + + # Push notification + async def _push_notification_bound() -> None: + if hasattr(self, "push_notifier") and ownership.owner_user_id is not None: + await self._notify_bound_external_event(event, ownership.owner_user_id) + + stages.append(("push_notification", _push_notification_bound())) + + # Auto-file-send + async def _auto_file_send_bound() -> None: + self._maybe_auto_file_send(event, ownership.owner_user_id) + + stages.append(("auto_file_send", _auto_file_send_bound())) + + else: + # Unbound + # External discovery record + async def _external_discovery() -> None: + if hasattr(self, "external_discovery"): + self.external_discovery.record_event(event) + + stages.append(("external_discovery", _external_discovery())) + + # Permission handling + async def _permission_handling() -> None: + if event.expects_response and hasattr(self, "unbound_permission_handler"): + if event.tool == "AskUserQuestion": + await self._auto_allow_ask_user_question(event) + else: + if hasattr(self, "auto_approve_service") and self.auto_approve_service.is_active(event.session_id): + await self._handle_auto_approved_permission(event) + else: + await self.unbound_permission_handler.handle_unbound_permission(event) + + stages.append(("permission_handling", _permission_handling())) + + return stages + + async def _run_auto_approve_check(self, event: HookEvent) -> None: + """Check if the event should be auto-approved. + + If active, handles permission response and raises _StageShortCircuit + to terminate the pipeline — downstream stages must not send redundant prompts. + """ + if event.expects_response and event.tool != "AskUserQuestion": + if hasattr(self, "auto_approve_service") and self.auto_approve_service.is_active(event.session_id): + await self._handle_auto_approved_permission(event) + raise _StageShortCircuit(reason="auto-approved") + + def _maybe_auto_file_send(self, event: HookEvent, owner_user_id: int | None) -> None: + if event.event == "PostToolUse" and event.tool == "Write" and owner_user_id is not None and hasattr(self, "file_sender"): + file_path_raw = event.tool_input.get("file_path", "") if event.tool_input else "" + task = asyncio.create_task( + self.file_sender.send_if_eligible( + file_path_raw=file_path_raw, + cwd=event.cwd, + chat_id=owner_user_id, + ) + ) + self._background_tasks.add(task) + task.add_done_callback(self._on_background_task_done) + + def _on_background_task_done(self, task: asyncio.Task[None]) -> None: + self._background_tasks.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning("background task failed", exc_info=exc) + + async def _notify_bound_external_event(self, event: HookEvent, user_id: int) -> None: + """Send push notifications for bound external session events.""" + if not hasattr(self, "push_notifier"): + return + if event.expects_response: + # AskUserQuestion: try PTY injection flow if tmux pane is available + if event.tool == "AskUserQuestion": + prompts = extract_user_question_prompts( + tool_use_id=event.tool_use_id or "", + tool_name=event.tool, + tool_input=event.tool_input, + ) + if prompts and hasattr(self, "external_uq_state") and event.pid is not None: + # Try to find tmux pane for interactive injection + from app.adapters.process.pty_injector import find_tmux_pane_for_pid + + pane_id = await find_tmux_pane_for_pid(event.pid) + if pane_id is not None: + # Store pending state and show interactive buttons + # Do NOT auto-allow — hold the permission until user clicks + from app.services.external_user_question_state import PendingExternalUserQuestion + + pending = PendingExternalUserQuestion( + tool_use_id=event.tool_use_id or "", + session_id=event.session_id, + user_id=user_id, + pid=event.pid, + prompts=prompts, + pane_id=pane_id, + ) + self.external_uq_state.store(pending) + await self.push_notifier.notify_user_question( + user_id=user_id, + session_id=event.session_id, + prompts=prompts, + interactive=True, + ) + return + + # Fallback: no tmux pane found or no PID — auto-allow and show read-only + await self._auto_allow_ask_user_question(event) + if prompts: + await self.push_notifier.notify_user_question( + user_id=user_id, + session_id=event.session_id, + prompts=prompts, + interactive=False, + ) + else: + # Fallback: couldn't parse structured prompts + short_id = event.session_id[:8] + question = "" + if event.tool_input: + question = event.tool_input.get("question", "") + text = f"❓ [{short_id}] 等待用户输入 — 请在终端中选择" + if question: + truncated = question[:150] + ("..." if len(question) > 150 else "") + text += f"\n{truncated}" + await self.push_notifier.notify_info(user_id=user_id, text=text) + return + # Resolve title for permission notification + _title: str | None = None + if hasattr(self, "claude_jsonl_parser"): + try: + _title = self.claude_jsonl_parser.extract_session_title(session_id=event.session_id, cwd=event.cwd) + except Exception: + pass + await self.push_notifier.notify_permission_request( + user_id=user_id, + session_id=event.session_id, + tool_name=event.tool or "", + tool_input=event.tool_input, + tool_use_id=event.tool_use_id or "", + cwd=event.cwd, + title=_title, + ) + elif event.event == "Stop": + await self.push_notifier.notify_session_end( + user_id=user_id, session_id=event.session_id, - type=SessionEventType.HOOK_RECEIVED, - payload=event.to_dict(), + cwd=event.cwd, ) + + async def _auto_allow_ask_user_question(self, event: HookEvent) -> None: + """Auto-allow AskUserQuestion permission requests — user answers in terminal.""" + tool_use_id = event.tool_use_id or "" + if not tool_use_id: + return + await self.hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision="allow", + reason="AskUserQuestion auto-allowed", + ) + + async def _handle_auto_approved_permission(self, event: HookEvent) -> None: + """Auto-approve a permission request and send silent notification.""" + tool_use_id = event.tool_use_id or "" + if not tool_use_id: + return + + # Respond with allow immediately + await self.hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision="allow", + reason="auto-approved", + ) + + # Send silent notification to the user who activated auto-approve + entry = self.auto_approve_service._sessions.get(event.session_id) + if entry is not None: + tool_name = event.tool or "Unknown" + input_summary = self._format_auto_approve_input_summary(event) + message = f"🟢 Auto-approved: {tool_name} {input_summary}".strip() + try: + await self.bot.send_message(chat_id=entry.user_id, text=message) + except Exception: + logger.warning( + "Failed to send auto-approve notification", + extra={"session_id": event.session_id, "tool_use_id": tool_use_id}, + ) + + # Audit log + logger.info( + "permission auto-approved", + extra={ + "session_id": event.session_id, + "tool": event.tool, + "tool_use_id": tool_use_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, ) - self._schedule_jsonl_sync(event.session_id, event.cwd) + + def _format_auto_approve_input_summary(self, event: HookEvent) -> str: + """Format a brief summary of tool_input for auto-approve notifications.""" + if not event.tool_input: + return "" + # Common patterns for tool inputs + for key in ("file_path", "path", "command", "url", "query", "description"): + value = event.tool_input.get(key) + if value and isinstance(value, str): + truncated = value[:80] + ("..." if len(value) > 80 else "") + return truncated + return "" async def _handle_permission_failure(self, session_id: str, tool_use_id: str) -> None: logger.warning( @@ -175,27 +577,30 @@ class SessionMatchingMixin(AppContainerBase): """Session matching: bind hook events to user sessions.""" async def _match_session_context(self, event: HookEvent) -> SessionContext | None: + # O(1) index lookup by claude_session_id (most common match path) + indexed = await self.session_service.lookup_by_claude_session_id(event.session_id) + if indexed is not None: + logger.info( + "matched hook session by claude_session_id (index)", + extra={ + "hook_session_id": event.session_id, + "user_id": indexed.user_id, + "workdir": indexed.workdir, + "terminal_id": indexed.terminal_id, + }, + ) + return indexed + + # Index miss — fall back to full-scan matching logic sessions = await self.session_service.list_all() logger.info( - "matching hook session context", + "matching hook session context (fallback)", extra={ "hook_session_id": event.session_id, "hook_cwd": event.cwd, "session_count": len(sessions), }, ) - for session in sessions: - if session.claude_session_id == event.session_id: - logger.info( - "matched hook session by claude_session_id", - extra={ - "hook_session_id": event.session_id, - "user_id": session.user_id, - "workdir": session.workdir, - "terminal_id": session.terminal_id, - }, - ) - return session state = self.structured_session_store.get(event.session_id) if state is not None: @@ -414,25 +819,11 @@ async def _recheck_active_claude_sessions(self) -> None: if session.provider != "claude_code" or not session.claude_chat_active: continue if not session.claude_session_id: - logger.info("periodic recheck skipped", extra={"user_id": session.user_id, "reason": "no_claude_session_id"}) continue state = self.structured_session_store.get(session.claude_session_id) if state is None: - logger.info( - "periodic recheck skipped", - extra={"user_id": session.user_id, "claude_session_id": session.claude_session_id, "reason": "no_state"}, - ) continue if state.phase not in {SessionPhase.PROCESSING, SessionPhase.WAITING_FOR_APPROVAL}: - logger.info( - "periodic recheck skipped", - extra={ - "user_id": session.user_id, - "claude_session_id": session.claude_session_id, - "reason": "phase_not_active", - "phase": state.phase.value, - }, - ) continue logger.info( "periodic recheck syncing", @@ -498,8 +889,7 @@ class EventDispatchMixin(AppContainerBase): """Session event dispatch with per-session locking.""" async def _dispatch_session_event(self, event: SessionEvent) -> None: - lock = self._session_event_locks.setdefault(event.session_id, asyncio.Lock()) - async with lock: + async with self._session_event_locks.lock(event.session_id): self.structured_session_store.get_or_create( session_id=event.session_id, provider="claude_code", @@ -507,3 +897,5 @@ async def _dispatch_session_event(self, event: SessionEvent) -> None: claude_session_id=event.session_id, ) self.structured_session_store.process(event) + if event.type == SessionEventType.SESSION_ENDED: + await self._session_event_locks.cleanup_key(event.session_id, require_expired=False) diff --git a/app/bot/commands.py b/app/bot/commands.py new file mode 100644 index 0000000..47d5f4e --- /dev/null +++ b/app/bot/commands.py @@ -0,0 +1,19 @@ +from aiogram.types import BotCommand + +BOT_COMMANDS: list[BotCommand] = [ + BotCommand(command="start", description="Help and current session info"), + BotCommand(command="run", description="Execute a task"), + BotCommand(command="claude", description="Open Claude chat session"), + BotCommand(command="cmds", description="Show Claude slash commands"), + BotCommand(command="list", description="View active sessions"), + BotCommand(command="attach", description="Connect to a session"), + BotCommand(command="status", description="Query task status"), + BotCommand(command="cancel", description="Cancel a task"), + BotCommand(command="session", description="View/switch session"), + BotCommand(command="approve", description="Approve pending permission"), + BotCommand(command="deny", description="Deny pending permission"), + BotCommand(command="resume", description="Resume a past Claude session"), + BotCommand(command="exit", description="Exit Claude session and close terminal"), +] + +assert len(BOT_COMMANDS) == 13, f"Expected 13 commands, got {len(BOT_COMMANDS)}" diff --git a/app/bot/handlers/command_cmds.py b/app/bot/handlers/command_cmds.py new file mode 100644 index 0000000..349a6dd --- /dev/null +++ b/app/bot/handlers/command_cmds.py @@ -0,0 +1,138 @@ +"""Handler for /cmds — lists available Claude slash commands as inline buttons.""" + +from __future__ import annotations + +import logging + +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message + +from app.services.claude_command_discovery import ClaudeCommand, discover_commands +from app.services.session_service import SessionService +from app.services.task_service import TaskService + +logger = logging.getLogger(__name__) + +_SOURCE_ICONS = { + "builtin": "⚡", + "user": "👤", + "project": "📁", + "skill": "🧠", +} + +# Max callback_data is 64 bytes +_CB_PREFIX = "clcmd:" + + +def _build_callback_data(slash_text: str) -> str: + """Build callback data, truncating if needed to fit 64 bytes.""" + cb = f"{_CB_PREFIX}{slash_text}" + if len(cb.encode("utf-8")) > 64: + max_bytes = 64 - len(_CB_PREFIX.encode("utf-8")) + cb = _CB_PREFIX + slash_text.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore") + return cb + + +def _parse_callback_data(data: str) -> str | None: + """Parse callback data to extract the slash command text.""" + if not data.startswith(_CB_PREFIX): + return None + return data[len(_CB_PREFIX) :] + + +def register_cmds_handler( + router: Router, + *, + session_service: SessionService, + task_service: TaskService, +) -> None: + @router.message(Command("cmds")) + async def command_cmds(message: Message) -> None: + user_id = message.from_user.id if message.from_user else 0 + session = await session_service.get(user_id) + + if session is None or not session.claude_chat_active: + await message.answer("请先发送 /claude 开启会话后再使用 /cmds") + return + + workdir = session.workdir + commands = discover_commands(workdir=workdir) + + if not commands: + await message.answer("未发现可用的 Claude 命令。") + return + + # Group by source + groups: dict[str, list[ClaudeCommand]] = {} + for cmd in commands: + groups.setdefault(cmd.source, []).append(cmd) + + # Build keyboard: multiple buttons per row to reduce vertical space + buttons: list[list[InlineKeyboardButton]] = [] + + for source in ["builtin", "user", "skill", "project"]: + cmds = groups.get(source, []) + if not cmds: + continue + icon = _SOURCE_ICONS.get(source, "") + row: list[InlineKeyboardButton] = [] + for cmd in cmds: + # Short label: just icon + command name (no description for compactness) + short_name = cmd.name.lstrip("/") + label = f"{icon}{short_name}" + row.append( + InlineKeyboardButton( + text=label, + callback_data=_build_callback_data(cmd.slash_text), + ) + ) + # Max 3 buttons per row + if len(row) >= 3: + buttons.append(row) + row = [] + if row: + buttons.append(row) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + # Summary text + counts = ", ".join(f"{_SOURCE_ICONS.get(s, '')} {s}: {len(c)}" for s, c in groups.items() if c) + await message.answer(f"📋 Claude 命令 ({counts})\n点击发送到当前会话:", reply_markup=keyboard) + + @router.callback_query(F.data.startswith(_CB_PREFIX)) + async def handle_cmd_callback(callback: CallbackQuery) -> None: + user_id = callback.from_user.id if callback.from_user else 0 + slash_text = _parse_callback_data(callback.data or "") + if not slash_text: + await callback.answer("Invalid command", show_alert=True) + return + + session = await session_service.get(user_id) + if session is None or not session.claude_chat_active: + await callback.answer("请先发送 /claude 开启会话", show_alert=True) + return + + # Send the slash command as a prompt to the Claude session + from app.bot.handlers.command_run import run_prompt_and_stream + from app.bot.presenters.chunk_sender import ChunkSender + + await callback.answer(f"发送: {slash_text}") + + # Use the message from the callback to send the stream + if callback.message: + + def sender_factory() -> ChunkSender: + return ChunkSender(chunk_size=4000, flush_interval_sec=1.0) + + await run_prompt_and_stream( + message=callback.message, + task_service=task_service, + sender_factory=sender_factory, + user_id=user_id, + provider="claude_code", + prompt=slash_text, + workdir=session.workdir, + diff_generator=None, + result_exporter=None, + ) diff --git a/app/bot/handlers/command_list.py b/app/bot/handlers/command_list.py index 1ed8378..93b8a4e 100644 --- a/app/bot/handlers/command_list.py +++ b/app/bot/handlers/command_list.py @@ -4,8 +4,10 @@ from aiogram import Router from aiogram.filters import Command -from aiogram.types import Message +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message +from app.services.external_session_binder import ExternalSessionBinder +from app.services.external_session_discovery import ExternalSessionDiscoveryService from app.services.session_registry import SessionRegistryService logger = logging.getLogger(__name__) @@ -20,11 +22,33 @@ } -def register_list_handler(router: Router, *, registry_service: SessionRegistryService) -> None: +def _short_cwd(cwd: str) -> str: + """Return last 2 path segments as a short display name.""" + parts = cwd.rstrip("/").split("/") + return "/".join(parts[-2:]) if len(parts) >= 2 else cwd + + +def register_list_handler( + router: Router, + *, + registry_service: SessionRegistryService, + external_discovery: ExternalSessionDiscoveryService | None = None, + external_binder: ExternalSessionBinder | None = None, +) -> None: @router.message(Command("list")) async def command_list(message: Message) -> None: + user_id = message.from_user.id if message.from_user else 0 sessions = await registry_service.list_active_sessions() - if not sessions: + + # Gather external sessions if discovery service is available + external_sessions = [] + bound_sessions = [] + if external_discovery is not None: + external_sessions = external_discovery.list_unbound() + if external_binder is not None: + bound_sessions = external_binder._binding_store.get_bindings_for_user(user_id) + + if not sessions and not external_sessions and not bound_sessions: await message.answer("当前无活跃会话。") return @@ -37,4 +61,41 @@ async def command_list(message: Message) -> None: lines.append(f"\n{icon} `{s.terminal_id}`{owner_tag}{attached}{alive_tag}\n workdir: {s.workdir}\n phase: {s.phase}") lines.append("\n使用 /attach 连接到会话") - await message.answer("\n".join(lines)) + + # Build inline keyboard for external sessions (unbound + bound) + buttons: list[list[InlineKeyboardButton]] = [] + if external_sessions: + lines.append("\n📡 External sessions (unbound):") + for ext in external_sessions: + short = _short_cwd(ext.cwd) + sid_tag = ext.session_id[:8] + if ext.title: + btn_text = f"💬 {ext.title} ({sid_tag})" + else: + btn_text = f"📂 {short} ({sid_tag})" + if len(btn_text) > 64: + btn_text = btn_text[:63] + "…" + buttons.append( + [ + InlineKeyboardButton( + text=btn_text, + callback_data=f"sess:select:{ext.session_id[:16]}", + ) + ] + ) + if bound_sessions: + lines.append("\n🔗 Bound sessions:") + for b in bound_sessions: + short = _short_cwd(b.cwd) + sid_tag = b.session_id[:8] + buttons.append( + [ + InlineKeyboardButton( + text=f"🔗 {short} ({sid_tag})", + callback_data=f"sess:select:{b.session_id[:16]}", + ) + ] + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None + await message.answer("\n".join(lines), reply_markup=keyboard) diff --git a/app/bot/handlers/command_permission.py b/app/bot/handlers/command_permission.py index b22c2bb..9c96a61 100644 --- a/app/bot/handlers/command_permission.py +++ b/app/bot/handlers/command_permission.py @@ -1,19 +1,28 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING from aiogram import F from aiogram.filters import Command, CommandObject from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message +from app.services.permission_callback_registry import PermissionCallbackRegistry from app.services.task_service import TaskService +if TYPE_CHECKING: + from app.adapters.claude.hook_socket_server import HookSocketServer + from app.services.auto_approve_service import AutoApproveService + from app.services.session_store import SessionStore + logger = logging.getLogger(__name__) _PERMISSION_CALLBACK_PREFIX = "perm" +_STALE_PERMISSION_CALLBACK_TEXT = "权限按钮已失效:请求可能已过期或 bot 已重启。请重新触发操作,或等待 Claude 再次请求权限。" + -def build_permission_callback_data(*, decision: str, tool_use_id: str) -> str: - return f"{_PERMISSION_CALLBACK_PREFIX}:{decision}:{tool_use_id}" +def build_permission_callback_data(*, decision: str, token: str) -> str: + return f"{_PERMISSION_CALLBACK_PREFIX}:{decision}:{token}" def parse_permission_callback_data(data: str | None) -> tuple[str, str] | None: @@ -22,30 +31,45 @@ def parse_permission_callback_data(data: str | None) -> tuple[str, str] | None: prefix, sep, rest = data.partition(":") if prefix != _PERMISSION_CALLBACK_PREFIX or not sep: return None - decision, sep, tool_use_id = rest.partition(":") - if not sep or decision not in {"allow", "deny"} or not tool_use_id: + decision, sep, token = rest.partition(":") + if not sep or decision not in {"allow", "deny", "auto_approve"} or not token: return None - return decision, tool_use_id + return decision, token -def build_permission_keyboard(*, tool_use_id: str) -> InlineKeyboardMarkup: +def build_permission_keyboard(*, tool_use_id: str, permission_callback_registry: PermissionCallbackRegistry) -> InlineKeyboardMarkup: + token = permission_callback_registry.register(tool_use_id) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text="允许", - callback_data=build_permission_callback_data(decision="allow", tool_use_id=tool_use_id), + callback_data=build_permission_callback_data(decision="allow", token=token), ), InlineKeyboardButton( text="拒绝", - callback_data=build_permission_callback_data(decision="deny", tool_use_id=tool_use_id), + callback_data=build_permission_callback_data(decision="deny", token=token), + ), + ], + [ + InlineKeyboardButton( + text="不再询问,全部允许", + callback_data=build_permission_callback_data(decision="auto_approve", token=token), ), - ] + ], ] ) -def register_permission_handlers(router, *, task_service: TaskService): +def register_permission_handlers( + router, + *, + task_service: TaskService, + auto_approve_service: AutoApproveService | None = None, + hook_socket_server: HookSocketServer | None = None, + structured_session_store: SessionStore | None = None, + permission_callback_registry: PermissionCallbackRegistry | None = None, +): @router.message(Command("approve")) async def command_approve(message: Message) -> None: user_id = message.from_user.id if message.from_user else 0 @@ -58,6 +82,15 @@ async def command_approve(message: Message) -> None: @router.message(Command("deny")) async def command_deny(message: Message, command: CommandObject) -> None: user_id = message.from_user.id if message.from_user else 0 + + # Check if auto-approve is active for user's current session + if auto_approve_service is not None: + state = await task_service.get_structured_session(user_id, log_missing=False) + if state is not None and auto_approve_service.get_active_session_for_user(user_id, state.session_id): + auto_approve_service.deactivate(state.session_id) + await message.answer("已关闭自动批准,后续权限请求将正常提示") + return + reason = (command.args or "").strip() or None ok, text = await task_service.respond_to_pending_permission(user_id=user_id, decision="deny", reason=reason) if ok: @@ -72,7 +105,29 @@ async def callback_permission(callback: CallbackQuery) -> None: if parsed is None: await callback.answer("无效的权限操作", show_alert=True) return - decision, tool_use_id = parsed + decision, token = parsed + if permission_callback_registry is None: + logger.error("permission_callback_registry is not configured") + await callback.answer("权限服务配置错误", show_alert=True) + return + tool_use_id = permission_callback_registry.resolve(token) + if tool_use_id is None: + if callback.message is not None: + await callback.message.answer(_STALE_PERMISSION_CALLBACK_TEXT) + await callback.answer("按钮已失效", show_alert=True) + return + + # Resolve owning user from session so token-based approval works cross-user + session_id = await _resolve_session_id_for_tool_use_id(tool_use_id, user_id=user_id) + if session_id is not None and structured_session_store is not None: + state = structured_session_store.get(session_id) + if state is not None and state.user_id is not None: + user_id = state.user_id + + if decision == "auto_approve": + await _handle_auto_approve_callback(callback, user_id=user_id, tool_use_id=tool_use_id) + return + ok, text = await task_service.respond_to_pending_permission( user_id=user_id, decision=decision, @@ -88,3 +143,68 @@ async def callback_permission(callback: CallbackQuery) -> None: else: await callback.message.answer(f"权限操作失败: {text}") await callback.answer(text, show_alert=not ok) + + async def _handle_auto_approve_callback(callback: CallbackQuery, *, user_id: int, tool_use_id: str) -> None: + """Handle auto-approve button: approve current request + activate auto-approve for session.""" + # Resolve session_id before approving (approval clears pending state) + session_id = await _resolve_session_id_for_tool_use_id(tool_use_id, user_id=user_id) + + # Approve the current permission (same as "allow") + ok, text = await task_service.respond_to_pending_permission( + user_id=user_id, + decision="allow", + expected_tool_use_id=tool_use_id, + ) + + if not ok: + if callback.message is not None: + await callback.message.answer(f"权限操作失败: {text}") + await callback.answer(text, show_alert=True) + return + + # Clear the inline keyboard + if callback.message is not None: + try: + await callback.message.edit_reply_markup(reply_markup=None) + except Exception: + logger.exception("failed to clear permission inline keyboard", extra={"user_id": user_id, "tool_use_id": tool_use_id}) + + # Activate auto-approve if we resolved the session_id + if session_id and auto_approve_service is not None: + auto_approve_service.activate(session_id, user_id=user_id) + confirmation = "🟢 已开启自动批准,本次会话后续权限请求将自动通过\n发送 /deny 可关闭" + if callback.message is not None: + await callback.message.answer(confirmation) + await callback.answer(confirmation) + else: + # Fallback: approved but couldn't activate auto-approve + logger.warning( + "auto-approve: could not resolve session_id, permission approved but auto-approve not activated", + extra={"user_id": user_id, "tool_use_id": tool_use_id}, + ) + if callback.message is not None: + await callback.message.answer(text) + await callback.answer(text) + + async def _resolve_session_id_for_tool_use_id(tool_use_id: str, *, user_id: int) -> str | None: + """Resolve the session_id associated with a pending permission's tool_use_id.""" + # Try structured session store first (matches by pending permission tool_use_id) + if structured_session_store is not None: + state = structured_session_store.find_by_pending_tool_use_id(tool_use_id) + if state is not None: + return state.session_id + + # Fallback: check hook_socket_server's pending permissions directly + if hook_socket_server is not None: + async with hook_socket_server._lock: + pending = hook_socket_server._pending_permissions.get(tool_use_id) + if pending is not None: + return pending.session_id + + # Last resort: get session from the user's current structured session + if structured_session_store is not None: + state = await task_service.get_structured_session(user_id, log_missing=False) + if state is not None and state.pending_permission is not None: + return state.session_id + + return None diff --git a/app/bot/handlers/command_resume.py b/app/bot/handlers/command_resume.py new file mode 100644 index 0000000..c76e84b --- /dev/null +++ b/app/bot/handlers/command_resume.py @@ -0,0 +1,95 @@ +"""Handler for /resume command — list and resume past Claude sessions.""" + +from __future__ import annotations + +import logging + +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message + +from app.adapters.claude.paths import ClaudePaths +from app.services.session_scanner import SessionInfo, SessionScanner +from app.services.session_service import SessionService +from app.services.task_service import TaskService + +logger = logging.getLogger(__name__) + +CALLBACK_PREFIX = "resume:" + + +def _format_button_label(info: SessionInfo) -> str: + """Format button label as 'MM-DD HH:MM | ...'.""" + date_part = info.modified_at.strftime("%m-%d %H:%M") + summary = info.summary.strip() if info.summary else "" + if not summary: + summary_part = "(no prompt)" + elif len(summary) > 30: + summary_part = summary[:30] + "..." + else: + summary_part = summary + return f"{date_part} | {summary_part}" + + +def register_resume_handler( + router: Router, + *, + session_scanner: SessionScanner, + task_service: TaskService, + session_service: SessionService, + claude_paths: ClaudePaths, +) -> None: + @router.message(Command("resume")) + async def command_resume(message: Message) -> None: + user_id = message.from_user.id if message.from_user else 0 + + session = await session_service.get(user_id) + if session is None or not session.workdir: + await message.answer("请先使用 /claude 开启会话") + return + + sessions = session_scanner.scan(session.workdir, claude_paths) + if not sessions: + await message.answer("当前工作目录无可恢复的会话") + return + + buttons = [[InlineKeyboardButton(text=_format_button_label(s), callback_data=f"{CALLBACK_PREFIX}{s.session_id}")] for s in sessions] + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + await message.answer("选择要恢复的会话:", reply_markup=keyboard) + + @router.callback_query(lambda cb: cb.data and cb.data.startswith(CALLBACK_PREFIX)) + async def callback_resume(callback: CallbackQuery) -> None: + user_id = callback.from_user.id if callback.from_user else 0 + data = callback.data or "" + session_id = data[len(CALLBACK_PREFIX) :] + + if not session_id: + await callback.answer("无效的会话 ID", show_alert=True) + return + + session = await session_service.get(user_id) + if session is None or not session.workdir: + await callback.answer("请先使用 /claude 开启会话", show_alert=True) + return + + # Verify session file still exists + encoded_workdir = SessionScanner.encode_workdir(session.workdir) + session_file = claude_paths.projects_dir / encoded_workdir / f"{session_id}.jsonl" + if not session_file.is_file(): + await callback.answer("该会话文件已不存在", show_alert=True) + return + + try: + # Close existing terminal and start resumed session + opened, text = await task_service.open_claude_resume_session(user_id, session_id=session_id, workdir=session.workdir) + except Exception as exc: + logger.exception("resume session failed", extra={"user_id": user_id, "session_id": session_id}) + await callback.answer(f"恢复失败: {exc}", show_alert=True) + return + + if opened: + await callback.answer("会话已恢复") + if callback.message: + await callback.message.answer(f"已恢复会话: {session_id[:8]}...\n{text}") + else: + await callback.answer(f"恢复失败: {text}", show_alert=True) diff --git a/app/bot/handlers/command_run.py b/app/bot/handlers/command_run.py index ea92521..15201c0 100644 --- a/app/bot/handlers/command_run.py +++ b/app/bot/handlers/command_run.py @@ -2,6 +2,8 @@ import asyncio import logging +from collections.abc import Callable +from contextlib import suppress from aiogram.filters import Command, CommandObject from aiogram.types import Message @@ -15,7 +17,9 @@ _MARKER_LINE_RE as _PRESENTER_MARKER_LINE_RE, ) from app.bot.presenters.tool_message_manager import ToolMessageManager +from app.domain.models import EventType from app.services.diff_generator import DiffGeneratorService +from app.services.permission_callback_registry import PermissionCallbackRegistry from app.services.result_exporter import ResultExporterService from app.services.task_service import TaskService @@ -23,6 +27,18 @@ _MARKER_LINE_RE = _PRESENTER_MARKER_LINE_RE _ACTIVE_STREAM_TASKS: set[asyncio.Task] = set() +_ABANDONED_STREAM_TASKS: set[asyncio.Task] = set() +_STREAM_WATCHDOG_BUFFER_SEC = 30.0 +_STREAM_WATCHDOG_MIN_SEC = 1.0 +_STREAM_WATCHDOG_CHECK_INTERVAL_SEC = 0.5 +_STREAM_WATCHDOG_FINALIZE_GRACE_SEC = 30.0 +_STREAM_WATCHDOG_CANCEL_GRACE_SEC = 5.0 + + +def _stream_watchdog_timeout(timeout_sec: int | float | None) -> float: + if timeout_sec is None: + return _STREAM_WATCHDOG_BUFFER_SEC + return max(float(timeout_sec) + _STREAM_WATCHDOG_BUFFER_SEC, _STREAM_WATCHDOG_MIN_SEC) def parse_run_args(text: str | None) -> tuple[str | None, str]: @@ -51,6 +67,8 @@ async def run_prompt_and_stream( workdir: str | None = None, diff_generator: DiffGeneratorService | None = None, result_exporter: ResultExporterService | None = None, + queued_upload_scheduler: Callable[[Message, int, str], None] | None = None, + permission_callback_registry: PermissionCallbackRegistry | None = None, ) -> asyncio.Task | None: logger.info( "run prompt requested", @@ -127,7 +145,39 @@ async def run_prompt_and_stream( messenger=messenger, tool_message_manager=tool_message_manager, task_id=start.task.task_id, + permission_callback_registry=permission_callback_registry, ) + loop = asyncio.get_running_loop() + last_stream_progress_at = loop.time() + stream_terminal_seen = False + stream_terminal_seen_at: float | None = None + stream_abandoned = False + queued_upload_scheduled = False + original_events = start.events + + def _schedule_queued_uploads_once() -> None: + nonlocal queued_upload_scheduled + if queued_upload_scheduler is None or queued_upload_scheduled: + return + queued_upload_scheduled = True + try: + queued_upload_scheduler(message, user_id, start.task.task_id) + except Exception: + logger.exception("failed to schedule queued upload processing", extra={"user_id": user_id}) + + async def _events_with_progress(): + nonlocal last_stream_progress_at, stream_terminal_seen, stream_terminal_seen_at + async for event in original_events: + if stream_abandoned: + return + now = asyncio.get_running_loop().time() + last_stream_progress_at = now + if event.type in {EventType.EXITED, EventType.FAILED, EventType.TIMEOUT, EventType.CANCELED}: + stream_terminal_seen = True + stream_terminal_seen_at = now + yield event + + start.events = _events_with_progress() streamer = RunEventStreamer( start=start, task_service=task_service, @@ -138,10 +188,134 @@ async def run_prompt_and_stream( lifecycle_message=lifecycle_message, diff_generator=diff_generator, result_exporter=result_exporter, + queued_upload_scheduler=_schedule_queued_uploads_once if queued_upload_scheduler is not None else None, ) await presenter.prime(baseline_current_snapshot=True) - task = asyncio.create_task(streamer.stream_events()) + async def _run_stream_with_watchdog() -> None: + nonlocal last_stream_progress_at, stream_abandoned + + timeout = _stream_watchdog_timeout(getattr(start.task, "timeout_sec", None)) + last_structured_cursor: int | None = None + stream_task = asyncio.create_task(streamer.stream_events()) + + def _consume_stream_task_result(done_task: asyncio.Task) -> None: + if done_task.cancelled(): + return + with suppress(Exception): + done_task.exception() + + def _forget_abandoned_stream_task(done_task: asyncio.Task) -> None: + _ABANDONED_STREAM_TASKS.discard(done_task) + _consume_stream_task_result(done_task) + + async def _cancel_stream_task() -> None: + if stream_task.done(): + _consume_stream_task_result(stream_task) + return + if stream_task in _ABANDONED_STREAM_TASKS: + return + stream_task.cancel() + done, _ = await asyncio.wait({stream_task}, timeout=_STREAM_WATCHDOG_CANCEL_GRACE_SEC) + if stream_task in done: + with suppress(asyncio.CancelledError): + await stream_task + return + _ABANDONED_STREAM_TASKS.add(stream_task) + stream_task.add_done_callback(_forget_abandoned_stream_task) + logger.error( + "task stream cancellation grace timeout", + extra={ + "task_id": start.task.task_id, + "user_id": user_id, + "timeout_sec": _STREAM_WATCHDOG_CANCEL_GRACE_SEC, + }, + ) + + async def _mark_stream_timeout(reason: str) -> bool: + mark_and_cancel = getattr(task_service, "mark_stream_timeout_and_cancel", None) + if mark_and_cancel is not None: + marked, _ = await mark_and_cancel( + start.task.task_id, + user_id, + reason=reason, + cancel_timeout_sec=_STREAM_WATCHDOG_CANCEL_GRACE_SEC, + ) + return bool(marked) + + marked = True + mark_stream_timeout = getattr(task_service, "mark_stream_timeout", None) + if mark_stream_timeout is not None: + marked = bool(await mark_stream_timeout(start.task.task_id, user_id, reason=reason)) + if marked: + cancel = getattr(task_service, "cancel", None) + if cancel is not None: + await cancel(start.task.task_id, user_id) + return marked + + try: + while True: + done, _ = await asyncio.wait( + {stream_task}, + timeout=min(_STREAM_WATCHDOG_CHECK_INTERVAL_SEC, timeout), + ) + if stream_task in done: + await stream_task + return + + now = asyncio.get_running_loop().time() + if start.interactive: + cursor = await task_service.get_structured_session_cursor(user_id, task_id=start.task.task_id) + if last_structured_cursor is None: + last_structured_cursor = cursor + elif cursor != last_structured_cursor: + last_structured_cursor = cursor + last_stream_progress_at = now + + if stream_terminal_seen: + terminal_seen_at = stream_terminal_seen_at or now + if now - terminal_seen_at < _STREAM_WATCHDOG_FINALIZE_GRACE_SEC: + continue + stream_abandoned = True + await _cancel_stream_task() + force_cleanup = getattr(streamer, "force_cleanup", None) + if force_cleanup is not None: + await force_cleanup(schedule_uploads=True, cancel_timeout_sec=_STREAM_WATCHDOG_CANCEL_GRACE_SEC) + else: + _schedule_queued_uploads_once() + logger.error( + "task stream finalization watchdog timeout", + extra={ + "task_id": start.task.task_id, + "user_id": user_id, + "timeout_sec": _STREAM_WATCHDOG_FINALIZE_GRACE_SEC, + }, + ) + await messenger.answer_safely("任务收尾处理超时,已停止后台监听。") + return + + idle_sec = now - last_stream_progress_at + if idle_sec < timeout: + continue + reason = "任务流处理超时" + marked = await _mark_stream_timeout(reason) + if not marked: + last_stream_progress_at = now + continue + stream_abandoned = True + await _cancel_stream_task() + _schedule_queued_uploads_once() + logger.error( + "task stream watchdog timeout", + extra={"task_id": start.task.task_id, "user_id": user_id, "timeout_sec": timeout}, + ) + await messenger.answer_safely("任务流处理超时,已停止后台监听。") + return + finally: + if not stream_task.done(): + await _cancel_stream_task() + + task = asyncio.create_task(_run_stream_with_watchdog()) _ACTIVE_STREAM_TASKS.add(task) logger.info( "task stream spawned", @@ -187,6 +361,8 @@ def register_run_handler( sender_factory, diff_generator: DiffGeneratorService | None = None, result_exporter: ResultExporterService | None = None, + queued_upload_scheduler: Callable[[Message, int, str], None] | None = None, + permission_callback_registry: PermissionCallbackRegistry | None = None, ): @router.message(Command("run")) async def command_run(message: Message, command: CommandObject) -> None: @@ -206,4 +382,6 @@ async def command_run(message: Message, command: CommandObject) -> None: prompt=prompt, diff_generator=diff_generator, result_exporter=result_exporter, + queued_upload_scheduler=queued_upload_scheduler, + permission_callback_registry=permission_callback_registry, ) diff --git a/app/bot/handlers/external_permission.py b/app/bot/handlers/external_permission.py new file mode 100644 index 0000000..00f20be --- /dev/null +++ b/app/bot/handlers/external_permission.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from aiogram import F, Router +from aiogram.types import CallbackQuery + +if TYPE_CHECKING: + from app.adapters.claude.hook_socket_server import HookSocketServer + from app.services.auto_approve_service import AutoApproveService + from app.services.external_user_question_state import ExternalUserQuestionState + from app.services.permission_callback_registry import PermissionCallbackRegistry + from app.services.unbound_permission_handler import UnboundPermissionHandler + +logger = logging.getLogger(__name__) + +_STALE_EXTERNAL_PERMISSION_CALLBACK_TEXT = ( + "Permission button expired or bot restarted. Trigger the action again or wait for Claude to request permission again." +) + + +def register_external_permission_handler( + router: Router, + *, + hook_socket_server: HookSocketServer, + unbound_permission_handler: UnboundPermissionHandler, + permission_callback_registry: PermissionCallbackRegistry, + external_uq_state: ExternalUserQuestionState | None = None, + auto_approve_service: AutoApproveService | None = None, +) -> None: + @router.callback_query(F.data.startswith("ext_perm:")) + async def handle_external_permission_callback(callback: CallbackQuery) -> None: + data = callback.data or "" + parts = data.split(":") + if len(parts) != 3: + await callback.answer("Invalid callback data", show_alert=True) + return + + _, token, decision = parts + if decision not in ("allow", "deny", "auto_approve"): + await callback.answer("Invalid decision", show_alert=True) + return + + # Resolve short token to full tool_use_id + tool_use_id = permission_callback_registry.resolve(token) + if tool_use_id is None: + await callback.answer(_STALE_EXTERNAL_PERMISSION_CALLBACK_TEXT, show_alert=True) + return + + user_id = callback.from_user.id if callback.from_user else 0 + + # For auto_approve: approve the current request + activate auto-approve for the session + if decision == "auto_approve": + # Resolve session_id from pending state + session_id: str | None = None + if unbound_permission_handler.is_unbound_permission(tool_use_id): + session_id = unbound_permission_handler.get_session_id(tool_use_id) + result = await unbound_permission_handler.handle_response( + tool_use_id=tool_use_id, + user_id=user_id, + decision="allow", + ) + if not result.accepted: + await callback.answer("Already responded by another user", show_alert=True) + return + else: + session_id = await hook_socket_server.get_session_id_for_tool_use_id(tool_use_id) + success = await hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision="allow", + reason=f"auto-approve activated by user {user_id}", + ) + if not success: + await callback.answer("Permission request expired or not found", show_alert=True) + return + + # Activate auto-approve for the session + if session_id and auto_approve_service is not None: + auto_approve_service.activate(session_id, user_id=user_id) + + await callback.answer("🟢 Auto-approve activated") + + # Edit original message to reflect decision + if callback.message: + original_text = callback.message.text or "" + await callback.message.edit_text(f"{original_text}\n\n🟢 已开启自动批准,本次会话后续权限请求将自动通过\n发送 /deny 可关闭") + + logger.info( + "external permission auto-approve activated", + extra={ + "tool_use_id": tool_use_id, + "session_id": session_id, + "user_id": user_id, + }, + ) + return + + # Try unbound first (first-responder-wins), then bound + if unbound_permission_handler.is_unbound_permission(tool_use_id): + result = await unbound_permission_handler.handle_response( + tool_use_id=tool_use_id, + user_id=user_id, + decision=decision, + ) + if not result.accepted: + await callback.answer("Already responded by another user", show_alert=True) + return + else: + # Bound session — respond directly via hook socket + success = await hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision=decision, + reason=f"responded by user {user_id}", + ) + if not success: + await callback.answer("Permission request expired or not found", show_alert=True) + return + + # Confirm to user + emoji = "✅" if decision == "allow" else "❌" + label = "Approved" if decision == "allow" else "Denied" + await callback.answer(f"{emoji} {label}") + + # Edit original message to reflect decision + if callback.message: + original_text = callback.message.text or "" + await callback.message.edit_text(f"{original_text}\n\n{emoji} {label} by you") + + logger.info( + "external permission callback handled", + extra={ + "tool_use_id": tool_use_id, + "decision": decision, + "user_id": user_id, + }, + ) + + @router.callback_query(F.data.startswith("ext_uq:")) + async def handle_external_user_question_callback(callback: CallbackQuery) -> None: + """Handle user clicking an AskUserQuestion option button for external sessions.""" + from app.adapters.process.pty_injector import inject_option_selection + + data = callback.data or "" + parts = data.split(":") + if len(parts) != 3: + await callback.answer("Invalid callback data", show_alert=True) + return + + _, tool_use_id, option_index_str = parts + try: + option_index = int(option_index_str) + except ValueError: + await callback.answer("Invalid option index", show_alert=True) + return + + if external_uq_state is None: + await callback.answer("Feature not available", show_alert=True) + return + + pending = external_uq_state.get(tool_use_id) + if pending is None: + await callback.answer("Question expired or already answered", show_alert=True) + return + + if pending.pane_id is None: + await callback.answer("Cannot inject: no tmux pane found", show_alert=True) + return + + # Validate option index + prompt = pending.prompts[0] if pending.prompts else None + if prompt is None or option_index < 0 or option_index >= len(prompt.options): + await callback.answer("Invalid option", show_alert=True) + return + + user_id = callback.from_user.id if callback.from_user else 0 + selected_label = prompt.options[option_index].label + + # Determine if this is the final question (submit after selection) + is_final = len(pending.prompts) == 1 + + # Inject the selection into the terminal + ok, err = await inject_option_selection( + pending.pane_id, + option_index=option_index, + submit_after=is_final, + ) + if not ok: + logger.warning( + "pty injection failed for external user question", + extra={"tool_use_id": tool_use_id, "pane_id": pending.pane_id, "error": err}, + ) + await callback.answer(f"Injection failed: {err}", show_alert=True) + return + + # After successful injection on final question, allow the permission + if is_final: + await hook_socket_server.respond_to_permission( + tool_use_id=tool_use_id, + decision="allow", + reason=f"AskUserQuestion answered via Telegram by user {user_id}", + ) + + # Clean up state + external_uq_state.remove(tool_use_id) + + # Confirm to user + await callback.answer(f"✅ Selected: {selected_label}") + + # Edit original message to reflect selection + if callback.message: + original_text = callback.message.text or "" + await callback.message.edit_text(f"{original_text}\n\n✅ Selected: {selected_label} (by you)") + + logger.info( + "external user question answered via Telegram", + extra={ + "tool_use_id": tool_use_id, + "option_index": option_index, + "selected_label": selected_label, + "user_id": user_id, + "pane_id": pending.pane_id, + }, + ) diff --git a/app/bot/handlers/external_session.py b/app/bot/handlers/external_session.py new file mode 100644 index 0000000..b21b4dd --- /dev/null +++ b/app/bot/handlers/external_session.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import logging + +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from app.domain.models import utc_now +from app.services.external_session_binder import ExternalSessionBinder +from app.services.external_session_discovery import ExternalSessionDiscoveryService +from app.services.session_store import SessionStore + +logger = logging.getLogger(__name__) + + +def _resolve_session_id( + session_id_prefix: str, + discovery: ExternalSessionDiscoveryService, + binder: ExternalSessionBinder, +) -> tuple[str | None, str | None]: + """Resolve a partial session_id prefix to a full session_id. + + Searches both unbound discovery list and bound sessions. + Returns (full_session_id, error_message). If ambiguous, returns error. + """ + prefix = session_id_prefix.rstrip(".") + candidates: list[str] = [] + + for s in discovery.list_unbound(): + if s.session_id == prefix or s.session_id.startswith(prefix): + candidates.append(s.session_id) + + for b in binder._binding_store.load_all().values(): + if b.session_id == prefix or b.session_id.startswith(prefix): + if b.session_id not in candidates: + candidates.append(b.session_id) + + if len(candidates) == 1: + return candidates[0], None + if len(candidates) == 0: + return None, "Session not found" + return None, f"Ambiguous prefix, {len(candidates)} matches. Be more specific." + + +def _time_ago(dt) -> str: # noqa: ANN001 + """Format a datetime as a human-readable 'X ago' string.""" + delta = utc_now() - dt + total_sec = int(delta.total_seconds()) + if total_sec < 60: + return f"{total_sec}s ago" + minutes = total_sec // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + return f"{days}d ago" + + +def register_external_session_handler( + router: Router, + *, + discovery: ExternalSessionDiscoveryService, + binder: ExternalSessionBinder, + session_store: SessionStore, +) -> None: + @router.message(Command("external")) + async def command_external(message: Message) -> None: + user_id = message.from_user.id if message.from_user else 0 + text = (message.text or "").strip() + # Parse: /external [args] + parts = text.split(maxsplit=2) + # parts[0] = "/external" + if len(parts) < 2: + await message.answer( + "用法:\n" + "/external list\n" + "/external bind \n" + "/external unbind \n" + "/external status " + ) + return + + subcommand = parts[1].lower() + arg = parts[2].strip() if len(parts) > 2 else "" + + if subcommand == "list": + await _handle_list(message, user_id=user_id, discovery=discovery, binder=binder) + elif subcommand == "bind": + await _handle_bind(message, user_id=user_id, session_id=arg, binder=binder, discovery=discovery) + elif subcommand == "unbind": + await _handle_unbind(message, user_id=user_id, session_id=arg, binder=binder, discovery=discovery) + elif subcommand == "status": + await _handle_status(message, user_id=user_id, session_id=arg, binder=binder, discovery=discovery, session_store=session_store) + else: + await message.answer(f"未知子命令: {subcommand}") + + +async def _handle_list( + message: Message, + *, + user_id: int, + discovery: ExternalSessionDiscoveryService, + binder: ExternalSessionBinder, +) -> None: + unbound = discovery.list_unbound() + bound = binder.list_bound_for_user(user_id) + + if not unbound and not bound: + await message.answer("📋 No external sessions found.") + return + + lines = ["📋 External Sessions:"] + + if unbound: + lines.append("\n🆓 Unbound:") + for s in unbound: + short_id = s.session_id[:12] + ago = _time_ago(s.first_seen) + lines.append(f" • {short_id}... | {s.cwd} | first seen {ago}") + + if bound: + lines.append("\n🔗 Your bound sessions:") + for b in bound: + short_id = b.session_id[:12] + ago = _time_ago(b.bound_at) + lines.append(f" • {short_id}... | {b.cwd} | bound {ago}") + + await message.answer("\n".join(lines)) + + +async def _handle_bind( + message: Message, + *, + user_id: int, + session_id: str, + binder: ExternalSessionBinder, + discovery: ExternalSessionDiscoveryService, +) -> None: + if not session_id: + await message.answer("用法: /external bind ") + return + + resolved, error = _resolve_session_id(session_id, discovery, binder) + if error: + await message.answer(f"❌ {error}") + return + + result = await binder.bind(user_id=user_id, session_id=resolved) + if result.success: + conv_status = "✅ conversation available" if result.conversation_available else "⏳ waiting for JSONL" + await message.answer(f"🔗 Bound session {resolved[:12]}...\n{conv_status}") + else: + await message.answer(f"❌ {result.message}") + + +async def _handle_unbind( + message: Message, + *, + user_id: int, + session_id: str, + binder: ExternalSessionBinder, + discovery: ExternalSessionDiscoveryService, +) -> None: + if not session_id: + await message.answer("用法: /external unbind ") + return + + resolved, error = _resolve_session_id(session_id, discovery, binder) + if error: + await message.answer(f"❌ {error}") + return + + result = await binder.unbind(user_id=user_id, session_id=resolved) + if result.success: + await message.answer(f"🔓 Unbound session {resolved[:12]}...") + else: + await message.answer(f"❌ {result.message}") + + +async def _handle_status( + message: Message, + *, + user_id: int, + session_id: str, + binder: ExternalSessionBinder, + discovery: ExternalSessionDiscoveryService, + session_store: SessionStore, +) -> None: + if not session_id: + await message.answer("用法: /external status ") + return + + resolved, error = _resolve_session_id(session_id, discovery, binder) + if error: + await message.answer(f"❌ {error}") + return + + # Verify user owns this binding + binding = binder.list_bound_for_user(user_id) + owned = any(b.session_id == resolved for b in binding) + if not owned: + await message.answer("❌ Session not bound to you") + return + + state = session_store.get(resolved) + if state is None: + await message.answer(f"📊 Session {resolved[:12]}...\n phase: unknown\n (no state available)") + return + + lines = [f"📊 Session {resolved[:12]}..."] + lines.append(f" phase: {state.phase.value}") + if state.last_tool_name: + lines.append(f" last tool: {state.last_tool_name}") + lines.append(f" cwd: {state.workdir}") + + await message.answer("\n".join(lines)) diff --git a/app/bot/handlers/file_upload.py b/app/bot/handlers/file_upload.py index d51655d..c009edd 100644 --- a/app/bot/handlers/file_upload.py +++ b/app/bot/handlers/file_upload.py @@ -1,7 +1,7 @@ from __future__ import annotations +import asyncio import logging -from collections import defaultdict from aiogram import F, Router from aiogram.types import Message @@ -11,12 +11,10 @@ from app.services.file_receiver import FileReceiverService from app.services.session_service import SessionService from app.services.task_service import TaskService +from app.services.upload_queue import UploadQueueManager logger = logging.getLogger(__name__) - -# In-memory queue for uploads received while a task is running. -# Maps user_id -> list of (filename, data) tuples waiting to be processed. -_pending_uploads: dict[int, list[tuple[str, bytes]]] = defaultdict(list) +_ACTIVE_UPLOAD_TASKS: set[asyncio.Task[None]] = set() def _format_size(size_bytes: int) -> str: @@ -29,10 +27,31 @@ def _format_size(size_bytes: int) -> str: return f"{size_bytes / (1024 * 1024):.1f} MB" -async def _user_has_running_task(task_service: TaskService, user_id: int) -> bool: +def _max_upload_size_bytes(upload_max_file_size_mb: int) -> int: + return upload_max_file_size_mb * 1024 * 1024 + + +def _format_ttl(ttl_sec: int) -> str: + if ttl_sec % 60 == 0: + return f"{ttl_sec // 60} 分钟" + return f"{ttl_sec} 秒" + + +def _metadata_exceeds_limit(file_size: int | None, *, max_size_bytes: int) -> bool: + return file_size is not None and file_size > max_size_bytes + + +async def _answer_oversized(message: Message, *, filename: str, size_bytes: int, upload_max_file_size_mb: int) -> None: + await message.answer(f"❌ 文件被拒绝: {filename}\n原因: 文件大小 {_format_size(size_bytes)} 超过 {upload_max_file_size_mb} MB 限制。") + + +async def _user_has_running_task(task_service: TaskService, user_id: int, *, exclude_task_id: str | None = None) -> bool: """Check if the user has a task currently in RUNNING or PENDING state.""" recent = await task_service.list_recent(user_id, limit=5) - return any(t.status in (TaskStatus.RUNNING, TaskStatus.PENDING) for t in recent) + return any( + t.status in (TaskStatus.RUNNING, TaskStatus.PENDING) and (exclude_task_id is None or getattr(t, "task_id", None) != exclude_task_id) + for t in recent + ) async def _process_upload( @@ -71,19 +90,73 @@ async def process_pending_uploads( *, file_receiver: FileReceiverService, session_service: SessionService, + upload_queue: UploadQueueManager, user_id: int, + task_service: TaskService | None = None, + completed_task_id: str | None = None, ) -> None: """Process any queued uploads for a user after their task completes.""" - pending = _pending_uploads.pop(user_id, []) - for filename, data in pending: - await _process_upload( + if task_service is not None and await _user_has_running_task(task_service, user_id, exclude_task_id=completed_task_id): + logger.info( + "queued upload processing deferred because another task is active", + extra={"user_id": user_id, "completed_task_id": completed_task_id}, + ) + return + + pending = await upload_queue.drain(user_id=user_id) + for item in pending: + try: + await _process_upload( + message, + file_receiver=file_receiver, + session_service=session_service, + filename=item.filename, + data=item.data, + ) + except Exception: + logger.exception("queued upload processing failed", extra={"user_id": user_id, "filename": item.filename}) + + +def schedule_pending_upload_processing( + message: Message, + *, + file_receiver: FileReceiverService, + session_service: SessionService, + upload_queue: UploadQueueManager, + user_id: int, + task_service: TaskService | None = None, + completed_task_id: str | None = None, +) -> asyncio.Task[None]: + """Schedule queued uploads to be processed in the background.""" + task: asyncio.Task[None] = asyncio.create_task( + process_pending_uploads( message, file_receiver=file_receiver, session_service=session_service, - filename=filename, - data=data, + upload_queue=upload_queue, + user_id=user_id, + task_service=task_service, + completed_task_id=completed_task_id, + ) + ) + _ACTIVE_UPLOAD_TASKS.add(task) + + def _on_done(done_task: asyncio.Task[None]) -> None: + _ACTIVE_UPLOAD_TASKS.discard(done_task) + if done_task.cancelled(): + return + exc = done_task.exception() + if exc is None: + return + logger.error( + "queued upload background task failed", + extra={"user_id": user_id, "error": str(exc)}, + exc_info=(type(exc), exc, exc.__traceback__), ) + task.add_done_callback(_on_done) + return task + def register_file_upload_handler( router: Router, @@ -91,6 +164,9 @@ def register_file_upload_handler( file_receiver: FileReceiverService, session_service: SessionService, task_service: TaskService, + upload_queue: UploadQueueManager, + upload_max_file_size_mb: int, + upload_queue_ttl_sec: int = 3600, ) -> None: @router.message(F.document) async def handle_document(message: Message) -> None: @@ -100,6 +176,16 @@ async def handle_document(message: Message) -> None: return filename = document.file_name or "unnamed_file" + max_size_bytes = _max_upload_size_bytes(upload_max_file_size_mb) + file_size = document.file_size + if _metadata_exceeds_limit(file_size, max_size_bytes=max_size_bytes): + await _answer_oversized( + message, + filename=filename, + size_bytes=file_size, + upload_max_file_size_mb=upload_max_file_size_mb, + ) + return # Download file via Telegram Bot API try: @@ -121,10 +207,26 @@ async def handle_document(message: Message) -> None: await message.answer(f"❌ 文件下载失败: {exc}") return + if len(data) > max_size_bytes: + await _answer_oversized( + message, + filename=filename, + size_bytes=len(data), + upload_max_file_size_mb=upload_max_file_size_mb, + ) + return + # Queue if task is running if await _user_has_running_task(task_service, user_id): - _pending_uploads[user_id].append((filename, data)) - await message.answer(f"⏳ 任务运行中,文件 {filename} 已加入队列,将在任务完成后处理。") + queued = await upload_queue.enqueue(user_id=user_id, filename=filename, data=data) + if not queued.accepted: + await message.answer(f"❌ 文件未加入队列: {filename}\n原因: {queued.reason}") + return + await message.answer( + f"⏳ 任务运行中,文件 {filename} 已加入队列,将在任务完成后处理。\n" + f"注意:队列仅保存在内存中,如果 bot 在任务完成前重启,已排队文件会丢失;" + f"排队文件超过 {_format_ttl(upload_queue_ttl_sec)} 未处理会过期。" + ) return await _process_upload( @@ -144,6 +246,16 @@ async def handle_photo(message: Message) -> None: # Use the largest resolution photo (last in array) photo = message.photo[-1] filename = f"photo_{photo.file_unique_id}.jpg" + max_size_bytes = _max_upload_size_bytes(upload_max_file_size_mb) + file_size = photo.file_size + if _metadata_exceeds_limit(file_size, max_size_bytes=max_size_bytes): + await _answer_oversized( + message, + filename=filename, + size_bytes=file_size, + upload_max_file_size_mb=upload_max_file_size_mb, + ) + return try: bot = message.bot @@ -164,10 +276,26 @@ async def handle_photo(message: Message) -> None: await message.answer(f"❌ 文件下载失败: {exc}") return + if len(data) > max_size_bytes: + await _answer_oversized( + message, + filename=filename, + size_bytes=len(data), + upload_max_file_size_mb=upload_max_file_size_mb, + ) + return + # Queue if task is running if await _user_has_running_task(task_service, user_id): - _pending_uploads[user_id].append((filename, data)) - await message.answer(f"⏳ 任务运行中,文件 {filename} 已加入队列,将在任务完成后处理。") + queued = await upload_queue.enqueue(user_id=user_id, filename=filename, data=data) + if not queued.accepted: + await message.answer(f"❌ 文件未加入队列: {filename}\n原因: {queued.reason}") + return + await message.answer( + f"⏳ 任务运行中,文件 {filename} 已加入队列,将在任务完成后处理。\n" + f"注意:队列仅保存在内存中,如果 bot 在任务完成前重启,已排队文件会丢失;" + f"排队文件超过 {_format_ttl(upload_queue_ttl_sec)} 未处理会过期。" + ) return await _process_upload( diff --git a/app/bot/handlers/run_event_streamer.py b/app/bot/handlers/run_event_streamer.py index 8aecb57..fe974a1 100644 --- a/app/bot/handlers/run_event_streamer.py +++ b/app/bot/handlers/run_event_streamer.py @@ -2,6 +2,7 @@ import asyncio import logging +from collections.abc import Callable from pathlib import Path from typing import Any @@ -80,6 +81,8 @@ def _build_error_message(*, event_type: EventType, task_id: str, error_text: str _SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") _SPINNER_INTERVAL_SEC = 1.0 _SPINNER_INITIAL_DELAY_SEC = 3.0 +_INTERACTIVE_PUMP_CANCEL_GRACE_SEC = 5.0 +_ABANDONED_INTERACTIVE_PUMP_TASKS: set[asyncio.Task] = set() class RunEventStreamer: @@ -95,6 +98,7 @@ def __init__( lifecycle_message: Message | None, diff_generator: DiffGeneratorService | None = None, result_exporter: ResultExporterService | None = None, + queued_upload_scheduler: Callable[[], None] | None = None, ) -> None: self._start = start self._task_service = task_service @@ -105,6 +109,8 @@ def __init__( self._lifecycle_message = lifecycle_message self._diff_generator = diff_generator self._result_exporter = result_exporter + self._queued_upload_scheduler = queued_upload_scheduler + self._queued_upload_scheduled = False self._interactive_pump: asyncio.Task | None = None self._spinner_task: asyncio.Task | None = None self._emit_lock = asyncio.Lock() @@ -129,6 +135,67 @@ async def _stop_spinner(self) -> None: except asyncio.CancelledError: pass + def _schedule_queued_uploads_once(self) -> None: + if self._queued_upload_scheduler is None or self._queued_upload_scheduled: + return + self._queued_upload_scheduled = True + try: + self._queued_upload_scheduler() + except Exception: + logger.exception("failed to schedule queued upload processing", extra={"user_id": self._user_id}) + + @staticmethod + def _consume_task_result(task: asyncio.Task) -> None: + if task.cancelled(): + return + try: + task.exception() + except Exception: + pass + + def _forget_abandoned_interactive_pump(self, task: asyncio.Task) -> None: + _ABANDONED_INTERACTIVE_PUMP_TASKS.discard(task) + if self._interactive_pump is task: + self._interactive_pump = None + self._consume_task_result(task) + + async def _cancel_interactive_pump(self, *, timeout_sec: float | None = None) -> None: + task = self._interactive_pump + if task is None: + return + task.cancel() + if timeout_sec is None: + try: + await task + except asyncio.CancelledError: + pass + if task.done() and self._interactive_pump is task: + self._interactive_pump = None + return + + done, _ = await asyncio.wait({task}, timeout=timeout_sec) + if task in done: + try: + await task + except asyncio.CancelledError: + pass + if self._interactive_pump is task: + self._interactive_pump = None + return + if task not in _ABANDONED_INTERACTIVE_PUMP_TASKS: + _ABANDONED_INTERACTIVE_PUMP_TASKS.add(task) + task.add_done_callback(self._forget_abandoned_interactive_pump) + logger.error( + "interactive pump cancellation grace timeout", + extra={"task_id": self._start.task.task_id, "user_id": self._user_id, "timeout_sec": timeout_sec}, + ) + + async def force_cleanup(self, *, schedule_uploads: bool = False, cancel_timeout_sec: float | None = None) -> None: + if schedule_uploads: + self._schedule_queued_uploads_once() + await self._stop_spinner() + await self._cancel_interactive_pump(timeout_sec=cancel_timeout_sec) + async def _spin(self) -> None: short_id = self._start.task.task_id[:8] frame_idx = 0 @@ -223,6 +290,7 @@ async def _maybe_auto_export(self) -> None: async def stream_events(self) -> None: saw_exit = False + saw_terminal = False try: async for event in self._start.events: if event.type in {EventType.STDOUT, EventType.STDERR}: @@ -253,6 +321,11 @@ async def stream_events(self) -> None: self._interactive_pump = asyncio.create_task(self.pump_structured_reply()) continue + if event.type in {EventType.EXITED, EventType.FAILED, EventType.TIMEOUT, EventType.CANCELED}: + saw_terminal = True + if event.type == EventType.EXITED: + saw_exit = True + if self._start.interactive: async with self._emit_lock: await self._dispatcher.emit_presenter_messages(log_missing=True) @@ -261,7 +334,6 @@ async def stream_events(self) -> None: duration, truncated = await _load_status_summary(self._task_service, self._start.task.task_id, self._user_id) if event.type == EventType.EXITED: - saw_exit = True success_msg = _build_success_message( task_id=self._start.task.task_id, exit_code=event.exit_code, @@ -296,18 +368,17 @@ async def stream_events(self) -> None: if not await self._messenger.edit_message_safely(self._lifecycle_message, error_msg): await self._messenger.answer_safely(error_msg) finally: - await self._stop_spinner() - if saw_exit and self._start.interactive: - await asyncio.sleep(0.1) - # Freeze the presenter's last turn ID to prevent emitting - # new turns that arrive after task completion (e.g., idle greetings). - self._presenter.freeze_reply_cursor() - async with self._emit_lock: - await self._dispatcher.emit_presenter_messages(final=True, log_missing=True) - await self._dispatcher.flush() - if self._interactive_pump is not None: - self._interactive_pump.cancel() - try: - await self._interactive_pump - except asyncio.CancelledError: - pass + if saw_terminal: + self._schedule_queued_uploads_once() + try: + await self._stop_spinner() + if saw_exit and self._start.interactive: + await asyncio.sleep(0.1) + # Freeze the presenter's last turn ID to prevent emitting + # new turns that arrive after task completion (e.g., idle greetings). + self._presenter.freeze_reply_cursor() + async with self._emit_lock: + await self._dispatcher.emit_presenter_messages(final=True, log_missing=True) + await self._dispatcher.flush() + finally: + await self._cancel_interactive_pump(timeout_sec=_INTERACTIVE_PUMP_CANCEL_GRACE_SEC) diff --git a/app/bot/handlers/run_presenter_dispatcher.py b/app/bot/handlers/run_presenter_dispatcher.py index 6d19518..ad9a255 100644 --- a/app/bot/handlers/run_presenter_dispatcher.py +++ b/app/bot/handlers/run_presenter_dispatcher.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from app.bot.handlers.command_permission import build_permission_keyboard from app.bot.handlers.command_user_question import build_user_question_keyboard from app.bot.handlers.run_telegram_messenger import RunTelegramMessenger @@ -18,6 +20,9 @@ ) from app.bot.presenters.tool_message_manager import ToolMessageManager +if TYPE_CHECKING: + from app.services.permission_callback_registry import PermissionCallbackRegistry + class PresenterOutputDispatcher: def __init__( @@ -28,12 +33,14 @@ def __init__( messenger: RunTelegramMessenger, tool_message_manager: ToolMessageManager, task_id: str, + permission_callback_registry: PermissionCallbackRegistry | None = None, ) -> None: self._presenter = presenter self._sender = sender self._messenger = messenger self._tool_message_manager = tool_message_manager self._task_id = task_id + self._permission_callback_registry = permission_callback_registry async def send_text(self, text: str) -> bool: normalized = normalize_stream_text(text) @@ -51,7 +58,13 @@ async def emit_presenter_messages(self, *, final: bool = False, log_missing: boo for output in await self._presenter.poll(task_id=self._task_id, final=final, log_missing=log_missing): if isinstance(output, PermissionRequestOutput): await self.flush() - keyboard = build_permission_keyboard(tool_use_id=output.tool_use_id) if output.tool_use_id else None + keyboard = ( + build_permission_keyboard( + tool_use_id=output.tool_use_id, permission_callback_registry=self._permission_callback_registry + ) + if output.tool_use_id and self._permission_callback_registry is not None + else None + ) # Try editing the existing tool status message into the permission prompt edited = False if output.tool_use_id: diff --git a/app/bot/handlers/session_actions.py b/app/bot/handlers/session_actions.py new file mode 100644 index 0000000..b456df0 --- /dev/null +++ b/app/bot/handlers/session_actions.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import logging + +from aiogram import F, Router +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup + +from app.services.external_session_binder import ExternalSessionBinder +from app.services.external_session_discovery import ExternalSessionDiscoveryService + +logger = logging.getLogger(__name__) + + +def _resolve_session_id( + session_id_prefix: str, + discovery: ExternalSessionDiscoveryService, + binder: ExternalSessionBinder, +) -> tuple[str | None, str | None]: + """Resolve a partial session_id prefix to a full session_id. + + Searches both unbound discovery list and bound sessions. + Returns (full_session_id, error_message). + """ + prefix = session_id_prefix.rstrip(".") + candidates: list[str] = [] + + for s in discovery.list_unbound(): + if s.session_id == prefix or s.session_id.startswith(prefix): + candidates.append(s.session_id) + + for b in binder._binding_store.load_all().values(): + if b.session_id == prefix or b.session_id.startswith(prefix): + if b.session_id not in candidates: + candidates.append(b.session_id) + + if len(candidates) == 1: + return candidates[0], None + if len(candidates) == 0: + return None, "Session not found" + return None, f"Ambiguous prefix, {len(candidates)} matches. Be more specific." + + +def register_session_action_handlers( + router: Router, + *, + discovery: ExternalSessionDiscoveryService, + binder: ExternalSessionBinder, +) -> None: + @router.callback_query(F.data.startswith("sess:select:")) + async def handle_session_select(callback: CallbackQuery) -> None: + user_id = callback.from_user.id if callback.from_user else 0 + data = callback.data or "" + parts = data.split(":", 2) + if len(parts) < 3: + await callback.answer("Invalid callback data") + return + + session_id_prefix = parts[2] + resolved, error = _resolve_session_id(session_id_prefix, discovery, binder) + if error: + await callback.answer(error) + return + + # Determine binding state for this user + binding = binder._binding_store.get_binding(resolved) + is_bound_to_user = binding is not None and binding.user_id == user_id + + # Build detail message + short_id = resolved[:12] + # Try to get cwd from discovery or binding + cwd = "" + unbound_session = discovery.get(resolved) + if unbound_session: + cwd = unbound_session.cwd + elif binding: + cwd = binding.cwd + + detail_text = f"📂 Session: {short_id}...\n cwd: {cwd}" + + # Build action buttons conditionally + sid_prefix = resolved[:16] + if is_bound_to_user: + buttons = [[InlineKeyboardButton(text="取消绑定", callback_data=f"sess:unbind:{sid_prefix}")]] + else: + buttons = [[InlineKeyboardButton(text="绑定", callback_data=f"sess:bind:{sid_prefix}")]] + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await callback.answer() + if callback.message: + await callback.message.answer(detail_text, reply_markup=keyboard) + + @router.callback_query(F.data.startswith("sess:bind:")) + async def handle_session_bind(callback: CallbackQuery) -> None: + user_id = callback.from_user.id if callback.from_user else 0 + data = callback.data or "" + parts = data.split(":", 2) + if len(parts) < 3: + await callback.answer("Invalid callback data") + return + + session_id_prefix = parts[2] + resolved, error = _resolve_session_id(session_id_prefix, discovery, binder) + if error: + await callback.answer(error) + return + + result = await binder.bind(user_id=user_id, session_id=resolved) + if result.success: + conv_status = "✅ conversation available" if result.conversation_available else "⏳ waiting for JSONL" + await callback.answer("绑定成功") + if callback.message: + await callback.message.answer(f"🔗 Bound session {resolved[:12]}...\n{conv_status}") + else: + await callback.answer(f"❌ {result.message}") + + @router.callback_query(F.data.startswith("sess:unbind:")) + async def handle_session_unbind(callback: CallbackQuery) -> None: + user_id = callback.from_user.id if callback.from_user else 0 + data = callback.data or "" + parts = data.split(":", 2) + if len(parts) < 3: + await callback.answer("Invalid callback data") + return + + session_id_prefix = parts[2] + resolved, error = _resolve_session_id(session_id_prefix, discovery, binder) + if error: + await callback.answer(error) + return + + result = await binder.unbind(user_id=user_id, session_id=resolved) + if result.success: + await callback.answer("取消绑定成功") + if callback.message: + await callback.message.answer(f"🔓 Unbound session {resolved[:12]}...") + else: + await callback.answer(f"❌ {result.message}") diff --git a/app/bot/middleware/rate_limit.py b/app/bot/middleware/rate_limit.py index a41f19e..c467dff 100644 --- a/app/bot/middleware/rate_limit.py +++ b/app/bot/middleware/rate_limit.py @@ -9,12 +9,59 @@ class RateLimitMiddleware(BaseMiddleware): - def __init__(self, *, limit: int, window_sec: int) -> None: + def __init__( + self, + *, + limit: int, + window_sec: int, + bucket_ttl_sec: int | None = None, + cleanup_interval_sec: int = 60, + cleanup_batch_size: int = 50, + ) -> None: super().__init__() + if limit <= 0: + raise ValueError("limit must be positive") + if window_sec <= 0: + raise ValueError("window_sec must be positive") + effective_bucket_ttl_sec = bucket_ttl_sec if bucket_ttl_sec is not None else window_sec + if effective_bucket_ttl_sec <= 0: + raise ValueError("bucket_ttl_sec must be positive") + if effective_bucket_ttl_sec < window_sec: + raise ValueError("bucket_ttl_sec must be greater than or equal to window_sec") + if cleanup_interval_sec <= 0: + raise ValueError("cleanup_interval_sec must be positive") + if cleanup_batch_size <= 0: + raise ValueError("cleanup_batch_size must be positive") self._limit = limit self._window_sec = window_sec + self._bucket_ttl_sec = effective_bucket_ttl_sec + self._cleanup_interval_sec = cleanup_interval_sec + self._cleanup_batch_size = cleanup_batch_size self._buckets: dict[int, deque[float]] = {} self._lock = asyncio.Lock() + self._cleanup_queue: deque[int] = deque() + self._cleanup_queued: set[int] = set() + self._last_cleanup_ts: float = 0.0 + + def _enqueue_cleanup(self, user_id: int) -> None: + if user_id not in self._cleanup_queued: + self._cleanup_queue.append(user_id) + self._cleanup_queued.add(user_id) + + def _try_cleanup_stale_buckets(self, now: float) -> None: + if now - self._last_cleanup_ts < self._cleanup_interval_sec: + return + self._last_cleanup_ts = now + for _ in range(min(self._cleanup_batch_size, len(self._cleanup_queue))): + uid = self._cleanup_queue.popleft() + self._cleanup_queued.discard(uid) + bucket = self._buckets.get(uid) + if bucket is None: + continue + if not bucket or now - bucket[-1] > self._bucket_ttl_sec: + self._buckets.pop(uid, None) + else: + self._enqueue_cleanup(uid) async def __call__( self, @@ -27,15 +74,27 @@ async def __call__( return await handler(event, data) now = asyncio.get_running_loop().time() + limited = False async with self._lock: - bucket = self._buckets.setdefault(user.id, deque()) + self._try_cleanup_stale_buckets(now) + + bucket = self._buckets.get(user.id) + if bucket is None: + bucket = deque() + self._buckets[user.id] = bucket + self._enqueue_cleanup(user.id) + elif user.id not in self._cleanup_queued: + self._enqueue_cleanup(user.id) while bucket and now - bucket[0] > self._window_sec: bucket.popleft() if len(bucket) >= self._limit: - await event.answer("请求过于频繁,请稍后再试。") - return None + limited = True + else: + bucket.append(now) - bucket.append(now) + if limited: + await event.answer("请求过于频繁,请稍后再试。") + return None return await handler(event, data) diff --git a/app/bot/presenters/structured_reply_messages.py b/app/bot/presenters/structured_reply_messages.py index 2196835..55fa3bd 100644 --- a/app/bot/presenters/structured_reply_messages.py +++ b/app/bot/presenters/structured_reply_messages.py @@ -59,14 +59,33 @@ def _format_tool_input_detail(tool_name: str | None, tool_input: dict | None) -> def build_permission_prompt(*, tool_name: str | None, tool_input: dict | None = None) -> str: - lines = ["权限请求"] + lines = ["🔐 权限请求"] if tool_name: lines.append(f"工具: {tool_name}") - detail = _format_tool_input_detail(tool_name, tool_input) - if detail is not None: - label, value = detail - lines.append(f"{label}: {value}") + # Show command/file_path using backtick code blocks (the messenger's + # render_markdownish_to_telegram_html converts these to /
).
+    if tool_input:
+        command = tool_input.get("command")
+        file_path = tool_input.get("file_path") or tool_input.get("path")
+        description = tool_input.get("description")
+        if command:
+            cmd_display = command if len(command) <= 300 else command[:300] + "..."
+            if "\n" in cmd_display:
+                lines.append(f"\n```\n{cmd_display}\n```")
+            else:
+                lines.append(f"\n`{cmd_display}`")
+        elif file_path:
+            lines.append(f"\n`{file_path}`")
+        elif description:
+            desc_display = description if len(description) <= 200 else description[:200] + "..."
+            lines.append(f"📝 {desc_display}")
+        else:
+            # Fallback: use _format_tool_input_detail for other tool types
+            detail = _format_tool_input_detail(tool_name, tool_input)
+            if detail is not None:
+                label, value = detail
+                lines.append(f"{label}: `{value}`")
 
     lines.append("")
     lines.append("请点击下方按钮选择允许或拒绝。")
diff --git a/app/bot/router.py b/app/bot/router.py
index b6ac428..3aa4f93 100644
--- a/app/bot/router.py
+++ b/app/bot/router.py
@@ -1,31 +1,50 @@
 from __future__ import annotations
 
 import logging
+from typing import TYPE_CHECKING
 
 from aiogram import F, Router
 from aiogram.filters import Command
 from aiogram.types import Message
 
+from app.adapters.claude.paths import ClaudePaths
 from app.bot.handlers.command_attach import register_attach_handler
 from app.bot.handlers.command_cancel import register_cancel_handler
 from app.bot.handlers.command_claude import register_claude_handler
+from app.bot.handlers.command_cmds import register_cmds_handler
 from app.bot.handlers.command_exit import register_exit_handler
 from app.bot.handlers.command_export import register_export_handler
 from app.bot.handlers.command_list import register_list_handler
+from app.bot.handlers.command_resume import register_resume_handler
+from app.bot.handlers.external_session import register_external_session_handler
+from app.bot.handlers.session_actions import register_session_action_handlers
+from app.bot.handlers.external_permission import register_external_permission_handler
 from app.bot.handlers.command_permission import register_permission_handlers
 from app.bot.handlers.command_user_question import maybe_handle_pending_user_question_text, register_user_question_handlers
 from app.bot.handlers.command_run import register_run_handler, run_prompt_and_stream
 from app.bot.handlers.command_session import register_session_handler
 from app.bot.handlers.command_status import register_status_handler
-from app.bot.handlers.file_upload import register_file_upload_handler
+from app.bot.handlers.file_upload import register_file_upload_handler, schedule_pending_upload_processing
 from app.bot.presenters.chunk_sender import ChunkSender
 from app.config.settings import Settings
 from app.services.diff_generator import DiffGeneratorService
+from app.services.external_session_binder import ExternalSessionBinder
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
 from app.services.file_receiver import FileReceiverService
 from app.services.result_exporter import ResultExporterService
 from app.services.session_registry import SessionRegistryService
+from app.services.session_scanner import SessionScanner
 from app.services.session_service import SessionService
+from app.services.session_store import SessionStore
 from app.services.task_service import TaskService
+from app.services.upload_queue import UploadQueueManager
+
+if TYPE_CHECKING:
+    from app.adapters.claude.hook_socket_server import HookSocketServer
+    from app.services.auto_approve_service import AutoApproveService
+    from app.services.external_user_question_state import ExternalUserQuestionState
+    from app.services.permission_callback_registry import PermissionCallbackRegistry
+    from app.services.unbound_permission_handler import UnboundPermissionHandler
 
 logger = logging.getLogger(__name__)
 
@@ -37,8 +56,19 @@ def create_router(
     session_service: SessionService,
     registry_service: SessionRegistryService | None = None,
     file_receiver: FileReceiverService | None = None,
+    upload_queue: UploadQueueManager | None = None,
     result_exporter: ResultExporterService | None = None,
     diff_generator: DiffGeneratorService | None = None,
+    external_discovery: ExternalSessionDiscoveryService | None = None,
+    external_binder: ExternalSessionBinder | None = None,
+    structured_session_store: SessionStore | None = None,
+    hook_socket_server: HookSocketServer | None = None,
+    unbound_permission_handler: UnboundPermissionHandler | None = None,
+    external_uq_state: ExternalUserQuestionState | None = None,
+    auto_approve_service: AutoApproveService | None = None,
+    permission_callback_registry: PermissionCallbackRegistry | None = None,
+    session_scanner: SessionScanner | None = None,
+    claude_paths: ClaudePaths | None = None,
 ) -> Router:
     router = Router()
 
@@ -78,30 +108,100 @@ async def command_start(message: Message) -> None:
         flush_interval_sec=settings.chunk_flush_interval_sec,
     )
 
+    queued_upload_scheduler = None
+    if file_receiver is not None and upload_queue is not None:
+
+        def _queued_upload_scheduler(message: Message, user_id: int, completed_task_id: str) -> None:
+            schedule_pending_upload_processing(
+                message,
+                file_receiver=file_receiver,
+                session_service=session_service,
+                upload_queue=upload_queue,
+                user_id=user_id,
+                task_service=task_service,
+                completed_task_id=completed_task_id,
+            )
+
+        queued_upload_scheduler = _queued_upload_scheduler
+
     register_run_handler(
         router,
         task_service=task_service,
         sender_factory=sender_factory,
         diff_generator=diff_generator,
         result_exporter=result_exporter,
+        queued_upload_scheduler=queued_upload_scheduler,
+        permission_callback_registry=permission_callback_registry,
     )
     register_claude_handler(router, task_service=task_service)
     register_cancel_handler(router, task_service=task_service)
     register_status_handler(router, task_service=task_service)
     register_session_handler(router, task_service=task_service, session_service=session_service)
-    register_permission_handlers(router, task_service=task_service)
+    if permission_callback_registry is not None:
+        register_permission_handlers(
+            router,
+            task_service=task_service,
+            auto_approve_service=auto_approve_service,
+            hook_socket_server=hook_socket_server,
+            structured_session_store=structured_session_store,
+            permission_callback_registry=permission_callback_registry,
+        )
     register_user_question_handlers(router, task_service=task_service)
     register_exit_handler(router, task_service=task_service)
+    register_cmds_handler(router, session_service=session_service, task_service=task_service)
+
+    if session_scanner is not None and claude_paths is not None:
+        register_resume_handler(
+            router,
+            session_scanner=session_scanner,
+            task_service=task_service,
+            session_service=session_service,
+            claude_paths=claude_paths,
+        )
+
     if registry_service is not None:
-        register_list_handler(router, registry_service=registry_service)
+        register_list_handler(
+            router,
+            registry_service=registry_service,
+            external_discovery=external_discovery,
+            external_binder=external_binder,
+        )
         register_attach_handler(router, registry_service=registry_service)
 
-    if file_receiver is not None:
+    if external_discovery is not None and external_binder is not None:
+        register_session_action_handlers(
+            router,
+            discovery=external_discovery,
+            binder=external_binder,
+        )
+
+    if external_discovery is not None and external_binder is not None and structured_session_store is not None:
+        register_external_session_handler(
+            router,
+            discovery=external_discovery,
+            binder=external_binder,
+            session_store=structured_session_store,
+        )
+
+    if hook_socket_server is not None and unbound_permission_handler is not None and permission_callback_registry is not None:
+        register_external_permission_handler(
+            router,
+            hook_socket_server=hook_socket_server,
+            unbound_permission_handler=unbound_permission_handler,
+            permission_callback_registry=permission_callback_registry,
+            external_uq_state=external_uq_state,
+            auto_approve_service=auto_approve_service,
+        )
+
+    if file_receiver is not None and upload_queue is not None:
         register_file_upload_handler(
             router,
             file_receiver=file_receiver,
             session_service=session_service,
             task_service=task_service,
+            upload_queue=upload_queue,
+            upload_max_file_size_mb=settings.upload_max_file_size_mb,
+            upload_queue_ttl_sec=settings.upload_queue_ttl_sec,
         )
 
     if result_exporter is not None:
@@ -153,6 +253,8 @@ async def command_claude_chat_text(message: Message) -> None:
             workdir=session.workdir,
             diff_generator=diff_generator,
             result_exporter=result_exporter,
+            queued_upload_scheduler=queued_upload_scheduler,
+            permission_callback_registry=permission_callback_registry,
         )
         logger.info(
             "claude chat stream spawned",
diff --git a/app/config/settings.py b/app/config/settings.py
index cb4790f..7ceeed0 100644
--- a/app/config/settings.py
+++ b/app/config/settings.py
@@ -4,7 +4,7 @@
 from pathlib import Path
 from typing import Annotated, Any
 
-from pydantic import Field, field_validator
+from pydantic import Field, field_validator, model_validator
 from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
 
 
@@ -89,6 +89,19 @@ class Settings(BaseSettings):
 
     rate_limit_max_requests: int = Field(6, alias="RATE_LIMIT_MAX_REQUESTS")
     rate_limit_window_sec: int = Field(20, alias="RATE_LIMIT_WINDOW_SEC")
+    rate_limit_bucket_ttl_sec: int | None = Field(None, alias="RATE_LIMIT_BUCKET_TTL_SEC")
+    rate_limit_bucket_cleanup_interval_sec: int = Field(60, alias="RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC")
+    rate_limit_bucket_cleanup_batch_size: int = Field(50, alias="RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE")
+
+    # Task store settings
+    task_store_ttl_hours: int = Field(168, alias="TASK_STORE_TTL_HOURS")
+    task_store_max_records: int = Field(1000, alias="TASK_STORE_MAX_RECORDS")
+
+    # Lock settings
+    permission_lock_ttl_sec: int | None = Field(None, alias="PERMISSION_LOCK_TTL_SEC")
+    session_lock_ttl_sec: int = Field(3600, alias="SESSION_LOCK_TTL_SEC")
+    lock_cleanup_interval_sec: int = Field(60, alias="LOCK_CLEANUP_INTERVAL_SEC")
+    lock_cleanup_batch_size: int = Field(50, alias="LOCK_CLEANUP_BATCH_SIZE")
 
     chunk_size: int = Field(3800, alias="CHUNK_SIZE")
     chunk_flush_interval_sec: float = Field(1.0, alias="CHUNK_FLUSH_INTERVAL_SEC")
@@ -99,6 +112,10 @@ class Settings(BaseSettings):
 
     # File upload settings
     upload_max_file_size_mb: int = Field(20, alias="UPLOAD_MAX_FILE_SIZE_MB")
+    upload_queue_max_files_per_user: int = Field(5, alias="UPLOAD_QUEUE_MAX_FILES_PER_USER")
+    upload_queue_max_bytes_per_user: int | None = Field(None, alias="UPLOAD_QUEUE_MAX_BYTES_PER_USER")
+    upload_queue_ttl_sec: int = Field(3600, alias="UPLOAD_QUEUE_TTL_SEC")
+    upload_queue_cleanup_interval_sec: int = Field(60, alias="UPLOAD_QUEUE_CLEANUP_INTERVAL_SEC")
     allowed_file_extensions: Annotated[list[str], NoDecode] = Field(
         default_factory=lambda: list(DEFAULT_ALLOWED_EXTENSIONS),
         alias="ALLOWED_FILE_EXTENSIONS",
@@ -106,6 +123,31 @@ class Settings(BaseSettings):
     upload_expiry_hours: int = Field(24, alias="UPLOAD_EXPIRY_HOURS")
     upload_cleanup_interval_min: int = Field(60, alias="UPLOAD_CLEANUP_INTERVAL_MIN")
 
+    # External session settings
+    external_session_stale_timeout_sec: float = Field(600.0, alias="EXTERNAL_SESSION_STALE_TIMEOUT_SEC")
+    push_notification_retry_count: int = Field(1, alias="PUSH_NOTIFICATION_RETRY_COUNT")
+
+    # Auto file send settings
+    auto_file_send_enabled: bool = Field(True, alias="AUTO_FILE_SEND_ENABLED")
+    auto_file_send_extensions: Annotated[list[str], NoDecode] = Field(
+        default_factory=lambda: [
+            ".png",
+            ".jpg",
+            ".jpeg",
+            ".gif",
+            ".webp",
+            ".svg",
+            ".pdf",
+            ".docx",
+            ".xlsx",
+            ".csv",
+            ".html",
+            ".zip",
+            ".tar.gz",
+        ],
+        alias="AUTO_FILE_SEND_EXTENSIONS",
+    )
+
     # Export settings
     auto_export_threshold_chars: int = Field(4096, alias="AUTO_EXPORT_THRESHOLD_CHARS")
     zip_max_size_mb: int = Field(50, alias="ZIP_MAX_SIZE_MB")
@@ -153,7 +195,7 @@ def parse_workdirs(cls, value: Any) -> list[str]:
             return dirs
         raise ValueError("ALLOWED_WORKDIRS 格式错误,需为逗号分隔路径")
 
-    @field_validator("allowed_file_extensions", mode="before")
+    @field_validator("allowed_file_extensions", "auto_file_send_extensions", mode="before")
     @classmethod
     def parse_file_extensions(cls, value: Any) -> list[str]:
         if isinstance(value, list):
@@ -162,7 +204,7 @@ def parse_file_extensions(cls, value: Any) -> list[str]:
             return [ext.strip().lower() for ext in value.split(",") if ext.strip()]
         raise ValueError("ALLOWED_FILE_EXTENSIONS 格式错误,需为逗号分隔扩展名")
 
-    @field_validator("claude_tmux_mode", "claude_install_hooks", mode="before")
+    @field_validator("claude_tmux_mode", "claude_install_hooks", "auto_file_send_enabled", mode="before")
     @classmethod
     def parse_bool_flag(cls, value: Any) -> bool:
         if isinstance(value, bool):
@@ -194,6 +236,13 @@ def validate_concurrency(cls, value: int) -> int:
     @field_validator(
         "rate_limit_max_requests",
         "rate_limit_window_sec",
+        "rate_limit_bucket_cleanup_interval_sec",
+        "rate_limit_bucket_cleanup_batch_size",
+        "task_store_ttl_hours",
+        "task_store_max_records",
+        "session_lock_ttl_sec",
+        "lock_cleanup_interval_sec",
+        "lock_cleanup_batch_size",
         "chunk_size",
         "task_output_char_limit",
         "tg_request_timeout_sec",
@@ -204,10 +253,13 @@ def validate_concurrency(cls, value: int) -> int:
         "claude_jsonl_sync_debounce_ms",
         "claude_periodic_recheck_ms",
         "upload_max_file_size_mb",
+        "upload_queue_ttl_sec",
+        "upload_queue_cleanup_interval_sec",
         "upload_expiry_hours",
         "upload_cleanup_interval_min",
         "auto_export_threshold_chars",
         "zip_max_size_mb",
+        "push_notification_retry_count",
     )
     @classmethod
     def validate_positive_int(cls, value: int) -> int:
@@ -215,6 +267,30 @@ def validate_positive_int(cls, value: int) -> int:
             raise ValueError("配置值必须大于 0")
         return value
 
+    @field_validator("upload_queue_max_files_per_user")
+    @classmethod
+    def validate_upload_queue_max_files_per_user(cls, value: int) -> int:
+        if value < 0:
+            raise ValueError("UPLOAD_QUEUE_MAX_FILES_PER_USER 必须大于等于 0")
+        return value
+
+    @field_validator("rate_limit_bucket_ttl_sec", "permission_lock_ttl_sec", "upload_queue_max_bytes_per_user", mode="before")
+    @classmethod
+    def validate_optional_positive_int(cls, value: Any) -> Any:
+        if value is None:
+            return None
+        if isinstance(value, str) and not value.strip():
+            return None
+        if int(value) <= 0:
+            raise ValueError("配置值必须大于 0")
+        return value
+
+    @model_validator(mode="after")
+    def validate_rate_limit_bucket_ttl(self) -> Settings:
+        if self.rate_limit_bucket_ttl_sec is not None and self.rate_limit_bucket_ttl_sec < self.rate_limit_window_sec:
+            raise ValueError("RATE_LIMIT_BUCKET_TTL_SEC 必须大于等于 RATE_LIMIT_WINDOW_SEC")
+        return self
+
     @property
     def allow_all_users(self) -> bool:
         return len(self.tg_allowed_user_ids) == 0
@@ -226,3 +302,17 @@ def allowed_user_id_set(self) -> set[int]:
     @property
     def default_workdir(self) -> str:
         return self.allowed_workdirs[0]
+
+    @property
+    def effective_rate_limit_bucket_ttl_sec(self) -> int:
+        return self.rate_limit_bucket_ttl_sec if self.rate_limit_bucket_ttl_sec is not None else self.rate_limit_window_sec
+
+    @property
+    def effective_permission_lock_ttl_sec(self) -> int:
+        return self.permission_lock_ttl_sec if self.permission_lock_ttl_sec is not None else self.claude_hook_pending_permission_ttl_sec
+
+    @property
+    def effective_upload_queue_max_bytes_per_user(self) -> int:
+        if self.upload_queue_max_bytes_per_user is not None:
+            return self.upload_queue_max_bytes_per_user
+        return self.upload_queue_max_files_per_user * self.upload_max_file_size_mb * 1024 * 1024
diff --git a/app/domain/external_session_models.py b/app/domain/external_session_models.py
new file mode 100644
index 0000000..3d57c16
--- /dev/null
+++ b/app/domain/external_session_models.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import Literal
+
+
+class SessionOrigin(str, Enum):
+    TMUX = "tmux"
+    EXTERNAL = "external"
+
+
+@dataclass
+class OwnershipResult:
+    owner_user_id: int | None
+    origin: SessionOrigin
+    ownership_state: Literal["owned", "bound", "unbound"]
+
+
+@dataclass
+class UnboundExternalSession:
+    session_id: str
+    cwd: str
+    pid: int | None
+    first_seen: datetime
+    last_seen: datetime
+    event_count: int
+    title: str | None = None
+
+
+@dataclass
+class ExternalBinding:
+    session_id: str
+    user_id: int
+    cwd: str
+    bound_at: datetime
+    jsonl_path: str | None
+
+
+@dataclass
+class UnboundPermissionState:
+    session_id: str
+    tool_use_id: str
+    notified_user_ids: list[int]
+    responded: bool
+    responded_by: int | None
+    created_at: datetime
+
+
+@dataclass
+class BindResult:
+    success: bool
+    message: str
+    session_id: str | None = None
+    jsonl_path: Path | None = None
+    conversation_available: bool = False
diff --git a/app/domain/session_models.py b/app/domain/session_models.py
index 27d5061..c647ee1 100644
--- a/app/domain/session_models.py
+++ b/app/domain/session_models.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import re
 from dataclasses import dataclass, field
 from datetime import datetime
 from enum import Enum
@@ -8,6 +9,23 @@
 from app.domain.models import utc_now
 
 
+CLAUDE_SESSION_PREFIX = "claude-session-"
+_UUID_SESSION_RE = re.compile(
+    r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
+    re.IGNORECASE,
+)
+
+
+def is_claude_session_id(session_id: str | None) -> bool:
+    """Check if a session_id looks like a Claude-originated session identifier."""
+    if not session_id:
+        return False
+    text = str(session_id).strip()
+    if not text:
+        return False
+    return text.startswith(CLAUDE_SESSION_PREFIX) or bool(_UUID_SESSION_RE.match(text))
+
+
 class SessionPhase(str, Enum):
     IDLE = "idle"
     PROCESSING = "processing"
diff --git a/app/services/agent_file_watcher.py b/app/services/agent_file_watcher.py
index ed14d53..f8372e6 100644
--- a/app/services/agent_file_watcher.py
+++ b/app/services/agent_file_watcher.py
@@ -37,11 +37,28 @@ def watch(self, *, session_id: str, workdir: str) -> None:
             return
         self._tasks[session_id] = asyncio.create_task(self._watch_session(session_id=session_id, workdir=workdir))
 
+    def _clear_seen_mtimes_for_session(self, session_id: str) -> None:
+        prefix = f"{session_id}:"
+        stale_keys = [key for key in self._seen_mtimes if key == session_id or key.startswith(prefix)]
+        for key in stale_keys:
+            self._seen_mtimes.pop(key, None)
+
+    def _cleanup_finished_session(self, *, session_id: str, task: asyncio.Task[None] | None) -> None:
+        active_task = self._tasks.get(session_id)
+        if active_task is not None and active_task is not task:
+            return
+        if active_task is task:
+            self._tasks.pop(session_id, None)
+        self._clear_seen_mtimes_for_session(session_id)
+        self._session_locks.pop(session_id, None)
+
     def forget(self, session_id: str) -> None:
         task = self._tasks.pop(session_id, None)
-        self._seen_mtimes.pop(session_id, None)
-        if task is not None:
-            task.cancel()
+        self._clear_seen_mtimes_for_session(session_id)
+        if task is None or task.done():
+            self._session_locks.pop(session_id, None)
+            return
+        task.cancel()
 
     async def stop_all(self) -> None:
         self._active = False
@@ -53,6 +70,7 @@ async def stop_all(self) -> None:
         for task in tasks:
             with suppress(asyncio.CancelledError):
                 await task
+        self._session_locks.clear()
 
     async def _watch_session(self, *, session_id: str, workdir: str) -> None:
         lock = self._session_locks.setdefault(session_id, asyncio.Lock())
@@ -73,8 +91,7 @@ async def _watch_session(self, *, session_id: str, workdir: str) -> None:
         except Exception:
             logger.exception("agent file watcher failed", extra={"session_id": session_id, "workdir": workdir})
         finally:
-            if task is not None and self._tasks.get(session_id) is task:
-                self._tasks.pop(session_id, None)
+            self._cleanup_finished_session(session_id=session_id, task=task)
 
     def _should_watch(self, state: SessionState) -> bool:
         if state.provider != "claude_code":
diff --git a/app/services/auto_approve_service.py b/app/services/auto_approve_service.py
new file mode 100644
index 0000000..25fbfa3
--- /dev/null
+++ b/app/services/auto_approve_service.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True)
+class AutoApproveEntry:
+    session_id: str
+    user_id: int
+    activated_at: datetime
+
+
+class AutoApproveService:
+    """Manages per-session auto-approve state (in-memory only)."""
+
+    def __init__(self) -> None:
+        self._sessions: dict[str, AutoApproveEntry] = {}
+
+    def is_active(self, session_id: str) -> bool:
+        """Check if auto-approve is active for the given session."""
+        return session_id in self._sessions
+
+    def activate(self, session_id: str, *, user_id: int) -> None:
+        """Enable auto-approve for the given session."""
+        self._sessions[session_id] = AutoApproveEntry(
+            session_id=session_id,
+            user_id=user_id,
+            activated_at=datetime.now(timezone.utc),
+        )
+        logger.info("Auto-approve activated for session %s by user %d", session_id, user_id)
+
+    def deactivate(self, session_id: str) -> bool:
+        """Disable auto-approve. Returns True if it was active."""
+        entry = self._sessions.pop(session_id, None)
+        if entry is not None:
+            logger.info("Auto-approve deactivated for session %s", session_id)
+            return True
+        return False
+
+    def clear_session(self, session_id: str) -> None:
+        """Clear state for a session (called on SessionEnd/cleanup)."""
+        self._sessions.pop(session_id, None)
+
+    def get_active_session_for_user(self, user_id: int, session_id: str) -> bool:
+        """Check if the given session_id has auto-approve active for the user."""
+        entry = self._sessions.get(session_id)
+        if entry is None:
+            return False
+        return entry.user_id == user_id
diff --git a/app/services/claude_command_discovery.py b/app/services/claude_command_discovery.py
new file mode 100644
index 0000000..4aa4658
--- /dev/null
+++ b/app/services/claude_command_discovery.py
@@ -0,0 +1,134 @@
+"""Discovers Claude Code slash commands and skills from the filesystem."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Claude Code built-in slash commands
+BUILTIN_COMMANDS: list[tuple[str, str]] = [
+    ("/compact", "压缩上下文窗口"),
+    ("/clear", "清空会话历史"),
+    ("/model", "切换模型"),
+]
+
+
+@dataclass
+class ClaudeCommand:
+    """A discovered Claude slash command."""
+
+    name: str  # e.g. "/user:security-review" or "/project:optimize" or "/skill-name"
+    description: str  # First line of the .md file or folder name
+    source: str  # "builtin", "user", "project", "skill"
+    slash_text: str  # The actual text to send to Claude (e.g. "/compact" or "/user:security-review")
+
+
+def discover_commands(*, workdir: str, claude_home: Path | None = None) -> list[ClaudeCommand]:
+    """Discover all available Claude commands for the given workdir.
+
+    Scans:
+    - Built-in commands
+    - ~/.claude/commands/ (user-level, prefix: /user:)
+    - ~/.claude/skills/ (user-level skills, prefix: /)
+    - /.claude/commands/ (project-level, prefix: /project:)
+    - /.claude/skills/ (project-level skills, prefix: /)
+    """
+    home = claude_home or Path.home() / ".claude"
+    workdir_path = Path(workdir)
+    commands: list[ClaudeCommand] = []
+
+    # Built-in commands
+    for name, desc in BUILTIN_COMMANDS:
+        commands.append(ClaudeCommand(name=name, description=desc, source="builtin", slash_text=name))
+
+    # User-level commands (~/.claude/commands/)
+    user_commands_dir = home / "commands"
+    if user_commands_dir.is_dir():
+        for md_file in sorted(user_commands_dir.glob("*.md")):
+            cmd_name = md_file.stem
+            desc = _read_first_line(md_file)
+            commands.append(
+                ClaudeCommand(
+                    name=f"/user:{cmd_name}",
+                    description=desc or cmd_name,
+                    source="user",
+                    slash_text=f"/user:{cmd_name}",
+                )
+            )
+
+    # User-level skills (~/.claude/skills/)
+    user_skills_dir = home / "skills"
+    if user_skills_dir.is_dir():
+        for skill_dir in sorted(user_skills_dir.iterdir()):
+            if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
+                skill_name = skill_dir.name
+                desc = _read_first_line(skill_dir / "SKILL.md")
+                commands.append(
+                    ClaudeCommand(
+                        name=f"/{skill_name}",
+                        description=desc or skill_name,
+                        source="skill",
+                        slash_text=f"/{skill_name}",
+                    )
+                )
+
+    # Project-level commands (/.claude/commands/)
+    project_commands_dir = workdir_path / ".claude" / "commands"
+    if project_commands_dir.is_dir():
+        for md_file in sorted(project_commands_dir.glob("*.md")):
+            cmd_name = md_file.stem
+            desc = _read_first_line(md_file)
+            commands.append(
+                ClaudeCommand(
+                    name=f"/project:{cmd_name}",
+                    description=desc or cmd_name,
+                    source="project",
+                    slash_text=f"/project:{cmd_name}",
+                )
+            )
+
+    # Project-level skills (/.claude/skills/)
+    project_skills_dir = workdir_path / ".claude" / "skills"
+    if project_skills_dir.is_dir():
+        for skill_dir in sorted(project_skills_dir.iterdir()):
+            if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
+                skill_name = skill_dir.name
+                desc = _read_first_line(skill_dir / "SKILL.md")
+                commands.append(
+                    ClaudeCommand(
+                        name=f"/{skill_name}",
+                        description=desc or skill_name,
+                        source="project",
+                        slash_text=f"/{skill_name}",
+                    )
+                )
+
+    return commands
+
+
+def _read_first_line(path: Path) -> str:
+    """Read the first non-empty, non-heading, non-frontmatter line from a markdown file."""
+    try:
+        in_frontmatter = False
+        with open(path, encoding="utf-8", errors="ignore") as f:
+            for line in f:
+                stripped = line.strip()
+                if not stripped:
+                    continue
+                # Skip YAML front matter (--- ... ---)
+                if stripped == "---":
+                    in_frontmatter = not in_frontmatter
+                    continue
+                if in_frontmatter:
+                    continue
+                # Skip markdown headings but extract their text
+                if stripped.startswith("#"):
+                    stripped = stripped.lstrip("#").strip()
+                if stripped:
+                    return stripped[:60]
+        return ""
+    except OSError:
+        return ""
diff --git a/app/services/claude_jsonl_parser.py b/app/services/claude_jsonl_parser.py
index e0d8934..b1736c0 100644
--- a/app/services/claude_jsonl_parser.py
+++ b/app/services/claude_jsonl_parser.py
@@ -118,6 +118,47 @@ def session_file_path(self, *, session_id: str, cwd: str) -> Path:
         project_dir = cwd.replace("/", "-").replace(".", "-")
         return self._paths.projects_dir / project_dir / f"{safe_session_id}.jsonl"
 
+    def extract_session_title(self, *, session_id: str, cwd: str, max_length: int = 60) -> str | None:
+        """Extract the first user message text as session title.
+
+        Skips isMeta messages and system-injected caveats (content starting with '<').
+        Uses only the first line to match Claude CLI terminal title behavior.
+        Returns None on any failure without raising.
+        """
+        try:
+            path = self.session_file_path(session_id=session_id, cwd=cwd)
+            if not path.exists():
+                return None
+            with open(path, encoding="utf-8") as f:
+                for line in f:
+                    line = line.strip()
+                    if not line:
+                        continue
+                    record = json.loads(line)
+                    if record.get("type") != "user":
+                        continue
+                    if record.get("isMeta"):
+                        continue
+                    msg = record.get("message", {})
+                    content = msg.get("content", "")
+                    text = ""
+                    if isinstance(content, str):
+                        text = content
+                    elif isinstance(content, list):
+                        for block in content:
+                            if isinstance(block, dict) and block.get("type") == "text":
+                                text = block.get("text", "")
+                                break
+                    text = text.split("\n", 1)[0].strip()
+                    if not text or text.startswith("<"):
+                        continue
+                    if len(text) > max_length:
+                        return text[:max_length] + "…"
+                    return text
+            return None
+        except Exception:
+            return None
+
     def subagent_file_path(self, *, session_id: str, agent_id: str, cwd: str) -> Path:
         safe_session_id = validate_session_id(session_id)
         safe_agent_id = validate_path_component(agent_id, field_name="agent_id")
diff --git a/app/services/external_binding_store.py b/app/services/external_binding_store.py
new file mode 100644
index 0000000..eb0244d
--- /dev/null
+++ b/app/services/external_binding_store.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import json
+import logging
+import tempfile
+from datetime import datetime
+from pathlib import Path
+
+from app.domain.external_session_models import ExternalBinding
+
+logger = logging.getLogger(__name__)
+
+
+class ExternalBindingStore:
+    """Persists external session bindings as JSON for restart survival."""
+
+    def __init__(self, data_dir: Path) -> None:
+        self._data_dir = data_dir
+        self._file_path = data_dir / "external_bindings.json"
+        self._bindings: dict[str, ExternalBinding] = self.load_all()
+
+    def save_binding(self, binding: ExternalBinding) -> None:
+        self._bindings[binding.session_id] = binding
+        self._persist()
+
+    def remove_binding(self, session_id: str) -> None:
+        self._bindings.pop(session_id, None)
+        self._persist()
+
+    def get_binding(self, session_id: str) -> ExternalBinding | None:
+        return self._bindings.get(session_id)
+
+    def get_bindings_for_user(self, user_id: int) -> list[ExternalBinding]:
+        return [b for b in self._bindings.values() if b.user_id == user_id]
+
+    def load_all(self) -> dict[str, ExternalBinding]:
+        if not self._file_path.exists():
+            return {}
+        try:
+            data = json.loads(self._file_path.read_text(encoding="utf-8"))
+            bindings: dict[str, ExternalBinding] = {}
+            for session_id, entry in data.items():
+                bindings[session_id] = ExternalBinding(
+                    session_id=session_id,
+                    user_id=entry["user_id"],
+                    cwd=entry["cwd"],
+                    bound_at=datetime.fromisoformat(entry["bound_at"]),
+                    jsonl_path=entry.get("jsonl_path"),
+                )
+            return bindings
+        except (json.JSONDecodeError, KeyError, ValueError, OSError) as exc:
+            logger.error("Failed to load external bindings from %s: %s", self._file_path, exc)
+            return {}
+
+    def _persist(self) -> None:
+        self._data_dir.mkdir(parents=True, exist_ok=True)
+        data: dict[str, dict] = {}
+        for session_id, binding in self._bindings.items():
+            data[session_id] = {
+                "user_id": binding.user_id,
+                "cwd": binding.cwd,
+                "bound_at": binding.bound_at.isoformat(),
+                "jsonl_path": binding.jsonl_path,
+            }
+        # Atomic write: write to temp file then rename to avoid corruption
+        try:
+            fd, tmp_path = tempfile.mkstemp(dir=str(self._data_dir), suffix=".tmp", prefix="external_bindings_")
+            try:
+                with open(fd, "w", encoding="utf-8") as f:
+                    json.dump(data, f, indent=2)
+                Path(tmp_path).replace(self._file_path)
+            except BaseException:
+                Path(tmp_path).unlink(missing_ok=True)
+                raise
+        except OSError as exc:
+            logger.error("Failed to persist external bindings: %s", exc)
diff --git a/app/services/external_session_binder.py b/app/services/external_session_binder.py
new file mode 100644
index 0000000..e1ee8e6
--- /dev/null
+++ b/app/services/external_session_binder.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+
+from app.domain.external_session_models import BindResult, ExternalBinding
+from app.domain.models import utc_now
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+
+logger = logging.getLogger(__name__)
+
+
+def _resolve_jsonl_path(*, session_id: str, cwd: str, projects_dir: Path) -> Path:
+    """Resolve the JSONL path for a session using Claude's path convention.
+
+    Convention: ~/.claude/projects//.jsonl
+    where sanitized_cwd replaces '/' with '-' and '.' with '-'.
+    """
+    project_dir = cwd.replace("/", "-").replace(".", "-")
+    return projects_dir / project_dir / f"{session_id}.jsonl"
+
+
+class ExternalSessionBinder:
+    """Handles binding/unbinding of external sessions to Telegram users."""
+
+    def __init__(
+        self,
+        *,
+        discovery: ExternalSessionDiscoveryService,
+        binding_store: ExternalBindingStore,
+        projects_dir: Path,
+        sync_callback: Callable[[str, str], Awaitable[None]] | None = None,
+    ) -> None:
+        self._discovery = discovery
+        self._binding_store = binding_store
+        self._projects_dir = projects_dir
+        self._sync_callback = sync_callback
+
+    async def bind(self, *, user_id: int, session_id: str) -> BindResult:
+        """Bind an unbound session to a user.
+
+        Steps:
+        1. Verify session exists in discovery list
+        2. Verify not already bound to another user
+        3. Resolve JSONL path immediately
+        4. Create binding in store
+        5. Remove from discovery list
+        6. Call sync_callback to trigger JSONL parsing
+        7. Return BindResult with jsonl_path and whether file exists
+        """
+        # 1. Verify session exists in discovery list
+        unbound = self._discovery.get(session_id)
+        if unbound is None:
+            return BindResult(
+                success=False,
+                message="Session not found in discoverable list",
+                session_id=session_id,
+            )
+
+        # 2. Verify not already bound to another user
+        existing = self._binding_store.get_binding(session_id)
+        if existing is not None:
+            if existing.user_id == user_id:
+                return BindResult(
+                    success=False,
+                    message="Session is already bound to you",
+                    session_id=session_id,
+                )
+            return BindResult(
+                success=False,
+                message="Session already bound to another user",
+                session_id=session_id,
+            )
+
+        # 3. Resolve JSONL path
+        jsonl_path = _resolve_jsonl_path(
+            session_id=session_id,
+            cwd=unbound.cwd,
+            projects_dir=self._projects_dir,
+        )
+
+        # 4. Create binding in store
+        binding = ExternalBinding(
+            session_id=session_id,
+            user_id=user_id,
+            cwd=unbound.cwd,
+            bound_at=utc_now(),
+            jsonl_path=str(jsonl_path),
+        )
+        self._binding_store.save_binding(binding)
+
+        # 5. Remove from discovery list
+        self._discovery.remove_session(session_id)
+
+        # 6. Call sync_callback to trigger JSONL parsing
+        if self._sync_callback is not None:
+            try:
+                await self._sync_callback(session_id, unbound.cwd)
+            except Exception:
+                logger.exception("sync_callback failed for session %s", session_id)
+
+        # 7. Return result
+        file_exists = jsonl_path.exists()
+        return BindResult(
+            success=True,
+            message="Session bound successfully",
+            session_id=session_id,
+            jsonl_path=jsonl_path,
+            conversation_available=file_exists,
+        )
+
+    async def unbind(self, *, user_id: int, session_id: str) -> BindResult:
+        """Unbind a session from a user.
+
+        If the session is still active (exists in recent events), return it
+        to the discovery list.
+        """
+        # 1. Verify session is bound to this user
+        binding = self._binding_store.get_binding(session_id)
+        if binding is None:
+            return BindResult(
+                success=False,
+                message="Session not bound to you",
+                session_id=session_id,
+            )
+        if binding.user_id != user_id:
+            return BindResult(
+                success=False,
+                message="Session not bound to you",
+                session_id=session_id,
+            )
+
+        # 2. Remove binding from store
+        self._binding_store.remove_binding(session_id)
+
+        # 3. Note: The session will be re-discovered automatically when
+        #    the next hook event arrives (if still alive). We don't need
+        #    to manually add it back to discovery since the ownership resolver
+        #    will route it to discovery on the next event.
+
+        return BindResult(
+            success=True,
+            message="Session unbound successfully",
+            session_id=session_id,
+        )
+
+    def get_binding_user(self, session_id: str) -> int | None:
+        """Get the user_id that owns a bound session, or None."""
+        binding = self._binding_store.get_binding(session_id)
+        return binding.user_id if binding else None
+
+    def list_bound_for_user(self, user_id: int) -> list[ExternalBinding]:
+        """List all external sessions bound to a specific user."""
+        return self._binding_store.get_bindings_for_user(user_id)
diff --git a/app/services/external_session_discovery.py b/app/services/external_session_discovery.py
new file mode 100644
index 0000000..122b477
--- /dev/null
+++ b/app/services/external_session_discovery.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import logging
+import os
+from collections.abc import Callable
+
+from app.domain.external_session_models import UnboundExternalSession
+from app.domain.hook_models import HookEvent
+from app.domain.models import utc_now
+
+logger = logging.getLogger(__name__)
+
+
+class ExternalSessionDiscoveryService:
+    """Tracks unbound external sessions discovered via hook events."""
+
+    def __init__(
+        self,
+        *,
+        stale_timeout_sec: float = 600.0,
+        title_resolver: Callable[[str, str], str | None] | None = None,
+    ) -> None:
+        self._stale_timeout_sec = stale_timeout_sec
+        self._title_resolver = title_resolver
+        self._sessions: dict[str, UnboundExternalSession] = {}
+
+    def record_event(self, event: HookEvent) -> None:
+        """Record a hook event from an unbound session.
+
+        Creates a new entry if session_id not yet tracked, otherwise updates
+        last_seen and increments event_count.
+        """
+        now = utc_now()
+        existing = self._sessions.get(event.session_id)
+        if existing is None:
+            title = self._resolve_title(event.session_id, event.cwd)
+            self._sessions[event.session_id] = UnboundExternalSession(
+                session_id=event.session_id,
+                cwd=event.cwd,
+                pid=event.pid,
+                first_seen=now,
+                last_seen=now,
+                event_count=1,
+                title=title,
+            )
+        else:
+            existing.last_seen = now
+            existing.event_count += 1
+            existing.cwd = event.cwd
+            if event.pid is not None:
+                existing.pid = event.pid
+            if existing.title is None:
+                existing.title = self._resolve_title(event.session_id, event.cwd)
+
+    def _resolve_title(self, session_id: str, cwd: str) -> str | None:
+        """Attempt to resolve session title via the injected resolver."""
+        if self._title_resolver is None:
+            return None
+        try:
+            return self._title_resolver(session_id, cwd)
+        except Exception:
+            logger.debug("title resolver failed", extra={"session_id": session_id})
+            return None
+
+    def remove_session(self, session_id: str) -> None:
+        """Remove a session from unbound tracking."""
+        self._sessions.pop(session_id, None)
+
+    def list_unbound(self) -> list[UnboundExternalSession]:
+        """Return all currently-active unbound sessions (pruning stale/dead ones first)."""
+        self._prune_dead()
+        self.prune_stale()
+        return list(self._sessions.values())
+
+    def _prune_dead(self) -> None:
+        """Remove sessions whose pid is no longer running."""
+        dead_ids: list[str] = []
+        for session_id, session in self._sessions.items():
+            if session.pid is not None and not self._is_pid_alive(session.pid):
+                dead_ids.append(session_id)
+        for session_id in dead_ids:
+            del self._sessions[session_id]
+
+    @staticmethod
+    def _is_pid_alive(pid: int) -> bool:
+        """Check if a process is still running."""
+        try:
+            os.kill(pid, 0)
+            return True
+        except (ProcessLookupError, PermissionError):
+            return False
+        except OSError:
+            return False
+
+    def get(self, session_id: str) -> UnboundExternalSession | None:
+        """Get a specific unbound session by ID."""
+        return self._sessions.get(session_id)
+
+    def prune_stale(self) -> list[str]:
+        """Remove sessions whose last_seen exceeds stale_timeout_sec.
+
+        Returns the list of removed session IDs.
+        """
+        now = utc_now()
+        stale_ids: list[str] = []
+        for session_id, session in self._sessions.items():
+            elapsed = (now - session.last_seen).total_seconds()
+            if elapsed > self._stale_timeout_sec:
+                stale_ids.append(session_id)
+        for session_id in stale_ids:
+            del self._sessions[session_id]
+        return stale_ids
diff --git a/app/services/external_session_push_notifier.py b/app/services/external_session_push_notifier.py
new file mode 100644
index 0000000..e9fcfcc
--- /dev/null
+++ b/app/services/external_session_push_notifier.py
@@ -0,0 +1,211 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+
+if TYPE_CHECKING:
+    from aiogram import Bot
+
+    from app.domain.session_models import SessionPhase
+    from app.domain.user_question_models import UserQuestionPrompt
+    from app.services.external_binding_store import ExternalBindingStore
+
+logger = logging.getLogger(__name__)
+
+
+def _escape_html(text: str) -> str:
+    """Escape HTML special characters for Telegram HTML parse_mode."""
+    return text.replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+class ExternalSessionPushNotifier:
+    """Sends Telegram push notifications for bound external session events."""
+
+    def __init__(
+        self,
+        *,
+        bot: Bot,
+        binding_store: ExternalBindingStore,
+        retry_count: int = 1,
+        permission_callback_registry: PermissionCallbackRegistry | None = None,
+    ) -> None:
+        self._bot = bot
+        self._binding_store = binding_store
+        self._retry_count = retry_count
+        # Use `is None` rather than `or` because PermissionCallbackRegistry defines
+        # __len__, so an empty (newly-constructed) registry is falsy.
+        self._permission_callback_registry = (
+            permission_callback_registry if permission_callback_registry is not None else PermissionCallbackRegistry(ttl_sec=600)
+        )
+
+    async def notify_permission_request(
+        self,
+        *,
+        user_id: int,
+        session_id: str,
+        tool_name: str,
+        tool_input: dict | None,
+        tool_use_id: str,
+        cwd: str,
+        title: str | None = None,
+    ) -> bool:
+        """Send permission request notification to bound user. Returns True if delivered."""
+        short_id = session_id[:8]
+        header = f"🔐 [{title or short_id}] 请求权限: {tool_name}"
+
+        # Build structured message with tool_input details in code block
+        lines = [header]
+        if tool_input:
+            # Show command or file_path in a code block for readability
+            command = tool_input.get("command")
+            file_path = tool_input.get("file_path") or tool_input.get("path")
+            description = tool_input.get("description")
+            if command:
+                # Truncate very long commands
+                cmd_display = command if len(command) <= 300 else command[:300] + "..."
+                lines.append(f"\n{_escape_html(cmd_display)}")
+            elif file_path:
+                lines.append(f"\n{_escape_html(file_path)}")
+            if description:
+                desc_display = description if len(description) <= 150 else description[:150] + "..."
+                lines.append(f"📝 {_escape_html(desc_display)}")
+        lines.append(f"📂 {_escape_html(cwd)}")
+        text = "\n".join(lines)
+
+        # Register tool_use_id via registry to get a short token
+        token = self._permission_callback_registry.register(tool_use_id)
+        logger.info(
+            "push notifier registered token=%s for tool_use_id=%s registry_id=%s",
+            token,
+            tool_use_id[:20],
+            id(self._permission_callback_registry),
+        )
+
+        keyboard = InlineKeyboardMarkup(
+            inline_keyboard=[
+                [
+                    InlineKeyboardButton(text="✅ Approve", callback_data=f"ext_perm:{token}:allow"),
+                    InlineKeyboardButton(text="❌ Deny", callback_data=f"ext_perm:{token}:deny"),
+                ],
+                [
+                    InlineKeyboardButton(text="🟢 Auto-approve All", callback_data=f"ext_perm:{token}:auto_approve"),
+                ],
+            ]
+        )
+        return await self._send_with_retry(chat_id=user_id, text=text, reply_markup=keyboard, parse_mode="HTML")
+
+    async def notify_phase_change(
+        self,
+        *,
+        user_id: int,
+        session_id: str,
+        old_phase: SessionPhase,
+        new_phase: SessionPhase,
+        cwd: str,
+    ) -> bool:
+        """Send phase change notification. Returns True if delivered."""
+        short_id = session_id[:8]
+        text = f"📊 [{short_id}] {old_phase.value} → {new_phase.value}\n路径: {cwd}"
+        return await self._send_with_retry(chat_id=user_id, text=text)
+
+    async def notify_session_end(
+        self,
+        *,
+        user_id: int,
+        session_id: str,
+        cwd: str,
+    ) -> bool:
+        """Send session ended notification. Returns True if delivered."""
+        short_id = session_id[:8]
+        text = f"🔚 [{short_id}] 会话已结束\n路径: {cwd}"
+        return await self._send_with_retry(chat_id=user_id, text=text)
+
+    async def notify_user_question(
+        self,
+        *,
+        user_id: int,
+        session_id: str,
+        prompts: tuple[UserQuestionPrompt, ...],
+        interactive: bool = False,
+    ) -> bool:
+        """Send notification showing AskUserQuestion options.
+
+        When *interactive* is True, options are shown as clickable buttons that
+        inject the answer into the external terminal via PTY injection.
+        Otherwise, this is informational only (user answers in terminal).
+        Returns True if delivered.
+        """
+        if not prompts:
+            return False
+        short_id = session_id[:8]
+        # For interactive mode, we only show the first unanswered prompt with buttons
+        prompt = prompts[0]
+        lines: list[str] = []
+        lines.append(f"❓ [{short_id}] 用户选择")
+        lines.append(f"问题: {prompt.question}")
+        if prompt.options:
+            lines.append("选项:")
+            for i, option in enumerate(prompt.options, start=1):
+                label = option.label
+                if option.description:
+                    label += f" — {option.description}"
+                lines.append(f"  {i}. {label}")
+
+        if interactive and prompt.options:
+            lines.append("")
+            lines.append("👇 点击按钮选择:")
+            text = "\n".join(lines).rstrip()
+            # Build option buttons
+            # Callback data format: ext_uq:{tool_use_id}:{option_index}
+            buttons: list[list[InlineKeyboardButton]] = []
+            tool_use_id = prompt.tool_use_id
+            for i, option in enumerate(prompt.options):
+                # Telegram callback_data max 64 bytes; truncate tool_use_id if needed
+                cb_data = f"ext_uq:{tool_use_id}:{i}"
+                if len(cb_data.encode()) > 64:
+                    # Truncate tool_use_id to fit
+                    max_id_len = 64 - len(f"ext_uq::{i}".encode())
+                    cb_data = f"ext_uq:{tool_use_id[:max_id_len]}:{i}"
+                buttons.append([InlineKeyboardButton(text=f"{i + 1}. {option.label}"[:40], callback_data=cb_data)])
+            keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
+            return await self._send_with_retry(chat_id=user_id, text=text, reply_markup=keyboard)
+        else:
+            lines.append("请在终端中选择")
+            lines.append("")
+            text = "\n".join(lines).rstrip()
+            return await self._send_with_retry(chat_id=user_id, text=text)
+
+    async def notify_info(
+        self,
+        *,
+        user_id: int,
+        text: str,
+    ) -> bool:
+        """Send an informational notification (no action buttons). Returns True if delivered."""
+        return await self._send_with_retry(chat_id=user_id, text=text)
+
+    async def _send_with_retry(
+        self, *, chat_id: int, text: str, reply_markup: InlineKeyboardMarkup | None = None, parse_mode: str | None = None
+    ) -> bool:
+        """Send message with retry on failure."""
+        for attempt in range(1 + self._retry_count):
+            try:
+                await self._bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode)
+                return True
+            except Exception:
+                if attempt < self._retry_count:
+                    logger.warning(
+                        "Push notification delivery failed (attempt %d), retrying...",
+                        attempt + 1,
+                    )
+                else:
+                    logger.error(
+                        "Push notification delivery failed after %d attempts, giving up. chat_id=%d",
+                        attempt + 1,
+                        chat_id,
+                    )
+        return False
diff --git a/app/services/external_user_question_state.py b/app/services/external_user_question_state.py
new file mode 100644
index 0000000..519c0ee
--- /dev/null
+++ b/app/services/external_user_question_state.py
@@ -0,0 +1,60 @@
+"""State tracker for pending AskUserQuestion prompts in external sessions.
+
+Holds references so that when a Telegram user clicks an option button,
+we can look up the session PID, tmux pane, and permission details needed
+to inject the answer and respond to the hook.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from app.domain.models import utc_now
+from app.domain.user_question_models import UserQuestionPrompt
+
+logger = logging.getLogger(__name__)
+
+_TTL_SEC = 300.0  # 5 minutes
+
+
+@dataclass(slots=True)
+class PendingExternalUserQuestion:
+    tool_use_id: str
+    session_id: str
+    user_id: int
+    pid: int | None
+    prompts: tuple[UserQuestionPrompt, ...]
+    pane_id: str | None
+    created_at: datetime = field(default_factory=utc_now)
+
+
+class ExternalUserQuestionState:
+    """In-memory store for pending external AskUserQuestion interactions."""
+
+    def __init__(self, *, ttl_sec: float = _TTL_SEC) -> None:
+        self._ttl_sec = ttl_sec
+        # Keyed by tool_use_id
+        self._pending: dict[str, PendingExternalUserQuestion] = {}
+
+    def store(self, pending: PendingExternalUserQuestion) -> None:
+        self._prune_stale()
+        self._pending[pending.tool_use_id] = pending
+        logger.debug(
+            "stored pending external user question",
+            extra={"tool_use_id": pending.tool_use_id, "session_id": pending.session_id},
+        )
+
+    def get(self, tool_use_id: str) -> PendingExternalUserQuestion | None:
+        self._prune_stale()
+        return self._pending.get(tool_use_id)
+
+    def remove(self, tool_use_id: str) -> PendingExternalUserQuestion | None:
+        return self._pending.pop(tool_use_id, None)
+
+    def _prune_stale(self) -> None:
+        now = utc_now()
+        stale_keys = [key for key, pending in self._pending.items() if (now - pending.created_at).total_seconds() > self._ttl_sec]
+        for key in stale_keys:
+            self._pending.pop(key, None)
diff --git a/app/services/file_sender.py b/app/services/file_sender.py
new file mode 100644
index 0000000..086f1ba
--- /dev/null
+++ b/app/services/file_sender.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from aiogram import Bot
+from aiogram.types import FSInputFile
+
+logger = logging.getLogger(__name__)
+
+
+class FileSenderService:
+    """Sends files created by Claude back to the user's Telegram chat.
+
+    Classifies files by extension and dispatches via the appropriate
+    Telegram method (send_photo for images, send_document for others).
+    Never raises — all errors are logged internally.
+    """
+
+    def __init__(
+        self,
+        *,
+        bot: Bot,
+        enabled: bool,
+        extensions: set[str],
+        image_extensions: set[str],
+        photo_max_bytes: int = 10 * 1024 * 1024,
+        document_max_bytes: int = 50 * 1024 * 1024,
+    ) -> None:
+        self._bot = bot
+        self._enabled = enabled
+        self._extensions = extensions
+        self._image_extensions = image_extensions
+        self._photo_max_bytes = photo_max_bytes
+        self._document_max_bytes = document_max_bytes
+
+    def resolve_path(self, file_path_raw: str, cwd: str) -> Path:
+        """If file_path_raw is absolute, return as Path. Otherwise join with cwd."""
+        p = Path(file_path_raw)
+        if p.is_absolute():
+            return p
+        return Path(cwd) / p
+
+    def classify(self, filename: str) -> str | None:
+        """Return 'image', 'document', or None based on extension.
+
+        Handles .tar.gz specially by checking if filename ends with it
+        before falling back to Path.suffix.
+        """
+        if filename.lower().endswith(".tar.gz"):
+            ext = ".tar.gz"
+        else:
+            ext = Path(filename).suffix.lower()
+
+        if not ext or ext not in self._extensions:
+            return None
+
+        if ext in self._image_extensions:
+            return "image"
+        return "document"
+
+    def build_caption(self, file_path: Path, cwd: str) -> str:
+        """Return caption like '📎 filename (./relative/path)' relative to cwd."""
+        name = file_path.name
+        try:
+            rel = file_path.relative_to(cwd)
+            rel_str = f"./{rel}"
+        except ValueError:
+            rel_str = str(file_path)
+        return f"📎 {name} ({rel_str})"
+
+    async def send_if_eligible(
+        self,
+        *,
+        file_path_raw: str,
+        cwd: str,
+        chat_id: int,
+    ) -> None:
+        """Orchestrate: resolve, classify, validate, send. Never raises."""
+        try:
+            if not self._enabled:
+                return
+
+            if not file_path_raw:
+                logger.warning("file_sender: empty file_path_raw, skipping")
+                return
+
+            file_path = self.resolve_path(file_path_raw, cwd)
+            classification = self.classify(file_path.name)
+
+            if classification is None:
+                return
+
+            if not file_path.exists():
+                logger.warning("file_sender: file does not exist: %s", file_path)
+                return
+
+            try:
+                size = file_path.stat().st_size
+            except OSError as exc:
+                logger.warning("file_sender: cannot stat file %s: %s", file_path, exc)
+                return
+
+            if classification == "image" and size > self._photo_max_bytes:
+                logger.info(
+                    "file_sender: image %s exceeds photo limit (%d > %d)",
+                    file_path,
+                    size,
+                    self._photo_max_bytes,
+                )
+                return
+
+            if classification == "document" and size > self._document_max_bytes:
+                logger.info(
+                    "file_sender: document %s exceeds document limit (%d > %d)",
+                    file_path,
+                    size,
+                    self._document_max_bytes,
+                )
+                return
+
+            caption = self.build_caption(file_path, cwd)
+            input_file = FSInputFile(file_path)
+
+            if classification == "image":
+                await self._bot.send_photo(chat_id, photo=input_file, caption=caption)
+            else:
+                await self._bot.send_document(chat_id, document=input_file, caption=caption)
+
+            logger.info("file_sender: sent %s as %s to chat %d", file_path, classification, chat_id)
+
+        except Exception:
+            logger.exception("file_sender: unexpected error sending %s", file_path_raw)
diff --git a/app/services/lock_registry.py b/app/services/lock_registry.py
new file mode 100644
index 0000000..ce7e5fc
--- /dev/null
+++ b/app/services/lock_registry.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import asyncio
+from collections import deque
+from collections.abc import AsyncIterator, Callable
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
+
+
+@dataclass(slots=True)
+class _LockEntry:
+    lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+    ref_count: int = 0
+    last_used: float = 0.0
+
+
+class RefCountedLockRegistry:
+    def __init__(
+        self,
+        *,
+        ttl_sec: int,
+        cleanup_interval_sec: int,
+        cleanup_batch_size: int,
+        clock: Callable[[], float] | None = None,
+    ) -> None:
+        if ttl_sec <= 0:
+            raise ValueError("ttl_sec must be positive")
+        if cleanup_interval_sec <= 0:
+            raise ValueError("cleanup_interval_sec must be positive")
+        if cleanup_batch_size <= 0:
+            raise ValueError("cleanup_batch_size must be positive")
+        self._ttl_sec = ttl_sec
+        self._cleanup_interval_sec = cleanup_interval_sec
+        self._cleanup_batch_size = cleanup_batch_size
+        self._clock = clock
+        self._entries: dict[str, _LockEntry] = {}
+        self._cleanup_queue: deque[str] = deque()
+        self._cleanup_queued: set[str] = set()
+        self._last_cleanup_ts = 0.0
+        self._registry_lock = asyncio.Lock()
+
+    def __len__(self) -> int:
+        return len(self._entries)
+
+    @asynccontextmanager
+    async def lock(self, key: str) -> AsyncIterator[None]:
+        entry = await self._acquire_entry(key)
+        try:
+            async with entry.lock:
+                yield None
+        finally:
+            await self._release_entry(key, entry)
+
+    async def cleanup_key(self, key: str, *, require_expired: bool = True) -> None:
+        async with self._registry_lock:
+            self._cleanup_key_locked(key, now=self._now(), require_expired=require_expired)
+
+    async def cleanup_expired(self) -> None:
+        async with self._registry_lock:
+            self._cleanup_expired_locked(now=self._now())
+
+    async def clear(self) -> None:
+        async with self._registry_lock:
+            self._entries.clear()
+            self._cleanup_queue.clear()
+            self._cleanup_queued.clear()
+
+    async def _acquire_entry(self, key: str) -> _LockEntry:
+        async with self._registry_lock:
+            now = self._now()
+            self._cleanup_expired_locked(now=now)
+            entry = self._entries.get(key)
+            if entry is None:
+                entry = _LockEntry(last_used=now)
+                self._entries[key] = entry
+            entry.ref_count += 1
+            self._enqueue_locked(key)
+            return entry
+
+    async def _release_entry(self, key: str, entry: _LockEntry) -> None:
+        async with self._registry_lock:
+            current = self._entries.get(key)
+            if current is entry:
+                now = self._now()
+                entry.ref_count = max(0, entry.ref_count - 1)
+                entry.last_used = now
+                self._cleanup_key_locked(key, now=now, require_expired=True)
+                self._cleanup_expired_locked(now=now)
+
+    def _cleanup_expired_locked(self, *, now: float) -> None:
+        if now - self._last_cleanup_ts < self._cleanup_interval_sec:
+            return
+        self._last_cleanup_ts = now
+        for _ in range(min(self._cleanup_batch_size, len(self._cleanup_queue))):
+            key = self._cleanup_queue.popleft()
+            self._cleanup_queued.discard(key)
+            entry = self._entries.get(key)
+            if entry is None:
+                continue
+            if self._can_delete_entry(entry, now=now, require_expired=True):
+                self._entries.pop(key, None)
+                continue
+            self._enqueue_locked(key)
+
+    def _cleanup_key_locked(self, key: str, *, now: float, require_expired: bool) -> None:
+        entry = self._entries.get(key)
+        if entry is None:
+            self._discard_queued_locked(key)
+            return
+        if self._can_delete_entry(entry, now=now, require_expired=require_expired):
+            self._entries.pop(key, None)
+            self._discard_queued_locked(key)
+
+    def _discard_queued_locked(self, key: str) -> None:
+        self._cleanup_queued.discard(key)
+        self._cleanup_queue = deque(queued_key for queued_key in self._cleanup_queue if queued_key != key)
+
+    def _can_delete_entry(self, entry: _LockEntry, *, now: float, require_expired: bool) -> bool:
+        if entry.ref_count != 0 or entry.lock.locked():
+            return False
+        if require_expired and now - entry.last_used < self._ttl_sec:
+            return False
+        return True
+
+    def _enqueue_locked(self, key: str) -> None:
+        if key not in self._cleanup_queued:
+            self._cleanup_queue.append(key)
+            self._cleanup_queued.add(key)
+
+    def _now(self) -> float:
+        if self._clock is not None:
+            return self._clock()
+        return asyncio.get_running_loop().time()
diff --git a/app/services/permission_callback_registry.py b/app/services/permission_callback_registry.py
new file mode 100644
index 0000000..b968c72
--- /dev/null
+++ b/app/services/permission_callback_registry.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import secrets
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class _PermissionCallbackEntry:
+    tool_use_id: str
+    expires_at: float
+
+
+class PermissionCallbackRegistry:
+    def __init__(
+        self,
+        *,
+        ttl_sec: int,
+        token_factory: Callable[[], str] | None = None,
+        clock: Callable[[], float] | None = None,
+    ) -> None:
+        if ttl_sec <= 0:
+            raise ValueError("ttl_sec must be positive")
+        self._ttl_sec = ttl_sec
+        self._token_factory = token_factory or (lambda: secrets.token_urlsafe(6))
+        self._clock = clock or time.monotonic
+        self._entries: dict[str, _PermissionCallbackEntry] = {}
+
+    def register(self, tool_use_id: str) -> str:
+        self._prune_expired()
+        for _ in range(16):
+            token = self._token_factory()
+            if token not in self._entries:
+                self._entries[token] = _PermissionCallbackEntry(
+                    tool_use_id=tool_use_id,
+                    expires_at=self._clock() + self._ttl_sec,
+                )
+                return token
+        raise RuntimeError("failed to generate unique permission callback token")
+
+    def resolve(self, token: str) -> str | None:
+        self._prune_expired()
+        entry = self._entries.get(token)
+        if entry is None:
+            return None
+        return entry.tool_use_id
+
+    def _prune_expired(self) -> None:
+        now = self._clock()
+        expired = [token for token, entry in self._entries.items() if entry.expires_at <= now]
+        for token in expired:
+            self._entries.pop(token, None)
+
+    def __len__(self) -> int:
+        """Return the number of live (non-expired) entries."""
+        self._prune_expired()
+        return len(self._entries)
diff --git a/app/services/permission_service.py b/app/services/permission_service.py
index e945948..35057bd 100644
--- a/app/services/permission_service.py
+++ b/app/services/permission_service.py
@@ -1,10 +1,10 @@
 from __future__ import annotations
 
-import asyncio
 from collections.abc import Awaitable, Callable
 
 from app.adapters.claude.hook_socket_server import HookSocketServer
 from app.domain.session_models import SessionEvent, SessionEventType, SessionState
+from app.services.lock_registry import RefCountedLockRegistry
 from app.services.session_service import SessionService
 from app.services.session_store import SessionStore
 
@@ -20,13 +20,20 @@ def __init__(
         hook_socket_server: HookSocketServer | None,
         get_structured_session: Callable[..., Awaitable[SessionState | None]],
         is_state_owned_by_user: Callable[..., Awaitable[bool]],
+        permission_lock_ttl_sec: int = 600,
+        lock_cleanup_interval_sec: int = 60,
+        lock_cleanup_batch_size: int = 50,
     ) -> None:
         self._session_service = session_service
         self._structured_session_store = structured_session_store
         self._hook_socket_server = hook_socket_server
         self._get_structured_session = get_structured_session
         self._is_state_owned_by_user = is_state_owned_by_user
-        self._permission_locks: dict[str, asyncio.Lock] = {}
+        self._permission_locks = RefCountedLockRegistry(
+            ttl_sec=permission_lock_ttl_sec,
+            cleanup_interval_sec=lock_cleanup_interval_sec,
+            cleanup_batch_size=lock_cleanup_batch_size,
+        )
 
     async def respond_to_pending_permission(
         self,
@@ -54,7 +61,7 @@ async def respond_to_pending_permission(
                 return False, "当前没有待处理的权限请求"
             lock_tool_use_id = pending.tool_use_id
 
-        async with self._get_permission_lock(lock_tool_use_id):
+        async with self._permission_locks.lock(lock_tool_use_id):
             return await self._respond_to_pending_permission_locked(
                 user_id=user_id,
                 decision=decision,
@@ -63,13 +70,6 @@ async def respond_to_pending_permission(
                 lock_tool_use_id=lock_tool_use_id,
             )
 
-    def _get_permission_lock(self, tool_use_id: str) -> asyncio.Lock:
-        lock = self._permission_locks.get(tool_use_id)
-        if lock is None:
-            lock = asyncio.Lock()
-            self._permission_locks[tool_use_id] = lock
-        return lock
-
     async def _resolve_pending_permission_state(
         self,
         *,
diff --git a/app/services/session_ownership_resolver.py b/app/services/session_ownership_resolver.py
new file mode 100644
index 0000000..ea53974
--- /dev/null
+++ b/app/services/session_ownership_resolver.py
@@ -0,0 +1,76 @@
+"""Session Ownership Resolver — first gate in the hook pipeline.
+
+Determines who owns a session based on explicit ownership data only.
+No workdir heuristics for external sessions.
+"""
+
+from __future__ import annotations
+
+from app.domain.external_session_models import OwnershipResult, SessionOrigin
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.session_service import SessionService
+
+
+class SessionOwnershipResolver:
+    """Resolve ownership of a Claude session by priority chain.
+
+    Priority:
+    1. Tmux-owned: session_id matches a SessionContext.claude_session_id
+       where the context has a terminal_id (tmux-launched).
+    2. External-bound: session_id exists in ExternalBindingStore.
+    3. Unbound: none of the above.
+
+    The resolver NEVER uses workdir-based matching for sessions without
+    a terminal_id (external sessions).
+    """
+
+    def __init__(
+        self,
+        *,
+        session_service: SessionService,
+        binding_store: ExternalBindingStore,
+    ) -> None:
+        self._session_service = session_service
+        self._binding_store = binding_store
+
+    async def resolve(self, session_id: str) -> OwnershipResult:
+        """Determine ownership of a session_id.
+
+        Priority:
+        1. Explicit tmux owner: session_id matches a SessionContext.claude_session_id
+           where the context has a terminal_id (tmux-launched)
+        2. External binding: session_id exists in ExternalBindingStore
+        3. Unbound: none of the above
+
+        NO workdir-based matching is performed here for external sessions.
+        """
+        # Priority 1: Check if tmux-owned
+        all_contexts = await self._session_service.list_all()
+        for ctx in all_contexts:
+            if ctx.claude_session_id == session_id and ctx.terminal_id is not None:
+                return OwnershipResult(
+                    owner_user_id=ctx.user_id,
+                    origin=SessionOrigin.TMUX,
+                    ownership_state="owned",
+                )
+
+        # Priority 2: Check external binding
+        binding = self._binding_store.get_binding(session_id)
+        if binding is not None:
+            return OwnershipResult(
+                owner_user_id=binding.user_id,
+                origin=SessionOrigin.EXTERNAL,
+                ownership_state="bound",
+            )
+
+        # Priority 3: Unbound
+        return OwnershipResult(
+            owner_user_id=None,
+            origin=SessionOrigin.EXTERNAL,
+            ownership_state="unbound",
+        )
+
+    async def is_tmux_owned(self, session_id: str) -> bool:
+        """Quick check if session is tmux-owned (has terminal_id)."""
+        all_contexts = await self._session_service.list_all()
+        return any(ctx.claude_session_id == session_id and ctx.terminal_id is not None for ctx in all_contexts)
diff --git a/app/services/session_registry.py b/app/services/session_registry.py
index 7ce4005..0b54e7c 100644
--- a/app/services/session_registry.py
+++ b/app/services/session_registry.py
@@ -5,7 +5,8 @@
 from contextlib import suppress
 
 from app.adapters.process.tmux_runner import TmuxRunner
-from app.domain.models import SessionContext, TerminalSessionInfo
+from app.domain.models import SessionContext, TerminalSessionInfo, utc_now
+from app.services.auto_approve_service import AutoApproveService
 from app.services.session_lookup_service import SessionLookupService
 from app.services.session_service import SessionService
 from app.services.session_state_repository import SessionStateRepository
@@ -23,12 +24,14 @@ def __init__(
         lookup: SessionLookupService,
         tmux_runner: TmuxRunner,
         repository: SessionStateRepository,
+        auto_approve_service: AutoApproveService | None = None,
         health_check_interval_sec: float = 30.0,
     ) -> None:
         self._session_service = session_service
         self._lookup = lookup
         self._tmux_runner = tmux_runner
         self._repository = repository
+        self._auto_approve_service = auto_approve_service
         self._health_check_interval_sec = health_check_interval_sec
         self._health_check_task: asyncio.Task[None] | None = None
 
@@ -211,7 +214,7 @@ async def _detach_user_internal(self, user_id: int, current: SessionContext) ->
     async def validate_or_reattach(self, user_id: int) -> SessionContext | None:
         """Validate that the user's session binding is alive.
 
-        If the tmux session is dead, try to find a live session with the same terminal_id.
+        If the tmux session is dead, try to find another live session for the same user/workdir.
         Returns the (possibly updated) SessionContext, or None if no live session found.
         """
         current = await self._session_service.get(user_id)
@@ -225,30 +228,38 @@ async def validate_or_reattach(self, user_id: int) -> SessionContext | None:
         if alive:
             return current
 
-        # Tmux session is dead. Try to find a live SessionState with the same terminal_id.
+        # Tmux session is dead. Try to find another live SessionState for this user/workdir.
         logger.info(
             "tmux session dead, attempting reattach",
             extra={"user_id": user_id, "terminal_id": terminal_id},
         )
 
-        # Search persisted SessionState records
-        all_states = self._repository.list_states()
-        for state in all_states:
-            if state.terminal_id == terminal_id:
-                state_tmux = self._tmux_runner._build_session_name(state.terminal_id)
-                if await self._tmux_runner._session_exists(state_tmux):
-                    # Found a live session with matching terminal_id
-                    logger.info(
-                        "reattach: found live session",
-                        extra={"user_id": user_id, "terminal_id": terminal_id, "session_id": state.session_id},
-                    )
-                    if current.claude_session_id != state.claude_session_id:
-                        await self._session_service.bind_claude_session(
-                            user_id=user_id,
-                            claude_session_id=state.claude_session_id or state.session_id,
-                            workdir=state.workdir,
-                        )
-                    return await self._session_service.get(user_id)
+        live_states = []
+        for state in self._repository.list_states():
+            if (
+                state.user_id != user_id
+                or state.provider != current.provider
+                or state.workdir != current.workdir
+                or not state.terminal_id
+                or state.terminal_id == terminal_id
+            ):
+                continue
+            state_tmux = self._tmux_runner._build_session_name(state.terminal_id)
+            if await self._tmux_runner._session_exists(state_tmux):
+                live_states.append(state)
+
+        if live_states:
+            state = max(live_states, key=lambda candidate: (candidate.last_activity, candidate.created_at, candidate.revision))
+            logger.info(
+                "reattach: found live session",
+                extra={"user_id": user_id, "terminal_id": state.terminal_id, "session_id": state.session_id},
+            )
+            current.terminal_id = state.terminal_id
+            current.claude_session_id = state.claude_session_id or state.session_id
+            current.workdir = state.workdir
+            current.updated_at = utc_now()
+            await self._session_service.save_session_context(current)
+            return await self._session_service.get(user_id)
 
         logger.info("reattach: no live session found", extra={"user_id": user_id, "terminal_id": terminal_id})
         return None
@@ -286,15 +297,16 @@ async def _health_check_loop(self) -> None:
     async def _run_health_check(self) -> None:
         """Scan all SessionContext records, clean up stale bindings."""
         all_contexts = await self._session_service.list_all()
-        stale: list[SessionContext] = []
 
-        for ctx in all_contexts:
-            if not ctx.terminal_id:
-                continue
-            tmux_name = self._tmux_runner._build_session_name(ctx.terminal_id)
-            alive = await self._tmux_runner._session_exists(tmux_name)
-            if not alive:
-                stale.append(ctx)
+        # Filter contexts with terminal bindings and check liveness in parallel
+        contexts_with_terminals = [ctx for ctx in all_contexts if ctx.terminal_id]
+        if not contexts_with_terminals:
+            return
+
+        tmux_names = [self._tmux_runner._build_session_name(ctx.terminal_id) for ctx in contexts_with_terminals]
+        alive_results = await asyncio.gather(*(self._tmux_runner._session_exists(name) for name in tmux_names))
+
+        stale = [ctx for ctx, alive in zip(contexts_with_terminals, alive_results) if not alive]
 
         if not stale:
             return
@@ -304,6 +316,9 @@ async def _run_health_check(self) -> None:
                 "health check: cleaning stale binding",
                 extra={"user_id": ctx.user_id, "terminal_id": ctx.terminal_id},
             )
+            # Clear auto-approve state for the stale session
+            if self._auto_approve_service is not None and ctx.claude_session_id:
+                self._auto_approve_service.clear_session(ctx.claude_session_id)
             ctx.claude_session_id = None
             if not ctx.is_owner:
                 # Non-owner: fully detach
diff --git a/app/services/session_scanner.py b/app/services/session_scanner.py
new file mode 100644
index 0000000..e5c4c25
--- /dev/null
+++ b/app/services/session_scanner.py
@@ -0,0 +1,108 @@
+"""Service for discovering past Claude Code sessions from JSONL files on disk."""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+
+from app.adapters.claude.paths import ClaudePaths
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SessionInfo:
+    session_id: str
+    modified_at: datetime
+    summary: str
+
+
+class SessionScanner:
+    """Scans Claude session JSONL files for a given workdir and returns metadata."""
+
+    @staticmethod
+    def encode_workdir(workdir: str) -> str:
+        """Encode a workdir path the same way Claude CLI does: replace `/` with `-`."""
+        return workdir.replace("/", "-")
+
+    def scan(
+        self,
+        workdir: str,
+        claude_paths: ClaudePaths,
+        max_results: int = 10,
+    ) -> list[SessionInfo]:
+        """Scan for session files and return the most recent ones.
+
+        Only reads `.jsonl` files directly in the encoded workdir directory,
+        excluding any files inside a `subagents/` subdirectory.
+        """
+        encoded = self.encode_workdir(workdir)
+        session_dir = claude_paths.projects_dir / encoded
+
+        if not session_dir.is_dir():
+            return []
+
+        sessions: list[SessionInfo] = []
+
+        for path in session_dir.iterdir():
+            if not path.is_file() or path.suffix != ".jsonl":
+                continue
+
+            session_id = path.stem
+            try:
+                mtime = path.stat().st_mtime
+                modified_at = datetime.fromtimestamp(mtime, tz=timezone.utc)
+            except OSError:
+                logger.debug("Cannot stat %s, skipping", path)
+                continue
+
+            summary = self._extract_first_human_message(path)
+
+            sessions.append(
+                SessionInfo(
+                    session_id=session_id,
+                    modified_at=modified_at,
+                    summary=summary,
+                )
+            )
+
+        sessions.sort(key=lambda s: s.modified_at, reverse=True)
+        return sessions[:max_results]
+
+    def _extract_first_human_message(self, path: Path) -> str:
+        """Extract the content of the first human message from a JSONL file."""
+        try:
+            with path.open("r", encoding="utf-8", errors="replace") as fh:
+                for line in fh:
+                    line = line.strip()
+                    if not line:
+                        continue
+                    try:
+                        payload = json.loads(line)
+                    except json.JSONDecodeError:
+                        continue
+
+                    if payload.get("type") != "human":
+                        continue
+
+                    message = payload.get("message")
+                    if not isinstance(message, dict):
+                        continue
+
+                    content = message.get("content")
+                    if isinstance(content, str):
+                        return content.strip()
+
+                    if isinstance(content, list):
+                        for block in content:
+                            if isinstance(block, dict) and block.get("type") == "text":
+                                text = block.get("text", "")
+                                if isinstance(text, str) and text.strip():
+                                    return text.strip()
+        except OSError:
+            logger.debug("Cannot read %s, returning empty summary", path)
+
+        return ""
diff --git a/app/services/session_service.py b/app/services/session_service.py
index 44b41a9..ec20dcf 100644
--- a/app/services/session_service.py
+++ b/app/services/session_service.py
@@ -122,6 +122,10 @@ async def save_session_context(self, session: SessionContext) -> None:
         """Save a session context directly (for cross-user attach/detach)."""
         await self._store.save(session)
 
+    async def lookup_by_claude_session_id(self, claude_session_id: str) -> SessionContext | None:
+        """O(1) lookup by claude_session_id via the store's index."""
+        return await self._store.get_by_claude_session_id(claude_session_id)
+
     async def list_all(self) -> list[SessionContext]:
         return await self._store.list_all()
 
diff --git a/app/services/session_state_cache.py b/app/services/session_state_cache.py
index fc44453..2e97734 100644
--- a/app/services/session_state_cache.py
+++ b/app/services/session_state_cache.py
@@ -1,33 +1,17 @@
 from __future__ import annotations
 
 import logging
-import re
 
 from app.domain.hook_models import validate_session_id
 from app.domain.session_models import (
     SessionPhase,
     SessionState,
+    is_claude_session_id,
 )
 from app.services.session_state_repository import SessionStateRepository
 
 logger = logging.getLogger(__name__)
 
-CLAUDE_SESSION_PREFIX = "claude-session-"
-_UUID_SESSION_RE = re.compile(
-    r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
-    re.IGNORECASE,
-)
-
-
-def is_claude_session_id(session_id: str | None) -> bool:
-    """Check if a session_id looks like a Claude-originated session identifier."""
-    if not session_id:
-        return False
-    text = str(session_id).strip()
-    if not text:
-        return False
-    return text.startswith(CLAUDE_SESSION_PREFIX) or bool(_UUID_SESSION_RE.match(text))
-
 
 class SessionStateCache:
     """In-memory cache for SessionState objects with load-on-miss from repository.
diff --git a/app/services/session_store.py b/app/services/session_store.py
index 969a643..4e0636d 100644
--- a/app/services/session_store.py
+++ b/app/services/session_store.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import re
 from datetime import datetime
 
 from app.adapters.storage.file_session_store import FileSessionStore
@@ -9,6 +8,7 @@
     ParserCheckpoint,
     SessionPhase,
     SessionState,
+    is_claude_session_id,
 )
 from app.services.session_event_processor import SessionEventProcessor
 from app.services.session_lookup_service import SessionLookupService
@@ -18,22 +18,6 @@
 from app.services.structured_reply_tracker import StructuredReplyTracker
 
 
-CLAUDE_SESSION_PREFIX = "claude-session-"
-_UUID_SESSION_RE = re.compile(
-    r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
-    re.IGNORECASE,
-)
-
-
-def is_claude_session_id(session_id: str | None) -> bool:
-    if not session_id:
-        return False
-    text = str(session_id).strip()
-    if not text:
-        return False
-    return text.startswith(CLAUDE_SESSION_PREFIX) or bool(_UUID_SESSION_RE.match(text))
-
-
 def parse_user_question_key(question_key: str | None) -> tuple[str, int] | None:
     if not question_key:
         return None
diff --git a/app/services/task_service.py b/app/services/task_service.py
index eaef8ed..9ff0c36 100644
--- a/app/services/task_service.py
+++ b/app/services/task_service.py
@@ -21,6 +21,8 @@
     TaskStatus,
     utc_now,
 )
+from app.domain.session_models import SessionState
+from app.domain.user_question_models import UserQuestionPrompt
 from app.services.permission_service import PermissionService
 from app.services.session_service import SessionService
 from app.services.session_store import SessionStore
@@ -33,9 +35,6 @@
 if TYPE_CHECKING:
     from app.services.context_builder import ContextBuilderService
 
-    from app.domain.session_models import SessionState
-    from app.domain.user_question_models import UserQuestionPrompt
-
 logger = logging.getLogger(__name__)
 
 
@@ -50,65 +49,9 @@ class TaskService:
     """Core task orchestration service.
 
     Interaction methods (structured session, user questions, permissions) are
-    delegated to the internal TaskInteractionFacade via __getattr__.
+    explicitly forwarded to the internal TaskInteractionFacade.
     """
 
-    # Methods that are explicitly defined on this class (not delegated).
-    _OWN_ATTRS: frozenset[str] = frozenset()
-
-    if TYPE_CHECKING:
-
-        async def get_structured_session(self, user_id: int, *, log_missing: bool = True) -> SessionState | None: ...
-        async def get_structured_session_for_task(self, *, task_id: str, user_id: int, log_missing: bool = True) -> SessionState | None: ...
-        async def get_structured_session_for_scope(
-            self, *, user_id: int, task_id: str | None, log_missing: bool
-        ) -> SessionState | None: ...
-        def lookup_structured_session(
-            self,
-            *,
-            user_id: int,
-            provider: str,
-            workdir: str,
-            claude_session_id: str | None,
-            terminal_id: str | None,
-            claude_chat_active: bool,
-            log_missing: bool,
-        ) -> SessionState | None: ...
-        def is_claude_session_id(self, session_id: str | None) -> bool: ...
-        async def is_state_owned_by_user(self, *, state: SessionState | None, user_id: int) -> bool: ...
-        async def get_structured_session_cursor(self, user_id: int, *, task_id: str | None = None) -> int: ...
-        async def get_structured_session_revision(self, user_id: int) -> int: ...
-        async def get_structured_reply_cursor(self, user_id: int, *, task_id: str | None = None) -> tuple[str | None, str | None]: ...
-        async def acknowledge_structured_reply(
-            self, user_id: int, *, turn_id: str | None = None, permission_key: str | None = None, task_id: str | None = None
-        ) -> None: ...
-        async def get_structured_user_question_cursor(self, user_id: int, *, task_id: str | None = None) -> str | None: ...
-        async def acknowledge_structured_user_question(
-            self, user_id: int, *, question_key: str | None = None, task_id: str | None = None
-        ) -> None: ...
-        async def wait_for_structured_session_update(
-            self, *, user_id: int, since_cursor: int, timeout_sec: float, task_id: str | None = None
-        ) -> bool: ...
-        async def wait_for_structured_session_change(self, *, user_id: int, since_revision: int, timeout_sec: float) -> bool: ...
-        async def get_pending_user_questions(self, user_id: int) -> tuple[UserQuestionPrompt, ...]: ...
-        async def answer_pending_user_question_option(
-            self, *, user_id: int, tool_use_id: str, question_index: int, option_index: int
-        ) -> tuple[bool, str, UserQuestionPrompt | None]: ...
-        async def toggle_pending_user_question_multi_select_option(
-            self, *, user_id: int, tool_use_id: str, question_index: int, option_index: int
-        ) -> tuple[bool, str, UserQuestionPrompt | None, frozenset[int] | None]: ...
-        async def submit_pending_user_question_multi_select(
-            self, *, user_id: int, tool_use_id: str, question_index: int
-        ) -> tuple[bool, str, UserQuestionPrompt | None]: ...
-        async def answer_pending_user_question_text(self, *, user_id: int, text: str) -> tuple[bool, str, UserQuestionPrompt | None]: ...
-        def extract_user_question_prompts_for_tool_use_id(
-            self, state: SessionState | None, *, tool_use_id: str
-        ) -> tuple[UserQuestionPrompt, ...]: ...
-        def ensure_user_question_draft(self, *, user_id: int, prompts: tuple[UserQuestionPrompt, ...]) -> object: ...
-        async def respond_to_pending_permission(
-            self, *, user_id: int, decision: str, reason: str | None = None, expected_tool_use_id: str | None = None
-        ) -> tuple[bool, str]: ...
-
     def __init__(
         self,
         *,
@@ -127,6 +70,7 @@ def __init__(
         self._cli_factory = cli_factory
         self._semaphore = semaphore
         self._context_builder = context_builder
+        self._task_lifecycle_locks: dict[str, asyncio.Lock] = {}
         self._structured_session_resolver = StructuredSessionResolver(
             session_service=session_service,
             task_store=task_store,
@@ -147,6 +91,9 @@ def __init__(
             hook_socket_server=hook_socket_server,
             get_structured_session=self._structured_session_resolver.get_structured_session,
             is_state_owned_by_user=self._structured_session_resolver.is_state_owned_by_user,
+            permission_lock_ttl_sec=settings.effective_permission_lock_ttl_sec,
+            lock_cleanup_interval_sec=settings.lock_cleanup_interval_sec,
+            lock_cleanup_batch_size=settings.lock_cleanup_batch_size,
         )
         self._interaction_facade = TaskInteractionFacade(
             structured_session_resolver=self._structured_session_resolver,
@@ -160,15 +107,133 @@ def __init__(
             clear_user_questions=self._user_question_service.clear_user,
         )
 
-    def __getattr__(self, name: str) -> object:
-        """Delegate interaction methods to the facade transparently."""
-        # Avoid infinite recursion for dunder attributes and during init
-        if name.startswith("__"):
-            raise AttributeError(name)
-        facade = self.__dict__.get("_interaction_facade")
-        if facade is not None and hasattr(facade, name):
-            return getattr(facade, name)
-        raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
+    def _task_lifecycle_lock(self, task_id: str) -> asyncio.Lock:
+        lock = self._task_lifecycle_locks.get(task_id)
+        if lock is None:
+            lock = asyncio.Lock()
+            self._task_lifecycle_locks[task_id] = lock
+        return lock
+
+    def _cleanup_task_lifecycle_lock(self, task_id: str) -> None:
+        self._task_lifecycle_locks.pop(task_id, None)
+
+    # ─── Forwarded interaction methods (from TaskInteractionFacade) ────
+
+    async def get_structured_session(self, user_id: int, *, log_missing: bool = True) -> SessionState | None:
+        return await self._interaction_facade.get_structured_session(user_id, log_missing=log_missing)
+
+    async def get_structured_session_for_task(self, *, task_id: str, user_id: int, log_missing: bool = True) -> SessionState | None:
+        return await self._interaction_facade.get_structured_session_for_task(task_id=task_id, user_id=user_id, log_missing=log_missing)
+
+    async def get_structured_session_for_scope(self, *, user_id: int, task_id: str | None, log_missing: bool) -> SessionState | None:
+        return await self._interaction_facade.get_structured_session_for_scope(user_id=user_id, task_id=task_id, log_missing=log_missing)
+
+    def lookup_structured_session(
+        self,
+        *,
+        user_id: int,
+        provider: str,
+        workdir: str,
+        claude_session_id: str | None,
+        terminal_id: str | None,
+        claude_chat_active: bool,
+        log_missing: bool,
+    ) -> SessionState | None:
+        return self._interaction_facade.lookup_structured_session(
+            user_id=user_id,
+            provider=provider,
+            workdir=workdir,
+            claude_session_id=claude_session_id,
+            terminal_id=terminal_id,
+            claude_chat_active=claude_chat_active,
+            log_missing=log_missing,
+        )
+
+    def is_claude_session_id(self, session_id: str | None) -> bool:
+        return self._interaction_facade.is_claude_session_id(session_id)
+
+    async def is_state_owned_by_user(self, *, state: SessionState | None, user_id: int) -> bool:
+        return await self._interaction_facade.is_state_owned_by_user(state=state, user_id=user_id)
+
+    async def get_structured_session_cursor(self, user_id: int, *, task_id: str | None = None) -> int:
+        return await self._interaction_facade.get_structured_session_cursor(user_id, task_id=task_id)
+
+    async def get_structured_session_revision(self, user_id: int) -> int:
+        return await self._interaction_facade.get_structured_session_revision(user_id)
+
+    async def get_structured_reply_cursor(self, user_id: int, *, task_id: str | None = None) -> tuple[str | None, str | None]:
+        return await self._interaction_facade.get_structured_reply_cursor(user_id, task_id=task_id)
+
+    async def acknowledge_structured_reply(
+        self, user_id: int, *, turn_id: str | None = None, permission_key: str | None = None, task_id: str | None = None
+    ) -> None:
+        await self._interaction_facade.acknowledge_structured_reply(
+            user_id, turn_id=turn_id, permission_key=permission_key, task_id=task_id
+        )
+
+    async def get_structured_user_question_cursor(self, user_id: int, *, task_id: str | None = None) -> str | None:
+        return await self._interaction_facade.get_structured_user_question_cursor(user_id, task_id=task_id)
+
+    async def acknowledge_structured_user_question(
+        self, user_id: int, *, question_key: str | None = None, task_id: str | None = None
+    ) -> None:
+        await self._interaction_facade.acknowledge_structured_user_question(user_id, question_key=question_key, task_id=task_id)
+
+    async def wait_for_structured_session_update(
+        self, *, user_id: int, since_cursor: int, timeout_sec: float, task_id: str | None = None
+    ) -> bool:
+        return await self._interaction_facade.wait_for_structured_session_update(
+            user_id=user_id, since_cursor=since_cursor, timeout_sec=timeout_sec, task_id=task_id
+        )
+
+    async def wait_for_structured_session_change(self, *, user_id: int, since_revision: int, timeout_sec: float) -> bool:
+        return await self._interaction_facade.wait_for_structured_session_change(
+            user_id=user_id, since_revision=since_revision, timeout_sec=timeout_sec
+        )
+
+    async def get_pending_user_questions(self, user_id: int) -> tuple[UserQuestionPrompt, ...]:
+        return await self._interaction_facade.get_pending_user_questions(user_id)
+
+    async def answer_pending_user_question_option(
+        self, *, user_id: int, tool_use_id: str, question_index: int, option_index: int
+    ) -> tuple[bool, str, UserQuestionPrompt | None]:
+        return await self._interaction_facade.answer_pending_user_question_option(
+            user_id=user_id, tool_use_id=tool_use_id, question_index=question_index, option_index=option_index
+        )
+
+    async def toggle_pending_user_question_multi_select_option(
+        self, *, user_id: int, tool_use_id: str, question_index: int, option_index: int
+    ) -> tuple[bool, str, UserQuestionPrompt | None, frozenset[int] | None]:
+        return await self._interaction_facade.toggle_pending_user_question_multi_select_option(
+            user_id=user_id, tool_use_id=tool_use_id, question_index=question_index, option_index=option_index
+        )
+
+    async def submit_pending_user_question_multi_select(
+        self, *, user_id: int, tool_use_id: str, question_index: int
+    ) -> tuple[bool, str, UserQuestionPrompt | None]:
+        return await self._interaction_facade.submit_pending_user_question_multi_select(
+            user_id=user_id, tool_use_id=tool_use_id, question_index=question_index
+        )
+
+    async def answer_pending_user_question_text(self, *, user_id: int, text: str) -> tuple[bool, str, UserQuestionPrompt | None]:
+        return await self._interaction_facade.answer_pending_user_question_text(user_id=user_id, text=text)
+
+    def extract_user_question_prompts_for_tool_use_id(
+        self, state: SessionState | None, *, tool_use_id: str
+    ) -> tuple[UserQuestionPrompt, ...]:
+        return self._interaction_facade.extract_user_question_prompts_for_tool_use_id(state, tool_use_id=tool_use_id)
+
+    def ensure_user_question_draft(self, *, user_id: int, prompts: tuple[UserQuestionPrompt, ...]) -> object:
+        return self._interaction_facade.ensure_user_question_draft(user_id=user_id, prompts=prompts)
+
+    async def respond_to_pending_permission(
+        self, *, user_id: int, decision: str, reason: str | None = None, expected_tool_use_id: str | None = None
+    ) -> tuple[bool, str]:
+        return await self._interaction_facade.respond_to_pending_permission(
+            user_id=user_id, decision=decision, reason=reason, expected_tool_use_id=expected_tool_use_id
+        )
+
+    # ─── Task execution methods ────────────────────────────────────
 
     async def create_and_run(
         self,
@@ -272,9 +337,23 @@ async def event_stream() -> AsyncIterator[CLIEvent]:
                     interactive=interactive,
                     claude_session_id=session.claude_session_id,
                 ):
-                    await self._apply_event(record, event)
-                    await self._task_store.save(record)
-                    yield event
+                    should_yield = True
+                    lock = self._task_lifecycle_lock(record.task_id)
+                    async with lock:
+                        if record.is_final:
+                            logger.info(
+                                "task event ignored after final status",
+                                extra={"task_id": record.task_id, "user_id": record.user_id, "event_type": event.type.value},
+                            )
+                            should_yield = False
+                        else:
+                            await self._apply_event(record, event)
+                            await self._task_store.save(record)
+                        cleanup_lock = record.is_final
+                    if cleanup_lock:
+                        self._cleanup_task_lifecycle_lock(record.task_id)
+                    if should_yield:
+                        yield event
 
         return StartTaskResult(task=record, events=event_stream(), interactive=interactive)
 
@@ -292,6 +371,75 @@ async def cancel(self, task_id: str, user_id: int) -> bool:
             logger.info("task cancel requested", extra={"task_id": task_id, "user_id": user_id, "provider": task.provider})
         return canceled
 
+    async def mark_stream_timeout(self, task_id: str, user_id: int, *, reason: str) -> bool:
+        lock = self._task_lifecycle_lock(task_id)
+        async with lock:
+            task = await self._task_store.get(task_id)
+            if task is None or task.user_id != user_id:
+                cleanup_lock = True
+                marked = False
+            elif task.is_final:
+                cleanup_lock = True
+                marked = False
+            else:
+                await self._apply_event(task, CLIEvent(type=EventType.TIMEOUT, task_id=task_id, error=reason))
+                await self._task_store.save(task)
+                cleanup_lock = task.is_final
+                marked = True
+        if cleanup_lock:
+            self._cleanup_task_lifecycle_lock(task_id)
+        return marked
+
+    async def mark_stream_timeout_and_cancel(
+        self,
+        task_id: str,
+        user_id: int,
+        *,
+        reason: str,
+        cancel_timeout_sec: float | None = None,
+    ) -> tuple[bool, bool]:
+        provider: str | None = None
+        lock = self._task_lifecycle_lock(task_id)
+        async with lock:
+            task = await self._task_store.get(task_id)
+            if task is None or task.user_id != user_id:
+                cleanup_lock = True
+                marked = False
+            elif task.is_final:
+                cleanup_lock = True
+                marked = False
+            else:
+                provider = task.provider
+                await self._apply_event(task, CLIEvent(type=EventType.TIMEOUT, task_id=task_id, error=reason))
+                await self._task_store.save(task)
+                cleanup_lock = task.is_final
+                marked = True
+        if cleanup_lock:
+            self._cleanup_task_lifecycle_lock(task_id)
+        if not marked or provider is None:
+            return marked, False
+
+        adapter = self._cli_factory.get(provider)
+        try:
+            if cancel_timeout_sec is None:
+                canceled = await adapter.cancel(task_id)
+            else:
+                canceled = await asyncio.wait_for(adapter.cancel(task_id), timeout=cancel_timeout_sec)
+        except TimeoutError:
+            logger.error(
+                "task stream timeout adapter cancel timeout",
+                extra={"task_id": task_id, "user_id": user_id, "timeout_sec": cancel_timeout_sec},
+            )
+            return marked, False
+        except Exception:
+            logger.exception("task stream timeout adapter cancel failed", extra={"task_id": task_id, "user_id": user_id})
+            return marked, False
+        if canceled:
+            logger.info(
+                "task stream timeout adapter cancel requested", extra={"task_id": task_id, "user_id": user_id, "provider": provider}
+            )
+        return marked, canceled
+
     async def get_status(self, task_id: str, user_id: int) -> TaskRecord | None:
         task = await self._task_store.get(task_id)
         if task is None or task.user_id != user_id:
@@ -316,6 +464,9 @@ async def close_terminal(self, user_id: int) -> tuple[bool, str]:
     async def open_claude_chat_session(self, user_id: int, *, workdir: str | None = None) -> tuple[bool, str]:
         return await self._terminal_session_service.open_claude_chat_session(user_id, workdir=workdir)
 
+    async def open_claude_resume_session(self, user_id: int, *, session_id: str, workdir: str | None = None) -> tuple[bool, str]:
+        return await self._terminal_session_service.open_claude_resume_session(user_id, session_id, workdir=workdir)
+
     def is_workdir_allowed(self, workdir: str) -> bool:
         return self._is_workdir_allowed(str(Path(workdir).resolve()))
 
diff --git a/app/services/terminal_session_service.py b/app/services/terminal_session_service.py
index 1a15d3c..c5edb29 100644
--- a/app/services/terminal_session_service.py
+++ b/app/services/terminal_session_service.py
@@ -117,6 +117,64 @@ async def open_claude_chat_session(self, user_id: int, *, workdir: str | None =
             message = f"{message}\n{detail}"
         return True, message
 
+    async def open_claude_resume_session(self, user_id: int, session_id: str, *, workdir: str | None = None) -> tuple[bool, str]:
+        session = await self._session_service.get(user_id)
+        had_old_terminal = bool(session and session.terminal_mode and session.terminal_id)
+        self._clear_user_questions(user_id)
+        if session is not None:
+            await self._session_service.clear_claude_session(user_id=user_id)
+        if had_old_terminal:
+            closed, text = await self.close_terminal(user_id)
+            if not closed and text != "终端不存在或关闭失败":
+                return False, f"旧终端关闭失败: {text}"
+
+        workdir_source = workdir or (session.workdir if session else self._settings.default_workdir)
+        selected_workdir = str(Path(workdir_source).resolve())
+        if workdir is None and not Path(selected_workdir).is_dir():
+            selected_workdir = str(Path(self._settings.default_workdir).resolve())
+        if not self._is_workdir_allowed(selected_workdir):
+            raise ValueError("workdir 不在 ALLOWED_WORKDIRS 白名单内")
+        if not Path(selected_workdir).is_dir():
+            return False, f"workdir 不存在或不是目录: {selected_workdir}"
+
+        updated_session = await self._session_service.switch(
+            user_id=user_id,
+            provider="claude_code",
+            workdir=selected_workdir,
+            terminal_mode=True,
+            claude_chat_active=True,
+        )
+
+        if not updated_session.terminal_id:
+            return False, "会话创建失败: terminal_id 为空"
+
+        # Use the resume-specific ensure method
+        ensured, err = await self._cli_factory.ensure_claude_resume_session(
+            terminal_key=updated_session.terminal_id,
+            workdir=updated_session.workdir,
+            session_id=session_id,
+        )
+        if not ensured:
+            await self._session_service.switch(user_id=user_id, terminal_mode=False, claude_chat_active=False)
+            return False, err
+
+        # Reveal the terminal
+        revealed, reveal_text = await self._cli_factory.reveal_terminal(updated_session.terminal_id)
+
+        # Bind the claude_session_id to the resumed session
+        await self._session_service.bind_claude_session(
+            user_id=user_id,
+            claude_session_id=session_id,
+            workdir=selected_workdir,
+        )
+
+        if not revealed:
+            return True, f"Claude 会话已恢复\n未能自动打开桌面终端: {reveal_text}"
+        message = "Claude 会话已恢复"
+        if reveal_text:
+            message = f"{message}\n{reveal_text}"
+        return True, message
+
     async def bind_claude_session(self, *, user_id: int, claude_session_id: str, workdir: str | None = None) -> None:
         await self._session_service.bind_claude_session(
             user_id=user_id,
diff --git a/app/services/unbound_permission_handler.py b/app/services/unbound_permission_handler.py
new file mode 100644
index 0000000..3d45d52
--- /dev/null
+++ b/app/services/unbound_permission_handler.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+
+from app.domain.external_session_models import UnboundPermissionState
+from app.domain.hook_models import HookEvent
+from app.domain.models import utc_now
+
+
+def _escape_html(text: str) -> str:
+    """Escape HTML special characters for Telegram HTML parse_mode."""
+    return text.replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+if TYPE_CHECKING:
+    from aiogram import Bot
+
+    from app.adapters.claude.hook_socket_server import HookSocketServer
+    from app.services.permission_callback_registry import PermissionCallbackRegistry
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class UnboundPermissionResponseResult:
+    accepted: bool
+    forwarded: bool
+
+
+class UnboundPermissionHandler:
+    """Handles permission requests from unbound sessions.
+
+    Broadcasts to all allowed users, first-responder-wins semantics,
+    auto-deny on TTL expiry.
+    """
+
+    def __init__(
+        self,
+        *,
+        bot: Bot,
+        hook_socket_server: HookSocketServer,
+        allowed_user_ids: set[int],
+        permission_callback_registry: PermissionCallbackRegistry,
+        permission_ttl_sec: int = 600,
+        title_resolver: Callable[[str, str], str | None] | None = None,
+    ) -> None:
+        self._bot = bot
+        self._hook_socket_server = hook_socket_server
+        self._allowed_user_ids = allowed_user_ids
+        self._permission_callback_registry = permission_callback_registry
+        self._permission_ttl_sec = permission_ttl_sec
+        self._title_resolver = title_resolver
+        self._pending: dict[str, UnboundPermissionState] = {}
+        self._expiry_tasks: dict[str, asyncio.Task[None]] = {}
+        self._state_lock = asyncio.Lock()
+
+    async def handle_unbound_permission(self, event: HookEvent) -> None:
+        """Broadcast permission request to all allowed users.
+
+        Steps:
+        1. Send message to each user in allowed_user_ids with session_id, cwd, tool info
+        2. Track the pending state
+        3. Schedule TTL expiry task
+        """
+        tool_use_id = event.tool_use_id or ""
+        if not tool_use_id:
+            logger.warning(
+                "unbound permission event missing tool_use_id, ignoring",
+                extra={"session_id": event.session_id},
+            )
+            return
+
+        notified_user_ids: list[int] = []
+        message_text = self._format_permission_message(event)
+        keyboard = self._build_permission_keyboard(tool_use_id)
+
+        for user_id in self._allowed_user_ids:
+            try:
+                await self._bot.send_message(chat_id=user_id, text=message_text, reply_markup=keyboard, parse_mode="HTML")
+                notified_user_ids.append(user_id)
+            except Exception:
+                logger.warning(
+                    "failed to send unbound permission notification",
+                    extra={"user_id": user_id, "tool_use_id": tool_use_id},
+                )
+
+        async with self._state_lock:
+            state = UnboundPermissionState(
+                session_id=event.session_id,
+                tool_use_id=tool_use_id,
+                notified_user_ids=notified_user_ids,
+                responded=False,
+                responded_by=None,
+                created_at=utc_now(),
+            )
+            self._pending[tool_use_id] = state
+
+            # Schedule expiry
+            self._cancel_expiry_task(tool_use_id)
+            self._expiry_tasks[tool_use_id] = asyncio.create_task(self._expire_permission(tool_use_id))
+
+        logger.info(
+            "unbound permission broadcast sent",
+            extra={
+                "tool_use_id": tool_use_id,
+                "session_id": event.session_id,
+                "notified_count": len(notified_user_ids),
+            },
+        )
+
+    async def handle_response(self, *, tool_use_id: str, user_id: int, decision: str) -> UnboundPermissionResponseResult:
+        """Process a response from a user.
+
+        Uses a short critical section to pop pending and cancel expiry,
+        then forwards the decision outside the lock.
+        """
+        forwarded = False
+        async with self._state_lock:
+            state = self._pending.pop(tool_use_id, None)
+            if state is None or state.responded:
+                # Put it back if it was there but already responded
+                if state is not None and state.responded:
+                    self._pending[tool_use_id] = state
+                return UnboundPermissionResponseResult(accepted=False, forwarded=False)
+
+            state.responded = True
+            state.responded_by = user_id
+
+            # Cancel expiry task since we have a response
+            self._cancel_expiry_task(tool_use_id)
+
+        # Forward decision outside lock
+        try:
+            await self._hook_socket_server.respond_to_permission(
+                tool_use_id=tool_use_id,
+                decision=decision,
+                reason=f"responded by user {user_id}",
+            )
+            forwarded = True
+        except Exception:
+            logger.warning(
+                "failed to forward unbound permission decision",
+                extra={"tool_use_id": tool_use_id, "user_id": user_id, "decision": decision},
+            )
+
+        if not forwarded:
+            logger.warning(
+                "unbound permission decision not forwarded",
+                extra={"tool_use_id": tool_use_id, "user_id": user_id, "decision": decision},
+            )
+
+        logger.info(
+            "unbound permission responded",
+            extra={
+                "tool_use_id": tool_use_id,
+                "user_id": user_id,
+                "decision": decision,
+                "session_id": state.session_id,
+                "forwarded": forwarded,
+            },
+        )
+        return UnboundPermissionResponseResult(accepted=True, forwarded=forwarded)
+
+    async def _expire_permission(self, tool_use_id: str) -> None:
+        """Auto-deny on TTL expiry if no response received."""
+        try:
+            await asyncio.sleep(self._permission_ttl_sec)
+        except asyncio.CancelledError:
+            return
+
+        session_id: str | None = None
+        async with self._state_lock:
+            state = self._pending.pop(tool_use_id, None)
+            self._expiry_tasks.pop(tool_use_id, None)
+            if state is None or state.responded:
+                return
+            session_id = state.session_id
+
+        # Auto-deny outside lock
+        await self._hook_socket_server.respond_to_permission(
+            tool_use_id=tool_use_id,
+            decision="deny",
+            reason="no user responded within TTL",
+        )
+
+        logger.info(
+            "unbound permission expired, auto-denied",
+            extra={"tool_use_id": tool_use_id, "session_id": session_id},
+        )
+
+    def is_unbound_permission(self, tool_use_id: str) -> bool:
+        """Check if a tool_use_id belongs to an unbound permission request."""
+        return tool_use_id in self._pending
+
+    def get_session_id(self, tool_use_id: str) -> str | None:
+        """Get the session_id for an unbound permission request."""
+        state = self._pending.get(tool_use_id)
+        return state.session_id if state is not None else None
+
+    def _build_permission_keyboard(self, tool_use_id: str) -> InlineKeyboardMarkup:
+        """Build inline keyboard with approve, deny, and auto-approve buttons.
+
+        Uses the permission callback registry to generate short tokens.
+        """
+        token = self._permission_callback_registry.register(tool_use_id)
+
+        return InlineKeyboardMarkup(
+            inline_keyboard=[
+                [
+                    InlineKeyboardButton(text="✅ Approve", callback_data=f"ext_perm:{token}:allow"),
+                    InlineKeyboardButton(text="❌ Deny", callback_data=f"ext_perm:{token}:deny"),
+                ],
+                [
+                    InlineKeyboardButton(text="🟢 Auto-approve All", callback_data=f"ext_perm:{token}:auto_approve"),
+                ],
+            ]
+        )
+
+    def _format_permission_message(self, event: HookEvent) -> str:
+        """Format a human-readable permission request message with code block for commands."""
+        tool_name = event.tool or "unknown tool"
+        cwd = event.cwd
+        session_id = event.session_id
+        short_id = session_id[:8]
+
+        # Resolve session title
+        title: str | None = None
+        if self._title_resolver is not None:
+            try:
+                title = self._title_resolver(session_id, cwd)
+            except Exception:
+                pass
+
+        lines = [f"🔐 [{title or short_id}] 请求权限: {_escape_html(tool_name)}"]
+
+        if event.tool_input:
+            command = event.tool_input.get("command")
+            file_path = event.tool_input.get("file_path") or event.tool_input.get("path")
+            description = event.tool_input.get("description")
+            if command:
+                cmd_display = command if len(command) <= 300 else command[:300] + "..."
+                lines.append(f"\n{_escape_html(cmd_display)}")
+            elif file_path:
+                lines.append(f"\n{_escape_html(file_path)}")
+            if description:
+                desc_display = description if len(description) <= 150 else description[:150] + "..."
+                lines.append(f"📝 {_escape_html(desc_display)}")
+
+        lines.append(f"📂 {_escape_html(cwd)}")
+
+        return "\n".join(lines)
+
+    def _cancel_expiry_task(self, tool_use_id: str) -> None:
+        """Cancel an existing expiry task for the given tool_use_id."""
+        task = self._expiry_tasks.pop(tool_use_id, None)
+        if task is not None:
+            task.cancel()
diff --git a/app/services/upload_queue.py b/app/services/upload_queue.py
new file mode 100644
index 0000000..1be6be7
--- /dev/null
+++ b/app/services/upload_queue.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import asyncio
+import time
+from collections import deque
+from collections.abc import Callable
+from contextlib import suppress
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class QueuedUpload:
+    filename: str
+    data: bytes
+    size_bytes: int
+    created_at: float
+
+
+@dataclass(frozen=True, slots=True)
+class UploadQueueEnqueueResult:
+    accepted: bool
+    reason: str = ""
+
+
+class UploadQueueManager:
+    def __init__(
+        self,
+        *,
+        max_files_per_user: int,
+        max_bytes_per_user: int,
+        ttl_sec: float = 3600.0,
+        cleanup_interval_sec: float = 60.0,
+        clock: Callable[[], float] = time.monotonic,
+    ) -> None:
+        if max_files_per_user < 0:
+            raise ValueError("max_files_per_user must be non-negative")
+        if max_bytes_per_user < 0:
+            raise ValueError("max_bytes_per_user must be non-negative")
+        if ttl_sec < 0:
+            raise ValueError("ttl_sec must be non-negative")
+        if cleanup_interval_sec <= 0:
+            raise ValueError("cleanup_interval_sec must be positive")
+        self._max_files_per_user = max_files_per_user
+        self._max_bytes_per_user = max_bytes_per_user
+        self._ttl_sec = ttl_sec
+        self._cleanup_interval_sec = cleanup_interval_sec
+        self._clock = clock
+        self._queues: dict[int, deque[QueuedUpload]] = {}
+        self._byte_totals: dict[int, int] = {}
+        self._lock = asyncio.Lock()
+        self._cleanup_task: asyncio.Task[None] | None = None
+
+    def _prune_expired_locked(self, user_id: int) -> int:
+        queue = self._queues.get(user_id)
+        if queue is None:
+            return 0
+
+        expires_before = self._clock() - self._ttl_sec
+        removed_count = 0
+        removed_bytes = 0
+        while queue and queue[0].created_at <= expires_before:
+            removed_count += 1
+            removed_bytes += queue.popleft().size_bytes
+
+        if queue:
+            self._byte_totals[user_id] = max(0, self._byte_totals.get(user_id, 0) - removed_bytes)
+        else:
+            self._queues.pop(user_id, None)
+            self._byte_totals.pop(user_id, None)
+        return removed_count
+
+    async def prune_expired(self) -> int:
+        async with self._lock:
+            expired = 0
+            for user_id in list(self._queues):
+                expired += self._prune_expired_locked(user_id)
+            return expired
+
+    async def start_cleanup(self) -> None:
+        if self._cleanup_task is not None and not self._cleanup_task.done():
+            return
+        self._cleanup_task = asyncio.create_task(self._cleanup_loop())
+
+    async def stop_cleanup(self) -> None:
+        task = self._cleanup_task
+        self._cleanup_task = None
+        if task is None:
+            return
+        task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+    async def _cleanup_loop(self) -> None:
+        while True:
+            await asyncio.sleep(self._cleanup_interval_sec)
+            await self.prune_expired()
+
+    async def enqueue(self, *, user_id: int, filename: str, data: bytes) -> UploadQueueEnqueueResult:
+        size_bytes = len(data)
+        async with self._lock:
+            self._prune_expired_locked(user_id)
+            if self._max_files_per_user == 0:
+                return UploadQueueEnqueueResult(False, "上传队列已关闭,请等待当前任务完成后重新上传。")
+
+            queue = self._queues.get(user_id)
+            queued_files = len(queue) if queue is not None else 0
+            if queued_files >= self._max_files_per_user:
+                return UploadQueueEnqueueResult(False, f"队列已满,最多允许排队 {self._max_files_per_user} 个文件。")
+
+            current_total = self._byte_totals.get(user_id, 0)
+            if current_total + size_bytes > self._max_bytes_per_user:
+                return UploadQueueEnqueueResult(
+                    False,
+                    f"队列容量不足,当前排队 {current_total} 字节,本文件 {size_bytes} 字节,上限 {self._max_bytes_per_user} 字节。",
+                )
+
+            if queue is None:
+                queue = deque()
+                self._queues[user_id] = queue
+            queue.append(QueuedUpload(filename=filename, data=data, size_bytes=size_bytes, created_at=self._clock()))
+            self._byte_totals[user_id] = current_total + size_bytes
+            return UploadQueueEnqueueResult(True)
+
+    async def drain(self, *, user_id: int) -> list[QueuedUpload]:
+        async with self._lock:
+            self._prune_expired_locked(user_id)
+            queue = self._queues.pop(user_id, deque())
+            self._byte_totals.pop(user_id, None)
+            return list(queue)
+
+    async def queued_count(self, *, user_id: int) -> int:
+        async with self._lock:
+            self._prune_expired_locked(user_id)
+            return len(self._queues.get(user_id, ()))
diff --git a/app/services/upload_queue_manager.py b/app/services/upload_queue_manager.py
new file mode 100644
index 0000000..00cd375
--- /dev/null
+++ b/app/services/upload_queue_manager.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass
+
+
+@dataclass(slots=True)
+class QueuedUpload:
+    filename: str
+    data: bytes
+    size: int
+
+
+class UploadQueueManager:
+    """Per-user FIFO upload queue with count and byte limits.
+
+    All state is in-memory only; queued uploads are lost on restart.
+    """
+
+    def __init__(self, *, max_files_per_user: int, max_bytes_per_user: int) -> None:
+        self._max_files = max_files_per_user
+        self._max_bytes = max_bytes_per_user
+        self._queues: dict[int, deque[QueuedUpload]] = {}
+        self._byte_totals: dict[int, int] = {}
+
+    def enqueue(self, user_id: int, filename: str, data: bytes, size: int) -> tuple[bool, str]:
+        """Attempt to enqueue a file for the given user.
+
+        Returns (True, message) on success or (False, reason) on rejection.
+        """
+        if self._max_files <= 0:
+            return (False, "queuing disabled")
+
+        queue = self._queues.get(user_id)
+        current_count = len(queue) if queue else 0
+        current_bytes = self._byte_totals.get(user_id, 0)
+
+        if current_count >= self._max_files:
+            return (False, f"queue full ({self._max_files} files)")
+
+        if current_bytes + size > self._max_bytes:
+            return (False, "queue byte limit exceeded")
+
+        if queue is None:
+            queue = deque()
+            self._queues[user_id] = queue
+
+        queue.append(QueuedUpload(filename=filename, data=data, size=size))
+        self._byte_totals[user_id] = current_bytes + size
+        return (True, "queued")
+
+    def drain(self, user_id: int) -> list[QueuedUpload]:
+        """Pop all queued entries for the user and return them in FIFO order."""
+        queue = self._queues.pop(user_id, None)
+        self._byte_totals.pop(user_id, None)
+        if queue is None:
+            return []
+        return list(queue)
+
+    def is_full(self, user_id: int) -> bool:
+        """Check whether the user's queue is at its file count limit."""
+        if self._max_files <= 0:
+            return True
+        queue = self._queues.get(user_id)
+        if queue is None:
+            return False
+        return len(queue) >= self._max_files
diff --git a/deploy/env/.env.example b/deploy/env/.env.example
index 05aa44f..cddf63f 100644
--- a/deploy/env/.env.example
+++ b/deploy/env/.env.example
@@ -35,9 +35,28 @@ ALLOWED_WORKDIRS=/opt/tg-cli-gateway/workdir
 # Rate limit
 RATE_LIMIT_MAX_REQUESTS=6
 RATE_LIMIT_WINDOW_SEC=20
+RATE_LIMIT_BUCKET_TTL_SEC=
+RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60
+RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50
+
+# Task store
+TASK_STORE_TTL_HOURS=168
+TASK_STORE_MAX_RECORDS=1000
+
+# Lock
+PERMISSION_LOCK_TTL_SEC=
+SESSION_LOCK_TTL_SEC=3600
+LOCK_CLEANUP_INTERVAL_SEC=60
+LOCK_CLEANUP_BATCH_SIZE=50
 
 # Message output
 CHUNK_SIZE=3800
 CHUNK_FLUSH_INTERVAL_SEC=1.0
 TASK_OUTPUT_CHAR_LIMIT=120000
 
+# File upload
+UPLOAD_MAX_FILE_SIZE_MB=20
+UPLOAD_QUEUE_MAX_FILES_PER_USER=5
+# Blank means UPLOAD_QUEUE_MAX_FILES_PER_USER * UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
+UPLOAD_QUEUE_MAX_BYTES_PER_USER=
+
diff --git a/docs/superpowers/plans/2026-05-23-memory-ttl-limits.md b/docs/superpowers/plans/2026-05-23-memory-ttl-limits.md
new file mode 100644
index 0000000..bef8394
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-23-memory-ttl-limits.md
@@ -0,0 +1,1543 @@
+# Memory TTL Limits Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为长期运行的内存结构增加可配置 TTL、容量上限和有界懒清理,避免任务记录、限流桶、权限锁、JSONL sync 锁、session event 锁无限增长。
+
+**Architecture:** 增加一个通用 `RefCountedLockRegistry` 负责 `asyncio.Lock + ref_count + last_used + 有界清理队列`。任务记录和限流桶在各自组件内部做懒清理;配置通过 `Settings` 注入,`AppContainer` 负责把配置传给 store、middleware 和 registry 使用方。
+
+**Tech Stack:** Python 3.11, asyncio, aiogram middleware, pydantic-settings, pytest, pytest-asyncio.
+
+---
+
+## 执行安全约束
+
+- 当前工作区已有无关变更:`app/bootstrap_mixins.py` 和 `_test_task21.py`。不要提交 `_test_task21.py`。如果在当前工作区执行本计划,修改 `app/bootstrap_mixins.py` 前先检查现有 diff,必须保留用户原有改动;更推荐在隔离 worktree 中执行。
+- 不使用 `git add .` 或 `git add -A`。每次提交只添加本任务列出的文件。
+- 运行 Python/pytest 前按用户全局要求确认 pyenv/pyenv-virtualenv 环境。若当前项目未绑定 pyenv virtualenv,先停止并按 pyenv-virtualenv 流程创建和绑定,不使用 venv、conda、poetry 自建环境。
+- 修改非代码文件前已经存在备份约束:本计划会修改 `.env.example` 和新增计划外实现文件;执行时如改已有非代码文件,先备份到 `.claude/backups/`,同一文件最多一个备份。
+
+## 文件结构
+
+- Create: `app/services/lock_registry.py`  
+  通用引用计数异步锁注册表,供权限、JSONL sync、session event dispatch 三处复用。
+- Create: `tests/test_lock_registry.py`  
+  覆盖 registry 串行化、TTL 清理、重建重新入队、批量清理上限、实例状态独立。
+- Create: `tests/test_memory_task_store.py`  
+  覆盖任务记录 TTL、容量、运行中任务保护、10,000 条压力路径、并发访问。
+- Modify: `app/adapters/storage/memory.py`  
+  `MemoryTaskStore` 增加 TTL 和 max records 淘汰。
+- Modify: `app/bot/middleware/rate_limit.py`  
+  限流桶增加当前桶清理、有界全局清理队列和 cleanup interval。
+- Modify: `tests/test_auth_settings.py`  
+  增加 settings 默认值/派生值校验,以及 rate limit cleanup 测试。
+- Modify: `app/config/settings.py`  
+  增加新配置字段、正数校验、派生属性。
+- Modify: `deploy/env/.env.example`  
+  补充新配置默认值。
+- Modify: `app/services/permission_service.py`  
+  用 `RefCountedLockRegistry` 替换 `_permission_locks` dict。
+- Modify: `app/services/task_service.py`  
+  将 settings 中的 permission lock 清理配置传给 `PermissionService`。
+- Modify: `app/bootstrap.py`  
+  将 task store、rate limit、JSONL sync registry、session event registry 接入配置。
+- Modify: `app/bootstrap_base.py`  
+  更新 `_jsonl_sync_locks` / `_session_event_locks` 类型声明。
+- Modify: `app/bootstrap_mixins.py`  
+  用 registry 的 async context manager 替换 dict lock 访问;stop 时调用 registry `clear()`。
+- Modify: `tests/test_bootstrap_hooks.py`  
+  更新已有 JSONL lock 测试,并补充 session event lock 清理验证。
+
+---
+
+### Task 1: RefCountedLockRegistry
+
+**Files:**
+- Create: `tests/test_lock_registry.py`
+- Create: `app/services/lock_registry.py`
+
+- [ ] **Step 1: Write failing registry tests**
+
+Create `tests/test_lock_registry.py` with:
+
+```python
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from app.services.lock_registry import RefCountedLockRegistry
+
+
+@pytest.mark.asyncio
+async def test_registry_serializes_same_key() -> None:
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=60, cleanup_batch_size=50)
+    entered: list[str] = []
+    release_first = asyncio.Event()
+    first_entered = asyncio.Event()
+
+    async def worker(name: str) -> None:
+        async with registry.lock("tool-1"):
+            entered.append(name)
+            if name == "first":
+                first_entered.set()
+                await release_first.wait()
+
+    first = asyncio.create_task(worker("first"))
+    await first_entered.wait()
+    second = asyncio.create_task(worker("second"))
+    await asyncio.sleep(0)
+
+    assert entered == ["first"]
+
+    release_first.set()
+    await first
+    await second
+
+    assert entered == ["first", "second"]
+
+
+@pytest.mark.asyncio
+async def test_registry_keeps_referenced_key_during_cleanup() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock)
+    lock_cm = registry.lock("tool-1")
+    entered = await lock_cm.__aenter__()
+    assert entered is None
+
+    now = 200.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 1
+
+    await lock_cm.__aexit__(None, None, None)
+    await registry.cleanup_expired()
+
+    assert len(registry) == 0
+
+
+@pytest.mark.asyncio
+async def test_registry_requeues_key_after_delete_and_recreate() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock)
+
+    async with registry.lock("tool-1"):
+        pass
+
+    now = 200.0
+    await registry.cleanup_expired()
+    assert len(registry) == 0
+    assert registry.queued_count == 0
+
+    async with registry.lock("tool-1"):
+        pass
+
+    assert len(registry) == 1
+    assert registry.queued_count == 1
+
+
+@pytest.mark.asyncio
+async def test_registry_cleanup_batch_size_limits_work_per_pass() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=2, clock=clock)
+    for key in ("a", "b", "c"):
+        async with registry.lock(key):
+            pass
+
+    now = 200.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 1
+    assert registry.queued_count == 1
+
+    now = 202.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 0
+    assert registry.queued_count == 0
+
+
+@pytest.mark.asyncio
+async def test_registry_instances_keep_cleanup_state_independent() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    first = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock)
+    second = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock)
+
+    async with first.lock("a"):
+        pass
+    async with second.lock("b"):
+        pass
+
+    now = 200.0
+    await first.cleanup_expired()
+
+    assert len(first) == 0
+    assert len(second) == 1
+
+    await second.cleanup_expired()
+    assert len(second) == 0
+```
+
+- [ ] **Step 2: Run registry tests and verify they fail**
+
+Run:
+
+```bash
+pytest -q tests/test_lock_registry.py
+```
+
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.lock_registry'`.
+
+- [ ] **Step 3: Implement RefCountedLockRegistry**
+
+Create `app/services/lock_registry.py` with:
+
+```python
+from __future__ import annotations
+
+import asyncio
+from collections import deque
+from collections.abc import AsyncIterator, Callable
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
+
+
+@dataclass(slots=True)
+class _LockEntry:
+    lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+    ref_count: int = 0
+    last_used: float = 0.0
+
+
+class RefCountedLockRegistry:
+    def __init__(
+        self,
+        *,
+        ttl_sec: int,
+        cleanup_interval_sec: int,
+        cleanup_batch_size: int,
+        clock: Callable[[], float] | None = None,
+    ) -> None:
+        if ttl_sec <= 0:
+            raise ValueError("ttl_sec must be positive")
+        if cleanup_interval_sec <= 0:
+            raise ValueError("cleanup_interval_sec must be positive")
+        if cleanup_batch_size <= 0:
+            raise ValueError("cleanup_batch_size must be positive")
+        self._ttl_sec = ttl_sec
+        self._cleanup_interval_sec = cleanup_interval_sec
+        self._cleanup_batch_size = cleanup_batch_size
+        self._clock = clock
+        self._entries: dict[str, _LockEntry] = {}
+        self._cleanup_queue: deque[str] = deque()
+        self._cleanup_queued: set[str] = set()
+        self._last_cleanup_ts = 0.0
+        self._registry_lock = asyncio.Lock()
+
+    def __len__(self) -> int:
+        return len(self._entries)
+
+    @property
+    def queued_count(self) -> int:
+        return len(self._cleanup_queued)
+
+    @asynccontextmanager
+    async def lock(self, key: str) -> AsyncIterator[None]:
+        entry = await self._acquire_entry(key)
+        try:
+            async with entry.lock:
+                yield None
+        finally:
+            await self._release_entry(key, entry)
+
+    async def cleanup_key(self, key: str, *, require_expired: bool = True) -> None:
+        async with self._registry_lock:
+            self._cleanup_key_locked(key, now=self._now(), require_expired=require_expired)
+
+    async def cleanup_expired(self) -> None:
+        async with self._registry_lock:
+            now = self._now()
+            if now - self._last_cleanup_ts < self._cleanup_interval_sec:
+                return
+            self._last_cleanup_ts = now
+            for _ in range(min(self._cleanup_batch_size, len(self._cleanup_queue))):
+                key = self._cleanup_queue.popleft()
+                self._cleanup_queued.discard(key)
+                entry = self._entries.get(key)
+                if entry is None:
+                    continue
+                if self._can_delete_entry(entry, now=now, require_expired=True):
+                    self._entries.pop(key, None)
+                    continue
+                self._enqueue_locked(key)
+
+    async def clear(self) -> None:
+        async with self._registry_lock:
+            self._entries.clear()
+            self._cleanup_queue.clear()
+            self._cleanup_queued.clear()
+
+    async def _acquire_entry(self, key: str) -> _LockEntry:
+        async with self._registry_lock:
+            now = self._now()
+            entry = self._entries.get(key)
+            if entry is None:
+                entry = _LockEntry(last_used=now)
+                self._entries[key] = entry
+            entry.ref_count += 1
+            self._enqueue_locked(key)
+            return entry
+
+    async def _release_entry(self, key: str, entry: _LockEntry) -> None:
+        async with self._registry_lock:
+            current = self._entries.get(key)
+            if current is entry:
+                entry.ref_count = max(0, entry.ref_count - 1)
+                entry.last_used = self._now()
+                self._cleanup_key_locked(key, now=entry.last_used, require_expired=True)
+        await self.cleanup_expired()
+
+    def _cleanup_key_locked(self, key: str, *, now: float, require_expired: bool) -> None:
+        entry = self._entries.get(key)
+        if entry is None:
+            self._cleanup_queued.discard(key)
+            return
+        if self._can_delete_entry(entry, now=now, require_expired=require_expired):
+            self._entries.pop(key, None)
+            self._cleanup_queued.discard(key)
+
+    def _can_delete_entry(self, entry: _LockEntry, *, now: float, require_expired: bool) -> bool:
+        if entry.ref_count != 0 or entry.lock.locked():
+            return False
+        if require_expired and now - entry.last_used < self._ttl_sec:
+            return False
+        return True
+
+    def _enqueue_locked(self, key: str) -> None:
+        if key not in self._cleanup_queued:
+            self._cleanup_queue.append(key)
+            self._cleanup_queued.add(key)
+
+    def _now(self) -> float:
+        if self._clock is not None:
+            return self._clock()
+        return asyncio.get_running_loop().time()
+```
+
+- [ ] **Step 4: Run registry tests and verify they pass**
+
+Run:
+
+```bash
+pytest -q tests/test_lock_registry.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit registry**
+
+Run:
+
+```bash
+git add app/services/lock_registry.py tests/test_lock_registry.py
+git commit -m "$(cat <<'EOF'
+feat: add ref-counted lock registry
+EOF
+)"
+```
+
+---
+
+### Task 2: MemoryTaskStore TTL and capacity eviction
+
+**Files:**
+- Create: `tests/test_memory_task_store.py`
+- Modify: `app/adapters/storage/memory.py`
+
+- [ ] **Step 1: Write failing MemoryTaskStore tests**
+
+Create `tests/test_memory_task_store.py` with:
+
+```python
+from __future__ import annotations
+
+import asyncio
+from datetime import timedelta
+
+import pytest
+
+from app.adapters.storage.memory import MemoryTaskStore
+from app.domain.models import TaskRecord, TaskStatus, utc_now
+
+
+def _task(task_id: str, *, status: TaskStatus, created_offset_hours: int = 0, ended_offset_hours: int | None = None) -> TaskRecord:
+    now = utc_now()
+    created_at = now + timedelta(hours=created_offset_hours)
+    ended_at = None if ended_offset_hours is None else now + timedelta(hours=ended_offset_hours)
+    return TaskRecord(
+        task_id=task_id,
+        session_id=f"session-{task_id}",
+        user_id=1,
+        provider="claude_code",
+        prompt="hi",
+        workdir="/tmp",
+        timeout_sec=10,
+        status=status,
+        created_at=created_at,
+        ended_at=ended_at,
+    )
+
+
+@pytest.mark.asyncio
+async def test_memory_task_store_evicts_expired_final_task_by_ended_at() -> None:
+    store = MemoryTaskStore(max_records=100, ttl_hours=24)
+    await store.add(_task("old", status=TaskStatus.SUCCEEDED, created_offset_hours=-200, ended_offset_hours=-25))
+    await store.add(_task("new", status=TaskStatus.SUCCEEDED, created_offset_hours=-200, ended_offset_hours=-23))
+
+    assert await store.get("old") is None
+    assert await store.get("new") is not None
+
+
+@pytest.mark.asyncio
+async def test_memory_task_store_falls_back_to_created_at_when_final_task_has_no_ended_at() -> None:
+    store = MemoryTaskStore(max_records=100, ttl_hours=24)
+    await store.add(_task("old", status=TaskStatus.FAILED, created_offset_hours=-25, ended_offset_hours=None))
+
+    assert await store.get("old") is None
+
+
+@pytest.mark.asyncio
+async def test_memory_task_store_never_evicts_running_task_for_ttl_or_capacity() -> None:
+    store = MemoryTaskStore(max_records=1, ttl_hours=1)
+    await store.add(_task("running", status=TaskStatus.RUNNING, created_offset_hours=-100, ended_offset_hours=None))
+    await store.add(_task("final", status=TaskStatus.SUCCEEDED, created_offset_hours=-1, ended_offset_hours=0))
+
+    assert await store.get("running") is not None
+    assert await store.get("final") is None
+
+
+@pytest.mark.asyncio
+async def test_memory_task_store_evicts_oldest_final_tasks_when_over_capacity() -> None:
+    store = MemoryTaskStore(max_records=2, ttl_hours=168)
+    await store.add(_task("old", status=TaskStatus.SUCCEEDED, created_offset_hours=-5, ended_offset_hours=-5))
+    await store.add(_task("middle", status=TaskStatus.SUCCEEDED, created_offset_hours=-4, ended_offset_hours=-4))
+    await store.add(_task("new", status=TaskStatus.SUCCEEDED, created_offset_hours=-3, ended_offset_hours=-3))
+
+    ids = [task.task_id for task in await store.list_by_user(user_id=1, limit=10)]
+
+    assert ids == ["new", "middle"]
+
+
+@pytest.mark.asyncio
+async def test_memory_task_store_large_eviction_path_keeps_latest_records() -> None:
+    store = MemoryTaskStore(max_records=1000, ttl_hours=168)
+    now = utc_now()
+    for i in range(10_000):
+        await store.add(
+            TaskRecord(
+                task_id=f"task-{i}",
+                session_id=f"session-{i}",
+                user_id=1,
+                provider="claude_code",
+                prompt="hi",
+                workdir="/tmp",
+                timeout_sec=10,
+                status=TaskStatus.SUCCEEDED,
+                created_at=now + timedelta(seconds=i),
+                ended_at=now + timedelta(seconds=i),
+            )
+        )
+
+    records = await store.list_by_user(user_id=1, limit=2000)
+
+    assert len(records) == 1000
+    assert records[0].task_id == "task-9999"
+    assert records[-1].task_id == "task-9000"
+
+
+@pytest.mark.asyncio
+async def test_memory_task_store_concurrent_access_keeps_running_tasks() -> None:
+    store = MemoryTaskStore(max_records=5, ttl_hours=1)
+
+    async def add_running(index: int) -> None:
+        await store.add(_task(f"running-{index}", status=TaskStatus.RUNNING, created_offset_hours=-10, ended_offset_hours=None))
+
+    async def add_final(index: int) -> None:
+        await store.add(_task(f"final-{index}", status=TaskStatus.SUCCEEDED, created_offset_hours=-10, ended_offset_hours=-10))
+
+    await asyncio.gather(*(add_running(i) for i in range(10)), *(add_final(i) for i in range(10)))
+    records = list(await store.iter_all())
+
+    assert {record.task_id for record in records} == {f"running-{i}" for i in range(10)}
+```
+
+- [ ] **Step 2: Run MemoryTaskStore tests and verify they fail**
+
+Run:
+
+```bash
+pytest -q tests/test_memory_task_store.py
+```
+
+Expected: FAIL with `TypeError: MemoryTaskStore.__init__() got an unexpected keyword argument 'max_records'`.
+
+- [ ] **Step 3: Implement MemoryTaskStore eviction**
+
+Replace the `MemoryTaskStore` class in `app/adapters/storage/memory.py` with:
+
+```python
+class MemoryTaskStore:
+    def __init__(self, *, max_records: int = 1000, ttl_hours: int = 168) -> None:
+        if max_records <= 0:
+            raise ValueError("max_records must be positive")
+        if ttl_hours <= 0:
+            raise ValueError("ttl_hours must be positive")
+        self._tasks: dict[str, TaskRecord] = {}
+        self._max_records = max_records
+        self._ttl = timedelta(hours=ttl_hours)
+        self._lock = asyncio.Lock()
+
+    async def add(self, record: TaskRecord) -> None:
+        async with self._lock:
+            self._evict_expired_and_overflow_locked()
+            self._tasks[record.task_id] = record
+            self._evict_expired_and_overflow_locked()
+
+    async def get(self, task_id: str) -> TaskRecord | None:
+        async with self._lock:
+            self._evict_expired_and_overflow_locked()
+            return self._tasks.get(task_id)
+
+    async def save(self, record: TaskRecord) -> None:
+        async with self._lock:
+            self._evict_expired_and_overflow_locked()
+            self._tasks[record.task_id] = record
+            self._evict_expired_and_overflow_locked()
+
+    async def list_by_user(self, user_id: int, limit: int = 10) -> list[TaskRecord]:
+        async with self._lock:
+            self._evict_expired_and_overflow_locked()
+            items = [x for x in self._tasks.values() if x.user_id == user_id]
+        items.sort(key=lambda x: x.created_at, reverse=True)
+        return items[:limit]
+
+    async def iter_all(self) -> Iterable[TaskRecord]:
+        async with self._lock:
+            self._evict_expired_and_overflow_locked()
+            return list(self._tasks.values())
+
+    def _evict_expired_and_overflow_locked(self) -> None:
+        now = utc_now()
+        expired_ids = [
+            task_id
+            for task_id, record in self._tasks.items()
+            if record.is_final and now - self._retention_time(record) > self._ttl
+        ]
+        for task_id in expired_ids:
+            self._tasks.pop(task_id, None)
+
+        overflow = len(self._tasks) - self._max_records
+        if overflow <= 0:
+            return
+
+        final_records = sorted(
+            (record for record in self._tasks.values() if record.is_final),
+            key=self._retention_time,
+        )
+        for record in final_records[:overflow]:
+            self._tasks.pop(record.task_id, None)
+
+    def _retention_time(self, record: TaskRecord) -> datetime:
+        return record.ended_at or record.created_at
+```
+
+Also update imports at the top of `app/adapters/storage/memory.py`:
+
+```python
+from datetime import datetime, timedelta
+
+from app.domain.models import SessionContext, TaskRecord, utc_now
+```
+
+- [ ] **Step 4: Run MemoryTaskStore tests and existing task tests**
+
+Run:
+
+```bash
+pytest -q tests/test_memory_task_store.py tests/test_task_service.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit MemoryTaskStore eviction**
+
+Run:
+
+```bash
+git add app/adapters/storage/memory.py tests/test_memory_task_store.py
+git commit -m "$(cat <<'EOF'
+feat: bound in-memory task store
+EOF
+)"
+```
+
+---
+
+### Task 3: RateLimitMiddleware bounded bucket cleanup
+
+**Files:**
+- Modify: `tests/test_auth_settings.py`
+- Modify: `app/bot/middleware/rate_limit.py`
+
+- [ ] **Step 1: Add failing rate-limit cleanup tests**
+
+Append these tests after `test_rate_limit_middleware_limits_callback_query_user` in `tests/test_auth_settings.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_rate_limit_middleware_deletes_empty_current_bucket_after_window() -> None:
+    middleware = RateLimitMiddleware(limit=1, window_sec=1, bucket_ttl_sec=1, cleanup_interval_sec=60, cleanup_batch_size=50)
+    callback = DummyCallbackQuery(user_id=1)
+
+    first = await middleware(_passing_handler, callback, {})
+    assert first == "ok"
+    assert 1 in middleware._buckets
+
+    now = __import__("asyncio").get_running_loop().time()
+    middleware._buckets[1].clear()
+    middleware._buckets[1].append(now - 2)
+
+    second = await middleware(_passing_handler, callback, {})
+
+    assert second == "ok"
+    assert list(middleware._buckets[1])
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_global_cleanup_is_interval_and_batch_limited() -> None:
+    middleware = RateLimitMiddleware(limit=2, window_sec=10, bucket_ttl_sec=10, cleanup_interval_sec=60, cleanup_batch_size=2)
+    loop = __import__("asyncio").get_running_loop()
+    now = loop.time()
+    for user_id in range(1, 6):
+        middleware._buckets[user_id].append(now - 100)
+        middleware._enqueue_bucket_locked(user_id)
+    middleware._last_cleanup_ts = now
+
+    callback = DummyCallbackQuery(user_id=99)
+    await middleware(_passing_handler, callback, {})
+
+    assert {1, 2, 3, 4, 5}.issubset(middleware._buckets)
+
+    middleware._last_cleanup_ts = now - 61
+    await middleware(_passing_handler, callback, {})
+
+    remaining_old_ids = {user_id for user_id in range(1, 6) if user_id in middleware._buckets}
+    assert len(remaining_old_ids) == 3
+@pytest.mark.asyncio
+async def test_rate_limit_bucket_recreated_after_delete_reenters_cleanup_queue() -> None:
+    middleware = RateLimitMiddleware(limit=2, window_sec=10, bucket_ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50)
+    loop = __import__("asyncio").get_running_loop()
+    now = loop.time()
+    middleware._buckets[1].append(now - 100)
+    middleware._enqueue_bucket_locked(1)
+    middleware._last_cleanup_ts = now - 2
+
+    await middleware(_passing_handler, DummyCallbackQuery(user_id=2), {})
+
+    assert 1 not in middleware._buckets
+    assert 1 not in middleware._cleanup_queued
+
+    await middleware(_passing_handler, DummyCallbackQuery(user_id=1), {})
+
+    assert 1 in middleware._buckets
+    assert 1 in middleware._cleanup_queued
+```
+
+- [ ] **Step 2: Run rate-limit tests and verify they fail**
+
+Run:
+
+```bash
+pytest -q tests/test_auth_settings.py::test_rate_limit_middleware_deletes_empty_current_bucket_after_window tests/test_auth_settings.py::test_rate_limit_global_cleanup_is_interval_and_batch_limited tests/test_auth_settings.py::test_rate_limit_bucket_recreated_after_delete_reenters_cleanup_queue
+```
+
+Expected: FAIL with `TypeError: RateLimitMiddleware.__init__() got an unexpected keyword argument 'bucket_ttl_sec'`.
+
+- [ ] **Step 3: Implement bounded bucket cleanup**
+
+Replace `app/bot/middleware/rate_limit.py` with:
+
+```python
+from __future__ import annotations
+
+import asyncio
+from collections import deque
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+from aiogram import BaseMiddleware
+
+
+class RateLimitMiddleware(BaseMiddleware):
+    def __init__(
+        self,
+        *,
+        limit: int,
+        window_sec: int,
+        bucket_ttl_sec: int | None = None,
+        cleanup_interval_sec: int = 60,
+        cleanup_batch_size: int = 50,
+    ) -> None:
+        super().__init__()
+        if limit <= 0:
+            raise ValueError("limit must be positive")
+        if window_sec <= 0:
+            raise ValueError("window_sec must be positive")
+        effective_bucket_ttl_sec = bucket_ttl_sec if bucket_ttl_sec is not None else window_sec
+        if effective_bucket_ttl_sec <= 0:
+            raise ValueError("bucket_ttl_sec must be positive")
+        if cleanup_interval_sec <= 0:
+            raise ValueError("cleanup_interval_sec must be positive")
+        if cleanup_batch_size <= 0:
+            raise ValueError("cleanup_batch_size must be positive")
+        self._limit = limit
+        self._window_sec = window_sec
+        self._bucket_ttl_sec = effective_bucket_ttl_sec
+        self._cleanup_interval_sec = cleanup_interval_sec
+        self._cleanup_batch_size = cleanup_batch_size
+        self._buckets: dict[int, deque[float]] = {}
+        self._cleanup_queue: deque[int] = deque()
+        self._cleanup_queued: set[int] = set()
+        self._last_cleanup_ts = 0.0
+        self._lock = asyncio.Lock()
+
+    async def __call__(
+        self,
+        handler: Callable[[Any, dict], Awaitable],
+        event: Any,
+        data: dict,
+    ):
+        user = event.from_user
+        if user is None:
+            return await handler(event, data)
+
+        limited = False
+        now = asyncio.get_running_loop().time()
+        async with self._lock:
+            bucket = self._buckets.get(user.id)
+            if bucket is None:
+                bucket = deque()
+                self._buckets[user.id] = bucket
+                self._enqueue_bucket_locked(user.id)
+
+            self._prune_bucket_locked(bucket, now=now)
+            if len(bucket) >= self._limit:
+                limited = True
+            else:
+                bucket.append(now)
+
+            self._maybe_cleanup_buckets_locked(now=now)
+
+        if limited:
+            await event.answer("请求过于频繁,请稍后再试。")
+            return None
+
+        return await handler(event, data)
+
+    def _maybe_cleanup_buckets_locked(self, *, now: float) -> None:
+        if now - self._last_cleanup_ts < self._cleanup_interval_sec:
+            return
+        self._last_cleanup_ts = now
+        for _ in range(min(self._cleanup_batch_size, len(self._cleanup_queue))):
+            user_id = self._cleanup_queue.popleft()
+            self._cleanup_queued.discard(user_id)
+            bucket = self._buckets.get(user_id)
+            if bucket is None:
+                continue
+            self._prune_bucket_locked(bucket, now=now)
+            if not bucket or now - bucket[-1] > self._bucket_ttl_sec:
+                self._delete_bucket_locked(user_id)
+                continue
+            self._enqueue_bucket_locked(user_id)
+
+    def _prune_bucket_locked(self, bucket: deque[float], *, now: float) -> None:
+        while bucket and now - bucket[0] > self._window_sec:
+            bucket.popleft()
+
+    def _delete_bucket_locked(self, user_id: int) -> None:
+        self._buckets.pop(user_id, None)
+        self._cleanup_queued.discard(user_id)
+
+    def _enqueue_bucket_locked(self, user_id: int) -> None:
+        if user_id not in self._cleanup_queued:
+            self._cleanup_queue.append(user_id)
+            self._cleanup_queued.add(user_id)
+```
+
+- [ ] **Step 4: Run rate-limit tests**
+
+Run:
+
+```bash
+pytest -q tests/test_auth_settings.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit rate-limit cleanup**
+
+Run:
+
+```bash
+git add app/bot/middleware/rate_limit.py tests/test_auth_settings.py
+git commit -m "$(cat <<'EOF'
+feat: bound rate limit buckets
+EOF
+)"
+```
+
+---
+
+### Task 4: Settings and environment configuration
+
+**Files:**
+- Modify: `tests/test_auth_settings.py`
+- Modify: `app/config/settings.py`
+- Modify: `deploy/env/.env.example`
+
+- [ ] **Step 1: Back up `.env.example` before editing**
+
+Run:
+
+```bash
+mkdir -p /Users/jack/project/remote-coding/.claude/backups
+cp /Users/jack/project/remote-coding/deploy/env/.env.example /Users/jack/project/remote-coding/.claude/backups/.env.example
+```
+
+Expected: backup exists at `.claude/backups/.env.example`.
+
+- [ ] **Step 2: Add failing settings tests**
+
+Add this test after `test_settings_parse_claude_hook_fields` in `tests/test_auth_settings.py`:
+
+```python
+def test_settings_parse_memory_cleanup_fields_and_effective_defaults() -> None:
+    settings = Settings.model_validate(
+        {
+            "TG_BOT_TOKEN": "token",
+            "TG_ALLOWED_USER_IDS": "1",
+            "DEFAULT_PROVIDER": "claude_code",
+            "DEFAULT_TIMEOUT_SEC": 10,
+            "MAX_CONCURRENT_TASKS": 1,
+            "CLAUDE_TMUX_MODE": False,
+            "CLAUDE_CLI_BIN": "claude",
+            "CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC": 45,
+            "CODEX_CLI_BIN": "codex",
+            "GEMINI_CLI_BIN": "gemini",
+            "ALLOWED_WORKDIRS": "/tmp",
+            "RATE_LIMIT_WINDOW_SEC": 12,
+        }
+    )
+
+    assert settings.task_store_ttl_hours == 168
+    assert settings.task_store_max_records == 1000
+    assert settings.effective_rate_limit_bucket_ttl_sec == 12
+    assert settings.rate_limit_bucket_cleanup_interval_sec == 60
+    assert settings.rate_limit_bucket_cleanup_batch_size == 50
+    assert settings.effective_permission_lock_ttl_sec == 45
+    assert settings.session_lock_ttl_sec == 3600
+    assert settings.lock_cleanup_interval_sec == 60
+    assert settings.lock_cleanup_batch_size == 50
+
+
+def test_settings_accepts_explicit_memory_cleanup_overrides() -> None:
+    settings = Settings.model_validate(
+        {
+            "TG_BOT_TOKEN": "token",
+            "TG_ALLOWED_USER_IDS": "1",
+            "DEFAULT_PROVIDER": "claude_code",
+            "DEFAULT_TIMEOUT_SEC": 10,
+            "MAX_CONCURRENT_TASKS": 1,
+            "CLAUDE_TMUX_MODE": False,
+            "CLAUDE_CLI_BIN": "claude",
+            "CODEX_CLI_BIN": "codex",
+            "GEMINI_CLI_BIN": "gemini",
+            "ALLOWED_WORKDIRS": "/tmp",
+            "TASK_STORE_TTL_HOURS": 24,
+            "TASK_STORE_MAX_RECORDS": 10,
+            "RATE_LIMIT_BUCKET_TTL_SEC": 30,
+            "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC": 5,
+            "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE": 3,
+            "PERMISSION_LOCK_TTL_SEC": 40,
+            "SESSION_LOCK_TTL_SEC": 50,
+            "LOCK_CLEANUP_INTERVAL_SEC": 6,
+            "LOCK_CLEANUP_BATCH_SIZE": 4,
+        }
+    )
+
+    assert settings.task_store_ttl_hours == 24
+    assert settings.task_store_max_records == 10
+    assert settings.effective_rate_limit_bucket_ttl_sec == 30
+    assert settings.rate_limit_bucket_cleanup_interval_sec == 5
+    assert settings.rate_limit_bucket_cleanup_batch_size == 3
+    assert settings.effective_permission_lock_ttl_sec == 40
+    assert settings.session_lock_ttl_sec == 50
+    assert settings.lock_cleanup_interval_sec == 6
+    assert settings.lock_cleanup_batch_size == 4
+
+
+def test_settings_rejects_non_positive_memory_cleanup_fields() -> None:
+    base_payload = {
+        "TG_BOT_TOKEN": "token",
+        "TG_ALLOWED_USER_IDS": "1",
+        "DEFAULT_PROVIDER": "claude_code",
+        "DEFAULT_TIMEOUT_SEC": 10,
+        "MAX_CONCURRENT_TASKS": 1,
+        "CLAUDE_TMUX_MODE": False,
+        "CLAUDE_CLI_BIN": "claude",
+        "CODEX_CLI_BIN": "codex",
+        "GEMINI_CLI_BIN": "gemini",
+        "ALLOWED_WORKDIRS": "/tmp",
+    }
+
+    for field in (
+        "TASK_STORE_TTL_HOURS",
+        "TASK_STORE_MAX_RECORDS",
+        "RATE_LIMIT_BUCKET_TTL_SEC",
+        "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC",
+        "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE",
+        "PERMISSION_LOCK_TTL_SEC",
+        "SESSION_LOCK_TTL_SEC",
+        "LOCK_CLEANUP_INTERVAL_SEC",
+        "LOCK_CLEANUP_BATCH_SIZE",
+    ):
+        with pytest.raises(ValidationError):
+            Settings.model_validate({**base_payload, field: 0})
+```
+
+Extend `test_env_example_matches_supported_claude_settings` with:
+
+```python
+    assert "TASK_STORE_TTL_HOURS=168" in content
+    assert "TASK_STORE_MAX_RECORDS=1000" in content
+    assert "RATE_LIMIT_BUCKET_TTL_SEC=" in content
+    assert "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60" in content
+    assert "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50" in content
+    assert "PERMISSION_LOCK_TTL_SEC=" in content
+    assert "SESSION_LOCK_TTL_SEC=3600" in content
+    assert "LOCK_CLEANUP_INTERVAL_SEC=60" in content
+    assert "LOCK_CLEANUP_BATCH_SIZE=50" in content
+```
+
+- [ ] **Step 3: Run settings tests and verify they fail**
+
+Run:
+
+```bash
+pytest -q tests/test_auth_settings.py::test_settings_parse_memory_cleanup_fields_and_effective_defaults tests/test_auth_settings.py::test_settings_accepts_explicit_memory_cleanup_overrides tests/test_auth_settings.py::test_settings_rejects_non_positive_memory_cleanup_fields tests/test_auth_settings.py::test_env_example_matches_supported_claude_settings
+```
+
+Expected: FAIL with missing `task_store_ttl_hours` or missing `.env.example` entries.
+
+- [ ] **Step 4: Add settings fields and validators**
+
+In `app/config/settings.py`, add fields after `rate_limit_window_sec`:
+
+```python
+    task_store_ttl_hours: int = Field(168, alias="TASK_STORE_TTL_HOURS")
+    task_store_max_records: int = Field(1000, alias="TASK_STORE_MAX_RECORDS")
+    rate_limit_bucket_ttl_sec: int | None = Field(None, alias="RATE_LIMIT_BUCKET_TTL_SEC")
+    rate_limit_bucket_cleanup_interval_sec: int = Field(60, alias="RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC")
+    rate_limit_bucket_cleanup_batch_size: int = Field(50, alias="RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE")
+    permission_lock_ttl_sec: int | None = Field(None, alias="PERMISSION_LOCK_TTL_SEC")
+    session_lock_ttl_sec: int = Field(3600, alias="SESSION_LOCK_TTL_SEC")
+    lock_cleanup_interval_sec: int = Field(60, alias="LOCK_CLEANUP_INTERVAL_SEC")
+    lock_cleanup_batch_size: int = Field(50, alias="LOCK_CLEANUP_BATCH_SIZE")
+```
+
+Add these names to the existing `validate_positive_int` field list:
+
+```python
+        "task_store_ttl_hours",
+        "task_store_max_records",
+        "rate_limit_bucket_cleanup_interval_sec",
+        "rate_limit_bucket_cleanup_batch_size",
+        "session_lock_ttl_sec",
+        "lock_cleanup_interval_sec",
+        "lock_cleanup_batch_size",
+```
+
+Add this validator below `validate_positive_int`:
+
+```python
+    @field_validator("rate_limit_bucket_ttl_sec", "permission_lock_ttl_sec")
+    @classmethod
+    def validate_optional_positive_int(cls, value: int | None) -> int | None:
+        if value is not None and value <= 0:
+            raise ValueError("配置值必须大于 0")
+        return value
+```
+
+Add these properties near the existing properties:
+
+```python
+    @property
+    def effective_rate_limit_bucket_ttl_sec(self) -> int:
+        return self.rate_limit_bucket_ttl_sec or self.rate_limit_window_sec
+
+    @property
+    def effective_permission_lock_ttl_sec(self) -> int:
+        return self.permission_lock_ttl_sec or self.claude_hook_pending_permission_ttl_sec
+```
+
+- [ ] **Step 5: Update `.env.example`**
+
+Insert after `RATE_LIMIT_WINDOW_SEC=20` in `deploy/env/.env.example`:
+
+```dotenv
+RATE_LIMIT_BUCKET_TTL_SEC=
+RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60
+RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50
+
+# In-memory cleanup
+TASK_STORE_TTL_HOURS=168
+TASK_STORE_MAX_RECORDS=1000
+PERMISSION_LOCK_TTL_SEC=
+SESSION_LOCK_TTL_SEC=3600
+LOCK_CLEANUP_INTERVAL_SEC=60
+LOCK_CLEANUP_BATCH_SIZE=50
+```
+
+- [ ] **Step 6: Run settings tests**
+
+Run:
+
+```bash
+pytest -q tests/test_auth_settings.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit settings and env config**
+
+Run:
+
+```bash
+git add app/config/settings.py deploy/env/.env.example tests/test_auth_settings.py .claude/backups/.env.example
+git commit -m "$(cat <<'EOF'
+feat: add memory cleanup settings
+EOF
+)"
+```
+
+---
+
+### Task 5: Wire MemoryTaskStore and RateLimitMiddleware configuration
+
+**Files:**
+- Modify: `app/bootstrap.py`
+- Test: `tests/test_auth_settings.py`, `tests/test_memory_task_store.py`
+
+- [ ] **Step 1: Write failing AppContainer wiring test**
+
+Add this test to `tests/test_auth_settings.py`:
+
+```python
+def test_app_container_uses_memory_cleanup_settings(tmp_path) -> None:
+    from app.bootstrap import AppContainer
+
+    settings = Settings.model_validate(
+        {
+            "TG_BOT_TOKEN": "123456:TESTTOKEN",
+            "TG_ALLOWED_USER_IDS": "1",
+            "DEFAULT_PROVIDER": "claude_code",
+            "DEFAULT_TIMEOUT_SEC": 10,
+            "MAX_CONCURRENT_TASKS": 1,
+            "CLAUDE_TMUX_MODE": False,
+            "TMUX_DATA_DIR": str(tmp_path),
+            "CLAUDE_CLI_BIN": "claude",
+            "CLAUDE_INSTALL_HOOKS": False,
+            "CLAUDE_CONFIG_DIR": str(tmp_path / ".claude"),
+            "CLAUDE_HOOK_SOCKET_PATH": str(tmp_path / "hook.sock"),
+            "CODEX_CLI_BIN": "codex",
+            "GEMINI_CLI_BIN": "gemini",
+            "ALLOWED_WORKDIRS": str(tmp_path),
+            "TASK_STORE_TTL_HOURS": 24,
+            "TASK_STORE_MAX_RECORDS": 12,
+        }
+    )
+
+    container = AppContainer(settings)
+
+    assert container.task_store._ttl.total_seconds() == 24 * 60 * 60
+    assert container.task_store._max_records == 12
+```
+
+- [ ] **Step 2: Run wiring test and verify it fails**
+
+Run:
+
+```bash
+pytest -q tests/test_auth_settings.py::test_app_container_uses_memory_cleanup_settings
+```
+
+Expected: FAIL because `container.task_store._max_records` remains `1000`.
+
+- [ ] **Step 3: Wire settings in AppContainer**
+
+In `app/bootstrap.py`, replace:
+
+```python
+        self.task_store = MemoryTaskStore()
+```
+
+with:
+
+```python
+        self.task_store = MemoryTaskStore(
+            max_records=settings.task_store_max_records,
+            ttl_hours=settings.task_store_ttl_hours,
+        )
+```
+
+In `app/bootstrap.py`, replace the `RateLimitMiddleware` construction with:
+
+```python
+        rate_limit_middleware = RateLimitMiddleware(
+            limit=self.settings.rate_limit_max_requests,
+            window_sec=self.settings.rate_limit_window_sec,
+            bucket_ttl_sec=self.settings.effective_rate_limit_bucket_ttl_sec,
+            cleanup_interval_sec=self.settings.rate_limit_bucket_cleanup_interval_sec,
+            cleanup_batch_size=self.settings.rate_limit_bucket_cleanup_batch_size,
+        )
+```
+
+- [ ] **Step 4: Run wiring and related tests**
+
+Run:
+
+```bash
+pytest -q tests/test_auth_settings.py tests/test_memory_task_store.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit bootstrap wiring for store and rate limit**
+
+Run:
+
+```bash
+git add app/bootstrap.py tests/test_auth_settings.py
+git commit -m "$(cat <<'EOF'
+feat: wire memory cleanup settings
+EOF
+)"
+```
+
+---
+
+### Task 6: PermissionService lock registry wiring
+
+**Files:**
+- Modify: `app/services/permission_service.py`
+- Modify: `app/services/task_service.py`
+- Test: `tests/test_task_service.py`, `tests/test_lock_registry.py`
+
+- [ ] **Step 1: Add failing PermissionService wiring test**
+
+Add this test near the existing permission tests in `tests/test_task_service.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_permission_service_uses_configured_lock_registry(tmp_path: Path) -> None:
+    adapter = StubAdapter(events=[])
+    factory = StubFactory(adapter)
+    session_service = make_file_backed_session_service(tmp_path)
+    structured_store = SessionStore(FileSessionStore(str(tmp_path)))
+    hook_socket_server = DummyHookSocketServer()
+    service = TaskService(
+        settings=make_settings(tmp_path, claude_tmux_mode=True),
+        task_store=MemoryTaskStore(),
+        session_service=session_service,
+        cli_factory=factory,
+        semaphore=asyncio.Semaphore(2),
+        structured_session_store=structured_store,
+        hook_socket_server=hook_socket_server,
+    )
+
+    registry = service._permission_service._permission_locks
+
+    assert registry._ttl_sec == service._settings.effective_permission_lock_ttl_sec
+    assert registry._cleanup_interval_sec == service._settings.lock_cleanup_interval_sec
+    assert registry._cleanup_batch_size == service._settings.lock_cleanup_batch_size
+```
+
+- [ ] **Step 2: Run PermissionService wiring test and verify it fails**
+
+Run:
+
+```bash
+pytest -q tests/test_task_service.py::test_permission_service_uses_configured_lock_registry
+```
+
+Expected: FAIL because `_permission_locks` is still a `dict`.
+
+- [ ] **Step 3: Update PermissionService constructor and lock usage**
+
+In `app/services/permission_service.py`, import registry:
+
+```python
+from app.services.lock_registry import RefCountedLockRegistry
+```
+
+Replace `_permission_locks` initialization in `PermissionService.__init__` with constructor parameters and registry:
+
+```python
+        permission_lock_ttl_sec: int = 600,
+        lock_cleanup_interval_sec: int = 60,
+        lock_cleanup_batch_size: int = 50,
+```
+
+and inside the body:
+
+```python
+        self._permission_locks = RefCountedLockRegistry(
+            ttl_sec=permission_lock_ttl_sec,
+            cleanup_interval_sec=lock_cleanup_interval_sec,
+            cleanup_batch_size=lock_cleanup_batch_size,
+        )
+```
+
+Replace:
+
+```python
+        async with self._get_permission_lock(lock_tool_use_id):
+```
+
+with:
+
+```python
+        async with self._permission_locks.lock(lock_tool_use_id):
+```
+
+Delete the `_get_permission_lock()` method from `PermissionService`.
+
+- [ ] **Step 4: Pass settings from TaskService**
+
+In `app/services/task_service.py`, update `PermissionService(...)` construction to include:
+
+```python
+            permission_lock_ttl_sec=settings.effective_permission_lock_ttl_sec,
+            lock_cleanup_interval_sec=settings.lock_cleanup_interval_sec,
+            lock_cleanup_batch_size=settings.lock_cleanup_batch_size,
+```
+
+- [ ] **Step 5: Run permission and registry tests**
+
+Run:
+
+```bash
+pytest -q tests/test_lock_registry.py tests/test_task_service.py::test_permission_service_uses_configured_lock_registry tests/test_task_service.py::test_respond_to_pending_permission_uses_resolved_structured_session_not_stale_context tests/test_task_service.py::test_respond_to_pending_permission_keeps_state_when_socket_response_fails
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit PermissionService registry wiring**
+
+Run:
+
+```bash
+git add app/services/permission_service.py app/services/task_service.py tests/test_task_service.py
+git commit -m "$(cat <<'EOF'
+feat: bound permission locks
+EOF
+)"
+```
+
+---
+
+### Task 7: JSONL sync and session event lock registry wiring
+
+**Files:**
+- Modify: `app/bootstrap.py`
+- Modify: `app/bootstrap_base.py`
+- Modify: `app/bootstrap_mixins.py`
+- Modify: `tests/test_bootstrap_hooks.py`
+
+- [ ] **Step 1: Add failing bootstrap lock tests**
+
+Update `tests/test_bootstrap_hooks.py::test_sync_claude_session_uses_per_session_lock` by replacing manual dict lock setup:
+
+```python
+    lock = asyncio.Lock()
+    await lock.acquire()
+    container._jsonl_sync_locks["claude-session-1"] = lock
+```
+
+with registry manual entry:
+
+```python
+    held_lock = container._jsonl_sync_locks.lock("claude-session-1")
+    await held_lock.__aenter__()
+```
+
+and replace:
+
+```python
+    lock.release()
+```
+
+with:
+
+```python
+    await held_lock.__aexit__(None, None, None)
+```
+
+Add this new test after `test_session_end_keeps_pending_sync_until_flushed`:
+
+```python
+@pytest.mark.asyncio
+async def test_session_end_cleans_event_lock_registry(tmp_path) -> None:
+    container = AppContainer(make_settings(tmp_path, install_hooks=False))
+
+    await container._dispatch_session_event(
+        container.structured_session_store.process.__globals__["SessionEvent"](
+            session_id="claude-session-ended",
+            type=container.structured_session_store.process.__globals__["SessionEventType"].SESSION_ENDED,
+            payload={"cwd": str(tmp_path)},
+        )
+    )
+
+    assert len(container._session_event_locks) == 0
+```
+
+- [ ] **Step 2: Run bootstrap lock tests and verify they fail**
+
+Run:
+
+```bash
+pytest -q tests/test_bootstrap_hooks.py::test_sync_claude_session_uses_per_session_lock tests/test_bootstrap_hooks.py::test_session_end_cleans_event_lock_registry
+```
+
+Expected: FAIL because `_jsonl_sync_locks` is still a dict and `_session_event_locks` has no registry semantics.
+
+- [ ] **Step 3: Update AppContainer lock registry construction**
+
+In `app/bootstrap.py`, add import:
+
+```python
+from app.services.lock_registry import RefCountedLockRegistry
+```
+
+Replace:
+
+```python
+        self._jsonl_sync_locks: dict[str, asyncio.Lock] = {}
+        self._session_event_locks: dict[str, asyncio.Lock] = {}
+```
+
+with:
+
+```python
+        self._jsonl_sync_locks = RefCountedLockRegistry(
+            ttl_sec=settings.session_lock_ttl_sec,
+            cleanup_interval_sec=settings.lock_cleanup_interval_sec,
+            cleanup_batch_size=settings.lock_cleanup_batch_size,
+        )
+        self._session_event_locks = RefCountedLockRegistry(
+            ttl_sec=settings.session_lock_ttl_sec,
+            cleanup_interval_sec=settings.lock_cleanup_interval_sec,
+            cleanup_batch_size=settings.lock_cleanup_batch_size,
+        )
+```
+
+- [ ] **Step 4: Update AppContainerBase type declarations**
+
+In `app/bootstrap_base.py`, remove the top-level `import asyncio` if it is no longer needed after this change. Add import:
+
+```python
+from app.services.lock_registry import RefCountedLockRegistry
+```
+
+Replace type declarations:
+
+```python
+    _jsonl_sync_locks: dict[str, asyncio.Lock]
+    _session_event_locks: dict[str, asyncio.Lock]
+```
+
+with:
+
+```python
+    _jsonl_sync_locks: RefCountedLockRegistry
+    _session_event_locks: RefCountedLockRegistry
+```
+
+Keep `asyncio` imported if `_jsonl_sync_tasks: dict[str, asyncio.Task[None]]` still needs it.
+
+- [ ] **Step 5: Update JsonlSyncMixin**
+
+In `app/bootstrap_mixins.py`, replace `sync_claude_session()` lock acquisition:
+
+```python
+        lock = self._jsonl_sync_locks.setdefault(session_id, asyncio.Lock())
+        async with lock:
+```
+
+with:
+
+```python
+        async with self._jsonl_sync_locks.lock(session_id):
+```
+
+In `_stop_jsonl_sync_tasks()`, replace:
+
+```python
+        self._jsonl_sync_locks.clear()
+```
+
+with:
+
+```python
+        await self._jsonl_sync_locks.clear()
+```
+
+In `_debounced_sync_claude_session()` finally block, after the existing task bookkeeping, add:
+
+```python
+            if session_id not in self._jsonl_sync_requests:
+                await self._jsonl_sync_locks.cleanup_key(session_id)
+```
+
+The final block should still preserve the existing behavior that reschedules when `session_id in self._jsonl_sync_requests`.
+
+- [ ] **Step 6: Update EventDispatchMixin**
+
+In `app/bootstrap_mixins.py`, replace `_dispatch_session_event()` with:
+
+```python
+    async def _dispatch_session_event(self, event: SessionEvent) -> None:
+        async with self._session_event_locks.lock(event.session_id):
+            self.structured_session_store.get_or_create(
+                session_id=event.session_id,
+                provider="claude_code",
+                workdir=str(event.payload.get("cwd", ".")),
+                claude_session_id=event.session_id,
+            )
+            self.structured_session_store.process(event)
+        if event.type == SessionEventType.SESSION_ENDED:
+            await self._session_event_locks.cleanup_key(event.session_id, require_expired=False)
+```
+
+- [ ] **Step 7: Run bootstrap tests**
+
+Run:
+
+```bash
+pytest -q tests/test_bootstrap_hooks.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit bootstrap lock registry wiring**
+
+Before committing, verify the unrelated pre-existing `app/bootstrap_mixins.py` changes were preserved and that only intended hunks are staged:
+
+```bash
+git diff -- app/bootstrap_mixins.py
+git add app/bootstrap.py app/bootstrap_base.py app/bootstrap_mixins.py tests/test_bootstrap_hooks.py
+git diff --cached -- app/bootstrap_mixins.py
+```
+
+If the cached diff contains unrelated user changes, stop and ask for direction. If it contains only feature hunks, commit:
+
+```bash
+git commit -m "$(cat <<'EOF'
+feat: bound session lock registries
+EOF
+)"
+```
+
+---
+
+### Task 8: Final verification and cleanup
+
+**Files:**
+- Verify all files changed in Tasks 1-7.
+
+- [ ] **Step 1: Run targeted tests**
+
+Run:
+
+```bash
+pytest -q tests/test_lock_registry.py tests/test_memory_task_store.py tests/test_auth_settings.py tests/test_task_service.py::test_permission_service_uses_configured_lock_registry tests/test_bootstrap_hooks.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run full test suite**
+
+Run:
+
+```bash
+pytest -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run lint on touched Python files**
+
+Run:
+
+```bash
+ruff check app/services/lock_registry.py app/adapters/storage/memory.py app/bot/middleware/rate_limit.py app/config/settings.py app/services/permission_service.py app/services/task_service.py app/bootstrap.py app/bootstrap_base.py app/bootstrap_mixins.py tests/test_lock_registry.py tests/test_memory_task_store.py tests/test_auth_settings.py tests/test_task_service.py tests/test_bootstrap_hooks.py
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Inspect git status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: only intentional tracked changes are present. `_test_task21.py` remains untracked and must not be committed.
+
+- [ ] **Step 5: Remove backup if it is no longer needed**
+
+If `.claude/backups/.env.example` exists and the `.env.example` change is verified, remove the backup in a dedicated cleanup commit or include it in the final cleanup commit only if project policy says backups should not remain. If the backup is intentionally tracked, keep it.
+
+Run this check:
+
+```bash
+git status --short .claude/backups/.env.example
+```
+
+Expected: decision is explicit; no accidental backup is left unstaged.
+
+---
+
+## Self-review
+
+- Spec coverage: task store TTL/capacity is in Task 2; rate limit bounded queue is in Task 3; settings/env are in Task 4; AppContainer wiring is in Tasks 5 and 7; permission locks are in Task 6; JSONL/session event locks are in Task 7; tests include large task store, concurrent task store, queue reentry, registry state independence, and configuration semantics.
+- Placeholder scan: no `TBD`, no empty test descriptions, no unspecified validation steps.
+- Type consistency: `RefCountedLockRegistry.lock()` is used as an async context manager; registry exposes `cleanup_key()`, `cleanup_expired()`, `clear()`, `__len__()`, and `queued_count`; settings properties use `effective_rate_limit_bucket_ttl_sec` and `effective_permission_lock_ttl_sec` consistently.
diff --git a/docs/superpowers/plans/2026-05-26-priority-fixes.md b/docs/superpowers/plans/2026-05-26-priority-fixes.md
new file mode 100644
index 0000000..1894f2f
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-26-priority-fixes.md
@@ -0,0 +1,2271 @@
+# Priority Fixes Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Repair upload queue behavior, permission callback routing, and stale in-memory state cleanup without broad refactors.
+
+**Architecture:** Add two focused in-memory services: `UploadQueueManager` for bounded per-user upload queues and `PermissionCallbackRegistry` for short callback tokens. Inject them through `AppContainer` and `create_router`, then keep behavior changes localized to upload handlers, run streaming completion hooks, permission handlers, and lock-owning services.
+
+**Tech Stack:** Python 3.11, asyncio, aiogram 3.x, pydantic-settings, pytest/pytest-asyncio, pyenv/pyenv-virtualenv (`.python-version` is `remote-coding`).
+
+---
+
+## File Structure
+
+**Create:**
+- `app/services/upload_queue.py` — bounded in-memory FIFO upload queue, per-user byte totals, drain API.
+- `app/services/permission_callback_registry.py` — TTL token registry mapping short callback tokens to full `tool_use_id` values.
+- `tests/test_upload_queue.py` — unit tests for queue limits, FIFO drain, disabled queue.
+- `tests/test_permission_callback_registry.py` — unit tests for token length, TTL expiry, collision retry.
+- `tests/test_run_event_streamer_upload_queue.py` — focused tests for scheduling queued uploads after the final task message.
+- `tests/test_agent_file_watcher.py` — focused watcher cleanup tests.
+
+**Modify:**
+- `app/config/settings.py` — upload queue settings and derived byte limit.
+- `deploy/env/.env.example` — document upload queue environment variables.
+- `app/bootstrap.py` — instantiate and inject `UploadQueueManager` and `PermissionCallbackRegistry`; pass tmux lock cleanup settings.
+- `app/bot/router.py` — thread upload queue and permission callback registry through handler registration.
+- `app/bot/handlers/file_upload.py` — remove global raw queue, reject oversize metadata before download, enqueue through `UploadQueueManager`, schedule tracked background processing.
+- `app/bot/handlers/command_run.py` — accept a queued-upload scheduler and pass it to `RunEventStreamer`.
+- `app/bot/handlers/run_event_streamer.py` — call the scheduler immediately after each final task result is displayed.
+- `app/bot/handlers/command_permission.py` — build normal permission buttons with short tokens and resolve tokens on callback.
+- `app/bot/handlers/external_permission.py` — resolve `ext_perm::` callbacks.
+- `app/services/unbound_permission_handler.py` — build external permission buttons with short tokens and atomically remove pending state on response/expiry.
+- `app/adapters/process/tmux_runner.py` — replace persistent-session lock dictionary with `RefCountedLockRegistry`.
+- Existing tests under `tests/test_file_upload_handler.py`, `tests/test_session_handlers.py`, `tests/property/test_unbound_permission_properties.py`, `tests/integration/test_external_session_pipeline.py`, `tests/test_tmux_runner.py`, `tests/test_auth_settings.py`, and `tests/test_bootstrap_hooks.py`.
+
+---
+
+### Task 0: Pre-flight environment check
+
+**Files:**
+- Read: `.python-version`
+
+- [ ] **Step 1: Confirm pyenv environment**
+
+Run:
+
+```bash
+pyenv version
+```
+
+Expected: output contains `remote-coding` and this repository path. If it does not, run this before any Python test command:
+
+```bash
+pyenv local remote-coding
+```
+
+- [ ] **Step 2: Confirm the working tree before coding**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: only the uncommitted plan file may appear. Do not overwrite unrelated user changes.
+
+---
+
+### Task 1: Settings and upload queue service
+
+**Files:**
+- Create: `app/services/upload_queue.py`
+- Create: `tests/test_upload_queue.py`
+- Modify: `app/config/settings.py:113-121,232-273,293-299`
+- Modify: `deploy/env/.env.example:52-57`
+- Modify: `tests/test_auth_settings.py:149-237`
+
+- [ ] **Step 1: Write failing upload queue tests**
+
+Create `tests/test_upload_queue.py` with this content:
+
+```python
+from __future__ import annotations
+
+import pytest
+
+from app.services.upload_queue import UploadQueueManager
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_accepts_and_drains_fifo() -> None:
+    queue = UploadQueueManager(max_files_per_user=3, max_bytes_per_user=100)
+
+    first = await queue.enqueue(user_id=1, filename="a.txt", data=b"a")
+    second = await queue.enqueue(user_id=1, filename="b.txt", data=b"bb")
+
+    assert first.accepted is True
+    assert second.accepted is True
+    drained = await queue.drain(user_id=1)
+    assert [(item.filename, item.data, item.size_bytes) for item in drained] == [
+        ("a.txt", b"a", 1),
+        ("b.txt", b"bb", 2),
+    ]
+    assert await queue.drain(user_id=1) == []
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_rejects_when_file_count_limit_is_reached() -> None:
+    queue = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=100)
+
+    accepted = await queue.enqueue(user_id=1, filename="a.txt", data=b"a")
+    rejected = await queue.enqueue(user_id=1, filename="b.txt", data=b"b")
+
+    assert accepted.accepted is True
+    assert rejected.accepted is False
+    assert "队列已满" in rejected.reason
+    drained = await queue.drain(user_id=1)
+    assert [item.filename for item in drained] == ["a.txt"]
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_rejects_when_byte_limit_would_be_exceeded() -> None:
+    queue = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=3)
+
+    accepted = await queue.enqueue(user_id=1, filename="a.txt", data=b"aa")
+    rejected = await queue.enqueue(user_id=1, filename="b.txt", data=b"bb")
+
+    assert accepted.accepted is True
+    assert rejected.accepted is False
+    assert "队列容量" in rejected.reason
+    drained = await queue.drain(user_id=1)
+    assert [(item.filename, item.size_bytes) for item in drained] == [("a.txt", 2)]
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_zero_file_limit_disables_queueing() -> None:
+    queue = UploadQueueManager(max_files_per_user=0, max_bytes_per_user=100)
+
+    result = await queue.enqueue(user_id=1, filename="a.txt", data=b"a")
+
+    assert result.accepted is False
+    assert "上传队列已关闭" in result.reason
+    assert await queue.drain(user_id=1) == []
+```
+
+- [ ] **Step 2: Run upload queue tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_upload_queue.py -q
+```
+
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.upload_queue'`.
+
+- [ ] **Step 3: Implement `UploadQueueManager`**
+
+Create `app/services/upload_queue.py` with this content:
+
+```python
+from __future__ import annotations
+
+import asyncio
+from collections import defaultdict, deque
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class QueuedUpload:
+    filename: str
+    data: bytes
+    size_bytes: int
+
+
+@dataclass(frozen=True, slots=True)
+class UploadQueueEnqueueResult:
+    accepted: bool
+    reason: str = ""
+
+
+class UploadQueueManager:
+    def __init__(self, *, max_files_per_user: int, max_bytes_per_user: int) -> None:
+        if max_files_per_user < 0:
+            raise ValueError("max_files_per_user must be non-negative")
+        if max_bytes_per_user < 0:
+            raise ValueError("max_bytes_per_user must be non-negative")
+        self._max_files_per_user = max_files_per_user
+        self._max_bytes_per_user = max_bytes_per_user
+        self._queues: dict[int, deque[QueuedUpload]] = defaultdict(deque)
+        self._byte_totals: dict[int, int] = defaultdict(int)
+        self._lock = asyncio.Lock()
+
+    async def enqueue(self, *, user_id: int, filename: str, data: bytes) -> UploadQueueEnqueueResult:
+        size_bytes = len(data)
+        async with self._lock:
+            if self._max_files_per_user == 0:
+                return UploadQueueEnqueueResult(False, "上传队列已关闭,请等待当前任务完成后重新上传。")
+
+            queue = self._queues[user_id]
+            if len(queue) >= self._max_files_per_user:
+                return UploadQueueEnqueueResult(False, f"队列已满,最多允许排队 {self._max_files_per_user} 个文件。")
+
+            current_total = self._byte_totals[user_id]
+            if current_total + size_bytes > self._max_bytes_per_user:
+                return UploadQueueEnqueueResult(
+                    False,
+                    f"队列容量不足,当前排队 {current_total} 字节,本文件 {size_bytes} 字节,上限 {self._max_bytes_per_user} 字节。",
+                )
+
+            queue.append(QueuedUpload(filename=filename, data=data, size_bytes=size_bytes))
+            self._byte_totals[user_id] = current_total + size_bytes
+            return UploadQueueEnqueueResult(True)
+
+    async def drain(self, *, user_id: int) -> list[QueuedUpload]:
+        async with self._lock:
+            queue = self._queues.pop(user_id, deque())
+            self._byte_totals.pop(user_id, None)
+            return list(queue)
+
+    async def queued_count(self, *, user_id: int) -> int:
+        async with self._lock:
+            return len(self._queues.get(user_id, ()))
+```
+
+- [ ] **Step 4: Run upload queue tests and verify they pass**
+
+Run:
+
+```bash
+python -m pytest tests/test_upload_queue.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Write failing settings tests**
+
+In `tests/test_auth_settings.py`, extend `test_settings_new_fields_defaults`:
+
+```python
+    assert settings.upload_queue_max_files_per_user == 5
+    assert settings.upload_queue_max_bytes_per_user is None
+    assert settings.effective_upload_queue_max_bytes_per_user == 5 * 20 * 1024 * 1024
+```
+
+Extend `test_settings_explicit_override_new_fields` payload:
+
+```python
+        "UPLOAD_QUEUE_MAX_FILES_PER_USER": 2,
+        "UPLOAD_QUEUE_MAX_BYTES_PER_USER": 1234,
+```
+
+Extend the assertions in that same test:
+
+```python
+    assert settings.upload_queue_max_files_per_user == 2
+    assert settings.upload_queue_max_bytes_per_user == 1234
+    assert settings.effective_upload_queue_max_bytes_per_user == 1234
+```
+
+Add this test near the other settings validation tests:
+
+```python
+def test_settings_allows_upload_queue_disabled_with_zero_files() -> None:
+    settings = Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_FILES_PER_USER": 0})
+
+    assert settings.upload_queue_max_files_per_user == 0
+    assert settings.effective_upload_queue_max_bytes_per_user == 0
+
+
+def test_settings_rejects_invalid_upload_queue_values() -> None:
+    with pytest.raises(ValidationError):
+        Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_FILES_PER_USER": -1})
+    with pytest.raises(ValidationError):
+        Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_BYTES_PER_USER": 0})
+```
+
+Extend `test_env_example_contains_new_entries`:
+
+```python
+    assert "UPLOAD_MAX_FILE_SIZE_MB=20" in content
+    assert "UPLOAD_QUEUE_MAX_FILES_PER_USER=5" in content
+    assert "UPLOAD_QUEUE_MAX_BYTES_PER_USER=" in content
+```
+
+- [ ] **Step 6: Run settings tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_auth_settings.py::test_settings_new_fields_defaults tests/test_auth_settings.py::test_settings_explicit_override_new_fields tests/test_auth_settings.py::test_settings_allows_upload_queue_disabled_with_zero_files tests/test_auth_settings.py::test_settings_rejects_invalid_upload_queue_values tests/test_auth_settings.py::test_env_example_contains_new_entries -q
+```
+
+Expected: FAIL because the new settings and `.env.example` entries do not exist yet.
+
+- [ ] **Step 7: Implement upload queue settings**
+
+In `app/config/settings.py`, add these fields after `upload_max_file_size_mb`:
+
+```python
+    upload_queue_max_files_per_user: int = Field(5, alias="UPLOAD_QUEUE_MAX_FILES_PER_USER")
+    upload_queue_max_bytes_per_user: int | None = Field(None, alias="UPLOAD_QUEUE_MAX_BYTES_PER_USER")
+```
+
+Add this validator after `validate_positive_int`:
+
+```python
+    @field_validator("upload_queue_max_files_per_user")
+    @classmethod
+    def validate_non_negative_int(cls, value: int) -> int:
+        if value < 0:
+            raise ValueError("配置值必须大于等于 0")
+        return value
+```
+
+Extend `validate_optional_positive_int` to include `upload_queue_max_bytes_per_user`:
+
+```python
+    @field_validator("rate_limit_bucket_ttl_sec", "permission_lock_ttl_sec", "upload_queue_max_bytes_per_user", mode="before")
+```
+
+Add this property after `effective_permission_lock_ttl_sec`:
+
+```python
+    @property
+    def effective_upload_queue_max_bytes_per_user(self) -> int:
+        if self.upload_queue_max_bytes_per_user is not None:
+            return self.upload_queue_max_bytes_per_user
+        return self.upload_queue_max_files_per_user * self.upload_max_file_size_mb * 1024 * 1024
+```
+
+In `deploy/env/.env.example`, add this section after line `TASK_OUTPUT_CHAR_LIMIT=120000`:
+
+```dotenv
+
+# File upload
+UPLOAD_MAX_FILE_SIZE_MB=20
+UPLOAD_QUEUE_MAX_FILES_PER_USER=5
+# Blank means UPLOAD_QUEUE_MAX_FILES_PER_USER * UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
+UPLOAD_QUEUE_MAX_BYTES_PER_USER=
+```
+
+- [ ] **Step 8: Run settings and upload queue tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_upload_queue.py tests/test_auth_settings.py::test_settings_new_fields_defaults tests/test_auth_settings.py::test_settings_explicit_override_new_fields tests/test_auth_settings.py::test_settings_allows_upload_queue_disabled_with_zero_files tests/test_auth_settings.py::test_settings_rejects_invalid_upload_queue_values tests/test_auth_settings.py::test_env_example_contains_new_entries -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 9: Commit Task 1**
+
+```bash
+git add app/services/upload_queue.py app/config/settings.py deploy/env/.env.example tests/test_upload_queue.py tests/test_auth_settings.py
+git commit -m "feat: add bounded upload queue settings"
+```
+
+---
+
+### Task 2: File upload handler queue limits and pre-download size rejection
+
+**Files:**
+- Modify: `app/bot/handlers/file_upload.py:17-180`
+- Modify: `app/bot/router.py:50-177`
+- Modify: `app/bootstrap.py:134-139,279-296`
+- Modify: `tests/test_file_upload_handler.py`
+- Modify: `tests/test_auth_settings.py:425-457`
+
+- [ ] **Step 1: Write failing file upload handler tests**
+
+In `tests/test_file_upload_handler.py`, replace imports of `_pending_uploads` with `UploadQueueManager`:
+
+```python
+from app.services.upload_queue import UploadQueueManager
+```
+
+Add this helper below `_make_services()`:
+
+```python
+class DummyRouter:
+    def __init__(self) -> None:
+        self.handlers = []
+
+    def message(self, *args, **kwargs):
+        def decorator(fn):
+            self.handlers.append(fn)
+            return fn
+
+        return decorator
+
+
+def _register_upload_handlers(*, max_file_size_mb: int = 20, max_files: int = 5, max_bytes: int | None = None):
+    from app.bot.handlers.file_upload import register_file_upload_handler
+
+    file_receiver, session_service, task_service = _make_services()
+    queue = UploadQueueManager(
+        max_files_per_user=max_files,
+        max_bytes_per_user=max_bytes if max_bytes is not None else max_files * max_file_size_mb * 1024 * 1024,
+    )
+    router = DummyRouter()
+    register_file_upload_handler(
+        router,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        task_service=task_service,
+        upload_queue=queue,
+        upload_max_file_size_mb=max_file_size_mb,
+    )
+    return router.handlers[0], router.handlers[1], queue, file_receiver, session_service, task_service
+```
+
+Remove the `clear_pending_uploads` fixture and add these tests:
+
+```python
+@pytest.mark.asyncio
+async def test_document_size_metadata_rejects_before_download() -> None:
+    document_handler, _, _, _, _, _ = _register_upload_handlers(max_file_size_mb=1)
+    message = _make_message()
+    message.document = MagicMock()
+    message.document.file_name = "big.txt"
+    message.document.file_id = "file123"
+    message.document.file_size = 2 * 1024 * 1024
+    bot = AsyncMock()
+    message.bot = bot
+
+    await document_handler(message)
+
+    bot.get_file.assert_not_called()
+    bot.download_file.assert_not_called()
+    assert message.answer.await_args_list
+    assert "文件被拒绝" in message.answer.await_args_list[0].args[0]
+    assert "1 MB" in message.answer.await_args_list[0].args[0]
+
+
+@pytest.mark.asyncio
+async def test_photo_size_metadata_rejects_before_download() -> None:
+    _, photo_handler, _, _, _, _ = _register_upload_handlers(max_file_size_mb=1)
+    message = _make_message()
+    photo = MagicMock()
+    photo.file_unique_id = "unique-photo"
+    photo.file_id = "photo123"
+    photo.file_size = 2 * 1024 * 1024
+    message.photo = [photo]
+    bot = AsyncMock()
+    message.bot = bot
+
+    await photo_handler(message)
+
+    bot.get_file.assert_not_called()
+    bot.download_file.assert_not_called()
+    assert "文件被拒绝" in message.answer.await_args_list[0].args[0]
+
+
+@pytest.mark.asyncio
+async def test_running_task_queue_reply_mentions_restart_loss() -> None:
+    document_handler, _, queue, _, _, task_service = _register_upload_handlers(max_file_size_mb=1)
+    running_task = MagicMock(spec=TaskRecord)
+    running_task.status = TaskStatus.RUNNING
+    task_service.list_recent = AsyncMock(return_value=[running_task])
+
+    message = _make_message()
+    message.document = MagicMock()
+    message.document.file_name = "queued.txt"
+    message.document.file_id = "file123"
+    message.document.file_size = 10
+    bot = AsyncMock()
+    message.bot = bot
+    file_obj = MagicMock()
+    file_obj.file_path = "documents/queued.txt"
+    bot.get_file = AsyncMock(return_value=file_obj)
+    bot.download_file = AsyncMock(return_value=io.BytesIO(b"queued"))
+
+    await document_handler(message)
+
+    assert await queue.queued_count(user_id=42) == 1
+    reply = message.answer.await_args_list[0].args[0]
+    assert "已加入队列" in reply
+    assert "bot 重启" in reply
+    assert "丢失" in reply
+
+
+@pytest.mark.asyncio
+async def test_running_task_rejects_when_queue_count_limit_reached() -> None:
+    document_handler, _, queue, _, _, task_service = _register_upload_handlers(max_file_size_mb=1, max_files=1)
+    running_task = MagicMock(spec=TaskRecord)
+    running_task.status = TaskStatus.RUNNING
+    task_service.list_recent = AsyncMock(return_value=[running_task])
+    await queue.enqueue(user_id=42, filename="existing.txt", data=b"x")
+
+    message = _make_message()
+    message.document = MagicMock()
+    message.document.file_name = "second.txt"
+    message.document.file_id = "file456"
+    message.document.file_size = 10
+    bot = AsyncMock()
+    message.bot = bot
+    file_obj = MagicMock()
+    file_obj.file_path = "documents/second.txt"
+    bot.get_file = AsyncMock(return_value=file_obj)
+    bot.download_file = AsyncMock(return_value=io.BytesIO(b"second"))
+
+    await document_handler(message)
+
+    assert await queue.queued_count(user_id=42) == 1
+    reply = message.answer.await_args_list[0].args[0]
+    assert "文件未加入队列" in reply
+    assert "队列已满" in reply
+
+
+@pytest.mark.asyncio
+async def test_running_task_rejects_downloaded_file_over_size_limit_before_queueing() -> None:
+    document_handler, _, queue, _, _, task_service = _register_upload_handlers(max_file_size_mb=1)
+    running_task = MagicMock(spec=TaskRecord)
+    running_task.status = TaskStatus.RUNNING
+    task_service.list_recent = AsyncMock(return_value=[running_task])
+
+    message = _make_message()
+    message.document = MagicMock()
+    message.document.file_name = "big.txt"
+    message.document.file_id = "file789"
+    message.document.file_size = None
+    bot = AsyncMock()
+    message.bot = bot
+    file_obj = MagicMock()
+    file_obj.file_path = "documents/big.txt"
+    bot.get_file = AsyncMock(return_value=file_obj)
+    bot.download_file = AsyncMock(return_value=io.BytesIO(b"x" * (1024 * 1024 + 1)))
+
+    await document_handler(message)
+
+    assert await queue.queued_count(user_id=42) == 0
+    reply = message.answer.await_args_list[0].args[0]
+    assert "文件被拒绝" in reply
+    assert "1 MB" in reply
+```
+
+- [ ] **Step 2: Run new file upload tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_file_upload_handler.py::test_document_size_metadata_rejects_before_download tests/test_file_upload_handler.py::test_photo_size_metadata_rejects_before_download tests/test_file_upload_handler.py::test_running_task_queue_reply_mentions_restart_loss tests/test_file_upload_handler.py::test_running_task_rejects_when_queue_count_limit_reached tests/test_file_upload_handler.py::test_running_task_rejects_downloaded_file_over_size_limit_before_queueing -q
+```
+
+Expected: FAIL because `register_file_upload_handler` does not accept `upload_queue` and `upload_max_file_size_mb`.
+
+- [ ] **Step 3: Implement file upload handler changes**
+
+In `app/bot/handlers/file_upload.py`, remove `_pending_uploads` and import the queue service:
+
+```python
+from app.services.upload_queue import UploadQueueManager
+```
+
+Add these helpers after `_format_size`:
+
+```python
+def _max_upload_size_bytes(upload_max_file_size_mb: int) -> int:
+    return upload_max_file_size_mb * 1024 * 1024
+
+
+def _metadata_exceeds_limit(file_size: int | None, *, max_size_bytes: int) -> bool:
+    return file_size is not None and file_size > max_size_bytes
+
+
+async def _answer_oversized(message: Message, *, filename: str, size_bytes: int, upload_max_file_size_mb: int) -> None:
+    await message.answer(
+        f"❌ 文件被拒绝: {filename}\n原因: 文件大小 {_format_size(size_bytes)} 超过 {upload_max_file_size_mb} MB 限制。"
+    )
+```
+
+Change `process_pending_uploads` to drain from the manager:
+
+```python
+async def process_pending_uploads(
+    message: Message,
+    *,
+    file_receiver: FileReceiverService,
+    session_service: SessionService,
+    upload_queue: UploadQueueManager,
+    user_id: int,
+) -> None:
+    """Process queued uploads for a user after their task completes."""
+    pending = await upload_queue.drain(user_id=user_id)
+    for item in pending:
+        try:
+            await _process_upload(
+                message,
+                file_receiver=file_receiver,
+                session_service=session_service,
+                filename=item.filename,
+                data=item.data,
+            )
+        except Exception:
+            logger.exception("queued upload processing failed", extra={"user_id": user_id, "filename": item.filename})
+```
+
+Change `register_file_upload_handler` signature:
+
+```python
+def register_file_upload_handler(
+    router: Router,
+    *,
+    file_receiver: FileReceiverService,
+    session_service: SessionService,
+    task_service: TaskService,
+    upload_queue: UploadQueueManager,
+    upload_max_file_size_mb: int,
+) -> None:
+```
+
+At the start of `handle_document`, after `filename = ...`, add:
+
+```python
+        max_size_bytes = _max_upload_size_bytes(upload_max_file_size_mb)
+        if _metadata_exceeds_limit(document.file_size, max_size_bytes=max_size_bytes):
+            await _answer_oversized(
+                message,
+                filename=filename,
+                size_bytes=document.file_size or 0,
+                upload_max_file_size_mb=upload_max_file_size_mb,
+            )
+            return
+```
+
+After downloading document data and before queueing/direct processing, add:
+
+```python
+        if len(data) > max_size_bytes:
+            await _answer_oversized(
+                message,
+                filename=filename,
+                size_bytes=len(data),
+                upload_max_file_size_mb=upload_max_file_size_mb,
+            )
+            return
+```
+
+Replace document queueing logic with:
+
+```python
+        if await _user_has_running_task(task_service, user_id):
+            queued = await upload_queue.enqueue(user_id=user_id, filename=filename, data=data)
+            if not queued.accepted:
+                await message.answer(f"❌ 文件未加入队列: {filename}\n原因: {queued.reason}")
+                return
+            await message.answer(
+                f"⏳ 任务运行中,文件 {filename} 已加入队列,将在任务完成后处理。\n"
+                "注意:队列仅保存在内存中,如果 bot 在任务完成前重启,已排队文件会丢失。"
+            )
+            return
+```
+
+In `handle_photo`, after `filename = ...`, add the same metadata and downloaded-size checks, using `photo.file_size` and `filename`. Replace the photo queueing block with the same `upload_queue.enqueue(...)` logic.
+
+- [ ] **Step 4: Inject upload queue through router and container**
+
+In `app/bot/router.py`, import `UploadQueueManager` for runtime type use:
+
+```python
+from app.services.upload_queue import UploadQueueManager
+```
+
+Add `upload_queue` to `create_router` parameters:
+
+```python
+    upload_queue: UploadQueueManager | None = None,
+```
+
+Change the file upload registration condition and call:
+
+```python
+    if file_receiver is not None and upload_queue is not None:
+        register_file_upload_handler(
+            router,
+            file_receiver=file_receiver,
+            session_service=session_service,
+            task_service=task_service,
+            upload_queue=upload_queue,
+            upload_max_file_size_mb=settings.upload_max_file_size_mb,
+        )
+```
+
+In `app/bootstrap.py`, import and create the queue:
+
+```python
+from app.services.upload_queue import UploadQueueManager
+```
+
+After `self.file_receiver = FileReceiverService(...)`, add:
+
+```python
+        self.upload_queue = UploadQueueManager(
+            max_files_per_user=settings.upload_queue_max_files_per_user,
+            max_bytes_per_user=settings.effective_upload_queue_max_bytes_per_user,
+        )
+```
+
+Pass it to `create_router`:
+
+```python
+            upload_queue=self.upload_queue,
+```
+
+- [ ] **Step 5: Add container wiring assertion**
+
+In `tests/test_auth_settings.py::test_container_wiring_passes_settings_to_task_store`, after `container = AppContainer(settings)`, add:
+
+```python
+    assert container.upload_queue._max_files_per_user == settings.upload_queue_max_files_per_user
+    assert container.upload_queue._max_bytes_per_user == settings.effective_upload_queue_max_bytes_per_user
+```
+
+- [ ] **Step 6: Run file upload and container tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_file_upload_handler.py tests/test_auth_settings.py::test_container_wiring_passes_settings_to_task_store -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit Task 2**
+
+```bash
+git add app/bot/handlers/file_upload.py app/bot/router.py app/bootstrap.py tests/test_file_upload_handler.py tests/test_auth_settings.py
+git commit -m "fix: bound queued upload memory usage"
+```
+
+---
+
+### Task 3: Background queued upload processing after final task messages
+
+**Files:**
+- Modify: `app/bot/handlers/file_upload.py`
+- Modify: `app/bot/handlers/run_event_streamer.py:85-113,224-298`
+- Modify: `app/bot/handlers/command_run.py:43-180,183-209`
+- Modify: `app/bot/router.py:107-177,218-228`
+- Create: `tests/test_run_event_streamer_upload_queue.py`
+
+- [ ] **Step 1: Write failing streamer scheduling tests**
+
+Create `tests/test_run_event_streamer_upload_queue.py`:
+
+```python
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.bot.handlers.command_run import run_prompt_and_stream
+from app.bot.presenters.chunk_sender import ChunkSender
+from app.domain.file_models import FileUploadResult, FileValidationError
+from app.domain.models import CLIEvent, EventType, TaskRecord, TaskStatus
+from app.services.upload_queue import UploadQueueManager
+from tests.fakes.telegram import DummyMessage
+
+
+class DummyTaskService:
+    def __init__(self, events: list[CLIEvent], status: TaskRecord) -> None:
+        self._events = events
+        self._status = status
+
+    async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+        task = SimpleNamespace(
+            task_id="task-queued-1",
+            provider="claude_code",
+            session_id="session-1",
+            workdir=workdir or "/tmp/work",
+            started_at=None,
+            created_at=None,
+        )
+        return SimpleNamespace(task=task, events=self._stream(), interactive=False)
+
+    async def get_status(self, task_id: str, user_id: int):
+        return self._status
+
+    async def get_structured_session(self, user_id: int, *, log_missing: bool = True):
+        return None
+
+    async def get_structured_session_for_task(self, *, task_id: str, user_id: int, log_missing: bool = True):
+        return None
+
+    async def get_structured_session_cursor(self, user_id: int, *, task_id: str | None = None) -> int:
+        return 0
+
+    async def get_structured_reply_cursor(self, user_id: int, *, task_id: str | None = None):
+        return None, None
+
+    async def acknowledge_structured_reply(self, user_id: int, **kwargs) -> None:
+        return None
+
+    async def get_structured_user_question_cursor(self, user_id: int, *, task_id: str | None = None):
+        return None
+
+    async def acknowledge_structured_user_question(self, user_id: int, **kwargs) -> None:
+        return None
+
+    async def wait_for_structured_session_update(self, **kwargs) -> bool:
+        await asyncio.sleep(0.01)
+        return False
+
+    async def _stream(self):
+        for event in self._events:
+            yield event
+
+
+@pytest.mark.asyncio
+async def test_queued_upload_scheduler_runs_after_success_message_is_displayed(tmp_path: Path) -> None:
+    events = [CLIEvent(type=EventType.STARTED, task_id="task-queued-1"), CLIEvent(type=EventType.EXITED, task_id="task-queued-1", exit_code=0)]
+    status = TaskRecord(
+        task_id="task-queued-1",
+        session_id="session-1",
+        user_id=1,
+        provider="claude_code",
+        prompt="hello",
+        workdir=str(tmp_path),
+        timeout_sec=60,
+        status=TaskStatus.SUCCEEDED,
+    )
+    service = DummyTaskService(events=events, status=status)
+    message = DummyMessage()
+    scheduler_calls: list[tuple[int, str]] = []
+
+    def queued_upload_scheduler(root_message, user_id: int) -> None:
+        scheduler_calls.append((user_id, root_message.sent_messages[0].text))
+
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=1,
+        provider="claude_code",
+        prompt="hello",
+        workdir=str(tmp_path),
+        queued_upload_scheduler=queued_upload_scheduler,
+    )
+    assert task is not None
+    await task
+
+    assert scheduler_calls == [(1, message.sent_messages[0].text)]
+    assert "✅ 完成" in scheduler_calls[0][1]
+
+
+@pytest.mark.asyncio
+async def test_queued_upload_processing_continues_after_failed_file(tmp_path: Path) -> None:
+    from app.bot.handlers.file_upload import schedule_pending_upload_processing
+
+    queue = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=100)
+    await queue.enqueue(user_id=1, filename="bad.exe", data=b"bad")
+    await queue.enqueue(user_id=1, filename="good.txt", data=b"good")
+
+    message = DummyMessage(user_id=1)
+    session_service = AsyncMock()
+    session = SimpleNamespace(workdir=str(tmp_path))
+    session_service.get = AsyncMock(return_value=session)
+    file_receiver = AsyncMock()
+    file_receiver.receive_file = AsyncMock(
+        side_effect=[
+            FileValidationError(filename="bad.exe", reason="Extension .exe is not allowed."),
+            FileUploadResult(filename="good.txt", size_bytes=4, path=tmp_path / ".tg-uploads" / "1" / "good.txt"),
+        ]
+    )
+
+    task = schedule_pending_upload_processing(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=queue,
+        user_id=1,
+    )
+    await task
+
+    assert [call.args[0] for call in message.answer.await_args_list] == [
+        "❌ 文件被拒绝: bad.exe\n原因: Extension .exe is not allowed.",
+        "✅ 文件已接收: good.txt (4 B)",
+    ]
+    assert await queue.drain(user_id=1) == []
+```
+
+- [ ] **Step 2: Run streamer scheduling tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_run_event_streamer_upload_queue.py -q
+```
+
+Expected: FAIL because `queued_upload_scheduler` and `schedule_pending_upload_processing` do not exist.
+
+- [ ] **Step 3: Add tracked upload processing scheduler**
+
+In `app/bot/handlers/file_upload.py`, add imports:
+
+```python
+import asyncio
+```
+
+Add this module-level set after `logger = logging.getLogger(__name__)`:
+
+```python
+_ACTIVE_UPLOAD_TASKS: set[asyncio.Task[None]] = set()
+```
+
+Add this function after `process_pending_uploads`:
+
+```python
+def schedule_pending_upload_processing(
+    message: Message,
+    *,
+    file_receiver: FileReceiverService,
+    session_service: SessionService,
+    upload_queue: UploadQueueManager,
+    user_id: int,
+) -> asyncio.Task[None]:
+    task = asyncio.create_task(
+        process_pending_uploads(
+            message,
+            file_receiver=file_receiver,
+            session_service=session_service,
+            upload_queue=upload_queue,
+            user_id=user_id,
+        )
+    )
+    _ACTIVE_UPLOAD_TASKS.add(task)
+
+    def _on_done(done_task: asyncio.Task[None]) -> None:
+        _ACTIVE_UPLOAD_TASKS.discard(done_task)
+        if done_task.cancelled():
+            return
+        exc = done_task.exception()
+        if exc is None:
+            return
+        logger.error(
+            "queued upload background task failed",
+            extra={"user_id": user_id, "error": str(exc)},
+            exc_info=(type(exc), exc, exc.__traceback__),
+        )
+
+    task.add_done_callback(_on_done)
+    return task
+```
+
+- [ ] **Step 4: Thread scheduler into run streaming**
+
+In `app/bot/handlers/run_event_streamer.py`, import `Callable`:
+
+```python
+from collections.abc import Callable
+```
+
+Add a constructor parameter and field:
+
+```python
+        queued_upload_scheduler: Callable[[], None] | None = None,
+```
+
+```python
+        self._queued_upload_scheduler = queued_upload_scheduler
+        self._queued_upload_scheduled = False
+```
+
+Add this method before `stream_events`:
+
+```python
+    def _schedule_queued_uploads_once(self) -> None:
+        if self._queued_upload_scheduled or self._queued_upload_scheduler is None:
+            return
+        self._queued_upload_scheduled = True
+        try:
+            self._queued_upload_scheduler()
+        except Exception:
+            logger.exception("failed to schedule queued upload processing", extra={"user_id": self._user_id})
+```
+
+In the `EXITED` branch, immediately after sending/editing `success_msg`, add:
+
+```python
+                    self._schedule_queued_uploads_once()
+```
+
+In the `{FAILED, TIMEOUT, CANCELED}` branch, immediately after sending/editing `error_msg`, add:
+
+```python
+                    self._schedule_queued_uploads_once()
+```
+
+In `app/bot/handlers/command_run.py`, import `Callable` and add a parameter to `run_prompt_and_stream`:
+
+```python
+from collections.abc import Callable
+```
+
+```python
+    queued_upload_scheduler: Callable[[Message, int], None] | None = None,
+```
+
+Pass this to `RunEventStreamer`:
+
+```python
+        queued_upload_scheduler=(
+            (lambda: queued_upload_scheduler(message, user_id)) if queued_upload_scheduler is not None else None
+        ),
+```
+
+Add `queued_upload_scheduler` to `register_run_handler` and pass it through to `run_prompt_and_stream`.
+
+In `app/bot/router.py`, import the scheduler:
+
+```python
+from app.bot.handlers.file_upload import register_file_upload_handler, schedule_pending_upload_processing
+```
+
+Before `register_run_handler(...)`, build a scheduler only when upload dependencies exist:
+
+```python
+    queued_upload_scheduler = None
+    if file_receiver is not None and upload_queue is not None:
+        queued_upload_scheduler = lambda message, user_id: schedule_pending_upload_processing(
+            message,
+            file_receiver=file_receiver,
+            session_service=session_service,
+            upload_queue=upload_queue,
+            user_id=user_id,
+        )
+```
+
+Pass it to `register_run_handler`:
+
+```python
+        queued_upload_scheduler=queued_upload_scheduler,
+```
+
+Pass it to the plain text Claude chat `run_prompt_and_stream(...)` call:
+
+```python
+            queued_upload_scheduler=queued_upload_scheduler,
+```
+
+- [ ] **Step 5: Run upload streaming tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_run_event_streamer_upload_queue.py tests/test_run_event_streamer_diff.py tests/test_command_run.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Task 3**
+
+```bash
+git add app/bot/handlers/file_upload.py app/bot/handlers/run_event_streamer.py app/bot/handlers/command_run.py app/bot/router.py tests/test_run_event_streamer_upload_queue.py
+git commit -m "fix: process queued uploads after task completion"
+```
+
+---
+
+### Task 4: Permission callback token registry
+
+**Files:**
+- Create: `app/services/permission_callback_registry.py`
+- Create: `tests/test_permission_callback_registry.py`
+
+- [ ] **Step 1: Write failing registry tests**
+
+Create `tests/test_permission_callback_registry.py`:
+
+```python
+from __future__ import annotations
+
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+
+
+def test_registry_resolves_full_tool_use_id_from_short_token() -> None:
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "abc12345", clock=lambda: 100.0)
+    tool_use_id = "toolu_" + "x" * 200
+
+    token = registry.register(tool_use_id)
+
+    assert token == "abc12345"
+    assert registry.resolve(token) == tool_use_id
+    assert len(token.encode("utf-8")) < len(tool_use_id.encode("utf-8"))
+
+
+def test_registry_expires_tokens() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = PermissionCallbackRegistry(ttl_sec=10, token_factory=lambda: "token001", clock=clock)
+    token = registry.register("tool-1")
+
+    now = 111.0
+
+    assert registry.resolve(token) is None
+
+
+def test_registry_retries_live_token_collision() -> None:
+    tokens = iter(["same001", "same001", "next002"])
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: next(tokens), clock=lambda: 100.0)
+
+    first = registry.register("tool-1")
+    second = registry.register("tool-2")
+
+    assert first == "same001"
+    assert second == "next002"
+    assert registry.resolve(first) == "tool-1"
+    assert registry.resolve(second) == "tool-2"
+```
+
+- [ ] **Step 2: Run registry tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_permission_callback_registry.py -q
+```
+
+Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.permission_callback_registry'`.
+
+- [ ] **Step 3: Implement registry**
+
+Create `app/services/permission_callback_registry.py`:
+
+```python
+from __future__ import annotations
+
+import secrets
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class _PermissionCallbackEntry:
+    tool_use_id: str
+    expires_at: float
+
+
+class PermissionCallbackRegistry:
+    def __init__(
+        self,
+        *,
+        ttl_sec: int,
+        token_factory: Callable[[], str] | None = None,
+        clock: Callable[[], float] | None = None,
+    ) -> None:
+        if ttl_sec <= 0:
+            raise ValueError("ttl_sec must be positive")
+        self._ttl_sec = ttl_sec
+        self._token_factory = token_factory or (lambda: secrets.token_urlsafe(6))
+        self._clock = clock or time.monotonic
+        self._entries: dict[str, _PermissionCallbackEntry] = {}
+
+    def register(self, tool_use_id: str) -> str:
+        self._prune_expired()
+        for _ in range(16):
+            token = self._token_factory()
+            if token not in self._entries:
+                self._entries[token] = _PermissionCallbackEntry(
+                    tool_use_id=tool_use_id,
+                    expires_at=self._clock() + self._ttl_sec,
+                )
+                return token
+        raise RuntimeError("failed to generate unique permission callback token")
+
+    def resolve(self, token: str) -> str | None:
+        self._prune_expired()
+        entry = self._entries.get(token)
+        if entry is None:
+            return None
+        return entry.tool_use_id
+
+    def _prune_expired(self) -> None:
+        now = self._clock()
+        expired = [token for token, entry in self._entries.items() if entry.expires_at <= now]
+        for token in expired:
+            self._entries.pop(token, None)
+```
+
+- [ ] **Step 4: Run registry tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_permission_callback_registry.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit Task 4**
+
+```bash
+git add app/services/permission_callback_registry.py tests/test_permission_callback_registry.py
+git commit -m "feat: add permission callback token registry"
+```
+
+---
+
+### Task 5: Normal permission callbacks use short tokens
+
+**Files:**
+- Modify: `app/bot/handlers/command_permission.py:18-202`
+- Modify: `app/bot/router.py:50-124`
+- Modify: `app/bootstrap.py:101-107,199-204,279-296`
+- Modify: `tests/test_session_handlers.py:250-397`
+- Modify: `tests/test_bootstrap_hooks.py`
+
+- [ ] **Step 1: Write failing normal permission callback tests**
+
+In `tests/test_session_handlers.py`, import the registry:
+
+```python
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+Add this test before `test_permission_callback_handler_approves_pending_request`:
+
+```python
+def test_permission_callback_data_uses_short_token_for_long_tool_use_id() -> None:
+    from app.bot.handlers.command_permission import build_permission_callback_data, build_permission_keyboard
+
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    long_tool_use_id = "toolu_" + "x" * 200
+
+    keyboard = build_permission_keyboard(tool_use_id=long_tool_use_id, permission_callback_registry=registry)
+    callback_data = [button.callback_data for row in keyboard.inline_keyboard for button in row]
+
+    assert callback_data == [
+        "perm:allow:tok12345",
+        "perm:deny:tok12345",
+        "perm:auto_approve:tok12345",
+    ]
+    assert all(data is not None and len(data.encode("utf-8")) <= 64 for data in callback_data)
+    assert registry.resolve("tok12345") == long_tool_use_id
+    assert build_permission_callback_data(decision="allow", token="tok12345") == "perm:allow:tok12345"
+```
+
+Update `test_permission_callback_handler_approves_pending_request` registration and callback setup:
+
+```python
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    token = registry.register("tool-1")
+    router = DummyRouter()
+    register_permission_handlers(router, task_service=service, permission_callback_registry=registry)
+    callback_handler = router.callback_handlers[0]
+    message = DummyMessage("权限请求")
+    callback = DummyCallbackQuery(f"perm:allow:{token}", message=message)
+```
+
+Update `test_permission_callback_handler_rejects_stale_button` to pass an empty registry and assert the new recovery message:
+
+```python
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    router = DummyRouter()
+    register_permission_handlers(router, task_service=service, permission_callback_registry=registry)
+    callback_handler = router.callback_handlers[0]
+    message = DummyMessage("权限请求")
+    callback = DummyCallbackQuery("perm:allow:missing", message=message)
+```
+
+Replace its assertions:
+
+```python
+    assert hook_socket_server.calls == []
+    assert "权限按钮已失效" in message.answers[0]
+    assert "重新触发" in message.answers[0]
+    assert message.edited_reply_markups == []
+    assert callback.answers == [(message.answers[0], True)]
+```
+
+Update `test_permission_callback_handler_rejects_cross_user_button` to register `tool-1` and pass the token:
+
+```python
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    token = registry.register("tool-1")
+    router = DummyRouter()
+    register_permission_handlers(router, task_service=service, permission_callback_registry=registry)
+    callback_handler = router.callback_handlers[0]
+    message = DummyMessage("权限请求", user_id=2)
+    callback = DummyCallbackQuery(f"perm:allow:{token}", user_id=2, message=message)
+```
+
+- [ ] **Step 2: Run normal permission tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_session_handlers.py::test_permission_callback_data_uses_short_token_for_long_tool_use_id tests/test_session_handlers.py::test_permission_callback_handler_approves_pending_request tests/test_session_handlers.py::test_permission_callback_handler_rejects_stale_button tests/test_session_handlers.py::test_permission_callback_handler_rejects_cross_user_button -q
+```
+
+Expected: FAIL because handler signatures and callback parsing still use `tool_use_id` directly.
+
+- [ ] **Step 3: Implement normal callback token use**
+
+In `app/bot/handlers/command_permission.py`, import the registry:
+
+```python
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+Add stale text near constants:
+
+```python
+_STALE_PERMISSION_CALLBACK_TEXT = "权限按钮已失效:请求可能已过期或 bot 已重启。请重新触发操作,或等待 Claude 再次请求权限。"
+```
+
+Replace `build_permission_callback_data`:
+
+```python
+def build_permission_callback_data(*, decision: str, token: str) -> str:
+    return f"{_PERMISSION_CALLBACK_PREFIX}:{decision}:{token}"
+```
+
+Keep `parse_permission_callback_data` but rename the parsed third value in local variables to `token`:
+
+```python
+    decision, sep, token = rest.partition(":")
+    if not sep or decision not in {"allow", "deny", "auto_approve"} or not token:
+        return None
+    return decision, token
+```
+
+Change `build_permission_keyboard`:
+
+```python
+def build_permission_keyboard(*, tool_use_id: str, permission_callback_registry: PermissionCallbackRegistry) -> InlineKeyboardMarkup:
+    token = permission_callback_registry.register(tool_use_id)
+    return InlineKeyboardMarkup(
+        inline_keyboard=[
+            [
+                InlineKeyboardButton(
+                    text="允许",
+                    callback_data=build_permission_callback_data(decision="allow", token=token),
+                ),
+                InlineKeyboardButton(
+                    text="拒绝",
+                    callback_data=build_permission_callback_data(decision="deny", token=token),
+                ),
+            ],
+            [
+                InlineKeyboardButton(
+                    text="不再询问,全部允许",
+                    callback_data=build_permission_callback_data(decision="auto_approve", token=token),
+                ),
+            ],
+        ]
+    )
+```
+
+Add `permission_callback_registry` to `register_permission_handlers` parameters:
+
+```python
+    permission_callback_registry: PermissionCallbackRegistry,
+```
+
+In `callback_permission`, resolve token before calling services:
+
+```python
+        decision, token = parsed
+        tool_use_id = permission_callback_registry.resolve(token)
+        if tool_use_id is None:
+            if callback.message is not None:
+                await callback.message.answer(_STALE_PERMISSION_CALLBACK_TEXT)
+            await callback.answer(_STALE_PERMISSION_CALLBACK_TEXT, show_alert=True)
+            return
+```
+
+Remove the prefix-matching loop from `_resolve_session_id_for_tool_use_id`; the fallback should only check the exact key:
+
+```python
+        if hook_socket_server is not None:
+            async with hook_socket_server._lock:
+                pending = hook_socket_server._pending_permissions.get(tool_use_id)
+                if pending is not None:
+                    return pending.session_id
+```
+
+- [ ] **Step 4: Inject registry through router and container**
+
+In `app/bot/router.py`, import the registry and add a parameter:
+
+```python
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+```python
+    permission_callback_registry: PermissionCallbackRegistry | None = None,
+```
+
+Only register normal permission handlers when the registry exists:
+
+```python
+    if permission_callback_registry is not None:
+        register_permission_handlers(
+            router,
+            task_service=task_service,
+            auto_approve_service=auto_approve_service,
+            hook_socket_server=hook_socket_server,
+            structured_session_store=structured_session_store,
+            permission_callback_registry=permission_callback_registry,
+        )
+```
+
+In `app/bootstrap.py`, import and instantiate:
+
+```python
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+After `self.hook_socket_server = HookSocketServer(...)`, add:
+
+```python
+        self.permission_callback_registry = PermissionCallbackRegistry(
+            ttl_sec=settings.claude_hook_pending_permission_ttl_sec,
+        )
+```
+
+Pass it to `create_router`:
+
+```python
+            permission_callback_registry=self.permission_callback_registry,
+```
+
+- [ ] **Step 5: Update bootstrap hook tests**
+
+In `tests/test_bootstrap_hooks.py::test_container_uses_independent_session_lock_registries`, add:
+
+```python
+    assert container.permission_callback_registry._ttl_sec == settings.claude_hook_pending_permission_ttl_sec
+```
+
+- [ ] **Step 6: Run normal permission tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_permission_callback_registry.py tests/test_session_handlers.py::test_permission_callback_data_uses_short_token_for_long_tool_use_id tests/test_session_handlers.py::test_permission_callback_handler_approves_pending_request tests/test_session_handlers.py::test_permission_callback_handler_rejects_stale_button tests/test_session_handlers.py::test_permission_callback_handler_rejects_cross_user_button tests/test_bootstrap_hooks.py::test_container_uses_independent_session_lock_registries -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit Task 5**
+
+```bash
+git add app/bot/handlers/command_permission.py app/bot/router.py app/bootstrap.py tests/test_session_handlers.py tests/test_bootstrap_hooks.py
+git commit -m "fix: route permission callbacks through short tokens"
+```
+
+---
+
+### Task 6: External permission tokens and unbound pending cleanup
+
+**Files:**
+- Modify: `app/services/unbound_permission_handler.py:22-247`
+- Modify: `app/bot/handlers/external_permission.py:18-126`
+- Modify: `app/bot/router.py:162-169`
+- Modify: `app/bootstrap.py:199-204,279-296`
+- Modify: `tests/property/test_unbound_permission_properties.py`
+- Modify: `tests/integration/test_external_session_pipeline.py`
+
+- [ ] **Step 1: Write failing unbound cleanup and external token tests**
+
+In `tests/property/test_unbound_permission_properties.py`, import the registry:
+
+```python
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+Add this helper near the top:
+
+```python
+def _registry(token: str = "tok12345") -> PermissionCallbackRegistry:
+    return PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: token, clock=lambda: 100.0)
+```
+
+Update every `UnboundPermissionHandler(...)` construction in this file to include:
+
+```python
+            permission_callback_registry=_registry(),
+```
+
+Add these tests near the first-responder tests:
+
+```python
+@pytest.mark.asyncio
+async def test_response_removes_unbound_pending_and_expiry_task() -> None:
+    bot = MagicMock()
+    bot.send_message = AsyncMock()
+    hook_socket_server = MagicMock()
+    hook_socket_server.respond_to_permission = AsyncMock(return_value=True)
+    handler = UnboundPermissionHandler(
+        bot=bot,
+        hook_socket_server=hook_socket_server,
+        allowed_user_ids={1},
+        permission_callback_registry=_registry(),
+    )
+    event = HookEvent(session_id="sess", cwd="/tmp/project", event="PermissionRequest", status="waiting_for_approval", tool="Bash", tool_use_id="tool-1")
+
+    await handler.handle_unbound_permission(event)
+    result = await handler.handle_response(tool_use_id="tool-1", user_id=1, decision="allow")
+
+    assert result.accepted is True
+    assert result.forwarded is True
+    assert handler.is_unbound_permission("tool-1") is False
+    assert handler._pending == {}
+    assert handler._expiry_tasks == {}
+
+
+@pytest.mark.asyncio
+async def test_expiry_removes_unbound_pending_and_expiry_task() -> None:
+    bot = MagicMock()
+    bot.send_message = AsyncMock()
+    hook_socket_server = MagicMock()
+    hook_socket_server.respond_to_permission = AsyncMock(return_value=True)
+    handler = UnboundPermissionHandler(
+        bot=bot,
+        hook_socket_server=hook_socket_server,
+        allowed_user_ids={1},
+        permission_ttl_sec=0,
+        permission_callback_registry=_registry(),
+    )
+    event = HookEvent(session_id="sess", cwd="/tmp/project", event="PermissionRequest", status="waiting_for_approval", tool="Bash", tool_use_id="tool-expire")
+
+    await handler.handle_unbound_permission(event)
+    await asyncio.sleep(0.05)
+
+    assert handler.is_unbound_permission("tool-expire") is False
+    assert handler._pending == {}
+    assert handler._expiry_tasks == {}
+
+
+@pytest.mark.asyncio
+async def test_concurrent_unbound_responses_preserve_first_responder_wins() -> None:
+    bot = MagicMock()
+    bot.send_message = AsyncMock()
+    release = asyncio.Event()
+    hook_socket_server = MagicMock()
+
+    async def respond_to_permission(**kwargs):
+        await release.wait()
+        return True
+
+    hook_socket_server.respond_to_permission = AsyncMock(side_effect=respond_to_permission)
+    handler = UnboundPermissionHandler(
+        bot=bot,
+        hook_socket_server=hook_socket_server,
+        allowed_user_ids={1, 2},
+        permission_callback_registry=_registry(),
+    )
+    event = HookEvent(session_id="sess", cwd="/tmp/project", event="PermissionRequest", status="waiting_for_approval", tool="Bash", tool_use_id="tool-race")
+    await handler.handle_unbound_permission(event)
+
+    first = asyncio.create_task(handler.handle_response(tool_use_id="tool-race", user_id=1, decision="allow"))
+    second = asyncio.create_task(handler.handle_response(tool_use_id="tool-race", user_id=2, decision="deny"))
+    await asyncio.sleep(0)
+    release.set()
+    results = await asyncio.gather(first, second)
+
+    assert sum(1 for result in results if result.accepted) == 1
+    assert hook_socket_server.respond_to_permission.await_count == 1
+    assert handler._pending == {}
+```
+
+In `tests/integration/test_external_session_pipeline.py`, update handler construction to pass a registry, and add this test:
+
+```python
+@pytest.mark.asyncio
+async def test_unbound_permission_keyboard_uses_external_short_token() -> None:
+    mock_bot = AsyncMock()
+    mock_hook_socket = AsyncMock()
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    handler = UnboundPermissionHandler(
+        bot=mock_bot,
+        hook_socket_server=mock_hook_socket,
+        allowed_user_ids={100},
+        permission_callback_registry=registry,
+    )
+    event = _make_hook_event(
+        session_id="sess-unbound01",
+        cwd="/tmp/project",
+        event="PermissionRequest",
+        status="waiting_for_approval",
+        tool="Write",
+        tool_use_id="toolu_" + "x" * 200,
+    )
+
+    await handler.handle_unbound_permission(event)
+
+    markup = mock_bot.send_message.await_args.kwargs["reply_markup"]
+    callback_data = [button.callback_data for row in markup.inline_keyboard for button in row]
+    assert callback_data == [
+        "ext_perm:tok12345:allow",
+        "ext_perm:tok12345:deny",
+        "ext_perm:tok12345:auto_approve",
+    ]
+    assert all(data is not None and len(data.encode("utf-8")) <= 64 for data in callback_data)
+    assert registry.resolve("tok12345") == event.tool_use_id
+```
+
+- [ ] **Step 2: Run unbound tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py::test_unbound_permission_keyboard_uses_external_short_token -q
+```
+
+Expected: FAIL because `UnboundPermissionHandler` does not accept `permission_callback_registry`, still returns bool from `handle_response`, and still embeds truncated IDs.
+
+- [ ] **Step 3: Implement unbound token keyboard and response result**
+
+In `app/services/unbound_permission_handler.py`, import dataclass and registry:
+
+```python
+from dataclasses import dataclass
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+Add this result type above `UnboundPermissionHandler`:
+
+```python
+@dataclass(frozen=True, slots=True)
+class UnboundPermissionResponseResult:
+    accepted: bool
+    forwarded: bool
+```
+
+Add constructor parameter and fields:
+
+```python
+        permission_callback_registry: PermissionCallbackRegistry,
+```
+
+```python
+        self._permission_callback_registry = permission_callback_registry
+        self._state_lock = asyncio.Lock()
+```
+
+In `handle_unbound_permission`, wrap pending and expiry mutations:
+
+```python
+        async with self._state_lock:
+            self._pending[tool_use_id] = state
+            self._cancel_expiry_task_locked(tool_use_id)
+            self._expiry_tasks[tool_use_id] = asyncio.create_task(self._expire_permission(tool_use_id))
+```
+
+Replace `handle_response`:
+
+```python
+    async def handle_response(self, *, tool_use_id: str, user_id: int, decision: str) -> UnboundPermissionResponseResult:
+        async with self._state_lock:
+            state = self._pending.pop(tool_use_id, None)
+            if state is None or state.responded:
+                return UnboundPermissionResponseResult(accepted=False, forwarded=False)
+            state.responded = True
+            state.responded_by = user_id
+            self._cancel_expiry_task_locked(tool_use_id)
+
+        forwarded = await self._hook_socket_server.respond_to_permission(
+            tool_use_id=tool_use_id,
+            decision=decision,
+            reason=f"responded by user {user_id}",
+        )
+        if not forwarded:
+            logger.warning(
+                "unbound permission response forwarding failed after claim",
+                extra={"tool_use_id": tool_use_id, "user_id": user_id, "decision": decision, "session_id": state.session_id},
+            )
+        else:
+            logger.info(
+                "unbound permission responded",
+                extra={"tool_use_id": tool_use_id, "user_id": user_id, "decision": decision, "session_id": state.session_id},
+            )
+        return UnboundPermissionResponseResult(accepted=True, forwarded=forwarded)
+```
+
+Replace `_expire_permission` cleanup path:
+
+```python
+        async with self._state_lock:
+            state = self._pending.pop(tool_use_id, None)
+            self._expiry_tasks.pop(tool_use_id, None)
+            if state is None or state.responded:
+                return
+            state.responded = True
+
+        await self._hook_socket_server.respond_to_permission(
+            tool_use_id=tool_use_id,
+            decision="deny",
+            reason="no user responded within TTL",
+        )
+```
+
+Replace `_build_permission_keyboard`:
+
+```python
+    def _build_permission_keyboard(self, tool_use_id: str) -> InlineKeyboardMarkup:
+        token = self._permission_callback_registry.register(tool_use_id)
+        return InlineKeyboardMarkup(
+            inline_keyboard=[
+                [
+                    InlineKeyboardButton(text="✅ Approve", callback_data=f"ext_perm:{token}:allow"),
+                    InlineKeyboardButton(text="❌ Deny", callback_data=f"ext_perm:{token}:deny"),
+                ],
+                [
+                    InlineKeyboardButton(text="🟢 Auto-approve All", callback_data=f"ext_perm:{token}:auto_approve"),
+                ],
+            ]
+        )
+```
+
+Replace `_cancel_expiry_task` with a locked helper and a public helper:
+
+```python
+    def _cancel_expiry_task_locked(self, tool_use_id: str) -> None:
+        task = self._expiry_tasks.pop(tool_use_id, None)
+        if task is not None:
+            task.cancel()
+
+    def _cancel_expiry_task(self, tool_use_id: str) -> None:
+        task = self._expiry_tasks.pop(tool_use_id, None)
+        if task is not None:
+            task.cancel()
+```
+
+The public helper remains for existing synchronous call sites if any remain; new locked mutations use `_cancel_expiry_task_locked`.
+
+- [ ] **Step 4: Implement external callback token resolution**
+
+In `app/bot/handlers/external_permission.py`, import the registry:
+
+```python
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+```
+
+Add a parameter to `register_external_permission_handler`:
+
+```python
+    permission_callback_registry: PermissionCallbackRegistry,
+```
+
+Add stale text after `logger`:
+
+```python
+_STALE_EXTERNAL_PERMISSION_CALLBACK_TEXT = "Permission button expired or bot restarted. Trigger the action again or wait for Claude to request permission again."
+```
+
+After parsing `parts`, resolve token:
+
+```python
+        _, token, decision = parts
+        if decision not in ("allow", "deny", "auto_approve"):
+            await callback.answer("Invalid decision", show_alert=True)
+            return
+        tool_use_id = permission_callback_registry.resolve(token)
+        if tool_use_id is None:
+            await callback.answer(_STALE_EXTERNAL_PERMISSION_CALLBACK_TEXT, show_alert=True)
+            return
+```
+
+Update unbound response handling for the new result object:
+
+```python
+                result = await unbound_permission_handler.handle_response(
+                    tool_use_id=tool_use_id,
+                    user_id=user_id,
+                    decision="allow",
+                )
+                if not result.accepted:
+                    await callback.answer("Already responded by another user", show_alert=True)
+                    return
+                if not result.forwarded:
+                    await callback.answer("Permission request expired or not found", show_alert=True)
+                    return
+```
+
+Apply the same `result.accepted` / `result.forwarded` checks in the non-auto-approve unbound path.
+
+- [ ] **Step 5: Inject registry into unbound and external handlers**
+
+In `app/bootstrap.py`, pass the existing registry to `UnboundPermissionHandler`:
+
+```python
+            permission_callback_registry=self.permission_callback_registry,
+```
+
+In `app/bot/router.py`, require the registry for external permission handler registration:
+
+```python
+    if hook_socket_server is not None and unbound_permission_handler is not None and permission_callback_registry is not None:
+        register_external_permission_handler(
+            router,
+            hook_socket_server=hook_socket_server,
+            unbound_permission_handler=unbound_permission_handler,
+            external_uq_state=external_uq_state,
+            auto_approve_service=auto_approve_service,
+            permission_callback_registry=permission_callback_registry,
+        )
+```
+
+- [ ] **Step 6: Update existing tests for new response result**
+
+In `tests/property/test_unbound_permission_properties.py`, replace assertions like:
+
+```python
+assert result_first is True
+assert result is False
+```
+
+with:
+
+```python
+assert result_first.accepted is True
+assert result.accepted is False
+```
+
+In `tests/integration/test_external_session_pipeline.py`, update `first_response` and `second_response` assertions:
+
+```python
+        assert first_response.accepted is True
+        assert first_response.forwarded is True
+```
+
+```python
+        assert second_response.accepted is False
+```
+
+- [ ] **Step 7: Run external/unbound tests**
+
+Run:
+
+```bash
+python -m pytest tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit Task 6**
+
+```bash
+git add app/services/unbound_permission_handler.py app/bot/handlers/external_permission.py app/bot/router.py app/bootstrap.py tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py
+git commit -m "fix: clean unbound permission state and tokenize external callbacks"
+```
+
+---
+
+### Task 7: Tmux persistent session lock cleanup
+
+**Files:**
+- Modify: `app/adapters/process/tmux_runner.py:71-100,204-212,569-589,712-717`
+- Modify: `app/bootstrap.py:121-127`
+- Modify: `tests/test_tmux_runner.py`
+
+- [ ] **Step 1: Write failing tmux lock cleanup test**
+
+In `tests/test_tmux_runner.py`, add:
+
+```python
+@pytest.mark.asyncio
+async def test_persistent_session_locks_are_ref_counted_and_cleaned(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+    now = 0.0
+
+    def clock() -> float:
+        return now
+
+    runner = TmuxRunner(
+        data_dir=str(tmp_path),
+        session_lock_ttl_sec=1,
+        lock_cleanup_interval_sec=1,
+        lock_cleanup_batch_size=10,
+        lock_clock=clock,
+    )
+
+    async def fake_run_task(*, meta, timeout_sec: int, env, workdir: str, command: str):
+        yield CLIEvent(type=EventType.STARTED, task_id=meta.task_id)
+        yield CLIEvent(type=EventType.EXITED, task_id=meta.task_id, exit_code=0)
+
+    monkeypatch.setattr(runner, "_run_task", fake_run_task)
+
+    nonlocal_now = {"value": now}
+
+    def advance(seconds: float) -> None:
+        nonlocal now
+        now += seconds
+        nonlocal_now["value"] = now
+
+    for idx in range(3):
+        events = await _collect_events(
+            runner.run(
+                task_id=f"task-{idx}",
+                argv=["echo", "ok"],
+                workdir=str(tmp_path),
+                timeout_sec=10,
+                terminal_key=f"user-{idx}",
+            )
+        )
+        assert events[-1].type == EventType.EXITED
+        advance(2.0)
+
+    assert len(runner._session_locks) <= 1
+```
+
+If Python rejects `nonlocal now` inside `advance`, use this simpler mutable clock instead:
+
+```python
+    current = {"now": 0.0}
+
+    def clock() -> float:
+        return current["now"]
+
+    def advance(seconds: float) -> None:
+        current["now"] += seconds
+```
+
+Use only the mutable-clock version in the final test file.
+
+- [ ] **Step 2: Run tmux lock test and verify it fails**
+
+Run:
+
+```bash
+python -m pytest tests/test_tmux_runner.py::test_persistent_session_locks_are_ref_counted_and_cleaned -q
+```
+
+Expected: FAIL because `TmuxRunner` does not accept lock registry settings.
+
+- [ ] **Step 3: Implement ref-counted locks in `TmuxRunner`**
+
+In `app/adapters/process/tmux_runner.py`, import the registry and async context manager tools:
+
+```python
+from collections.abc import AsyncIterator, Callable
+from contextlib import asynccontextmanager
+from app.services.lock_registry import RefCountedLockRegistry
+```
+
+Add constructor parameters:
+
+```python
+        session_lock_ttl_sec: int = 3600,
+        lock_cleanup_interval_sec: int = 60,
+        lock_cleanup_batch_size: int = 50,
+        lock_clock: Callable[[], float] | None = None,
+```
+
+Replace `_session_locks` initialization:
+
+```python
+        self._session_locks = RefCountedLockRegistry(
+            ttl_sec=session_lock_ttl_sec,
+            cleanup_interval_sec=lock_cleanup_interval_sec,
+            cleanup_batch_size=lock_cleanup_batch_size,
+            clock=lock_clock,
+        )
+```
+
+Replace `_get_session_lock` with:
+
+```python
+    @asynccontextmanager
+    async def _session_lock(self, session_name: str) -> AsyncIterator[None]:
+        async with self._session_locks.lock(session_name):
+            yield
+```
+
+Update persistent sections:
+
+```python
+        if persistent_terminal:
+            async with self._session_lock(session_name):
+                async for event in self._run_task(meta=meta, timeout_sec=timeout_sec, env=env, workdir=workdir, command=command):
+                    yield event
+            return
+```
+
+Update `ensure_terminal`, `ensure_claude_interactive_session`, and `ensure_claude_resume_session` to use:
+
+```python
+        async with self._session_lock(session_name):
+```
+
+- [ ] **Step 4: Pass settings from container**
+
+In `app/bootstrap.py`, extend `TmuxRunner(...)` construction:
+
+```python
+            session_lock_ttl_sec=settings.session_lock_ttl_sec,
+            lock_cleanup_interval_sec=settings.lock_cleanup_interval_sec,
+            lock_cleanup_batch_size=settings.lock_cleanup_batch_size,
+```
+
+- [ ] **Step 5: Run tmux tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_tmux_runner.py tests/test_lock_registry.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Task 7**
+
+```bash
+git add app/adapters/process/tmux_runner.py app/bootstrap.py tests/test_tmux_runner.py
+git commit -m "fix: clean up tmux session locks"
+```
+
+---
+
+### Task 8: Agent file watcher cleanup
+
+**Files:**
+- Modify: `app/services/agent_file_watcher.py:28-78,120-140`
+- Create: `tests/test_agent_file_watcher.py`
+
+- [ ] **Step 1: Write failing watcher cleanup tests**
+
+Create `tests/test_agent_file_watcher.py`:
+
+```python
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from app.services.agent_file_watcher import AgentFileWatcher
+
+
+class DummySessionStore:
+    def get(self, session_id: str):
+        return None
+
+
+class DummyParser:
+    def subagent_file_path(self, *, session_id: str, agent_id: str, cwd: str):
+        raise AssertionError("subagent_file_path should not be called in these cleanup tests")
+
+    def reset_state(self, session_id: str) -> None:
+        return None
+
+
+@pytest.mark.asyncio
+async def test_forget_clears_all_seen_mtime_keys_for_session() -> None:
+    watcher = AgentFileWatcher(
+        session_store=DummySessionStore(),
+        claude_jsonl_parser=DummyParser(),
+        on_update=lambda session_id, workdir: asyncio.sleep(0),
+    )
+    watcher._seen_mtimes = {
+        "session-1:tool-a:agent-a": 1.0,
+        "session-1:tool-b:agent-b": 2.0,
+        "session-2:tool-c:agent-c": 3.0,
+    }
+    watcher._session_locks["session-1"] = asyncio.Lock()
+
+    watcher.forget("session-1")
+
+    assert watcher._seen_mtimes == {"session-2:tool-c:agent-c": 3.0}
+    assert "session-1" not in watcher._session_locks
+
+
+@pytest.mark.asyncio
+async def test_forget_defers_lock_cleanup_until_running_watcher_exits() -> None:
+    release_update = asyncio.Event()
+    update_started = asyncio.Event()
+
+    async def on_update(session_id: str, workdir: str) -> None:
+        update_started.set()
+        await release_update.wait()
+
+    watcher = AgentFileWatcher(
+        session_store=DummySessionStore(),
+        claude_jsonl_parser=DummyParser(),
+        on_update=on_update,
+    )
+
+    async def fake_watch_session(*, session_id: str, workdir: str) -> None:
+        lock = watcher._session_locks.setdefault(session_id, asyncio.Lock())
+        task = asyncio.current_task()
+        try:
+            async with lock:
+                await on_update(session_id, workdir)
+        finally:
+            watcher._cleanup_finished_session(session_id=session_id, task=task)
+
+    watcher._tasks["session-1"] = asyncio.create_task(fake_watch_session(session_id="session-1", workdir="/tmp/project"))
+    await update_started.wait()
+    watcher._seen_mtimes = {"session-1:tool-a:agent-a": 1.0}
+
+    watcher.forget("session-1")
+
+    assert "session-1:tool-a:agent-a" not in watcher._seen_mtimes
+    assert "session-1" in watcher._session_locks
+
+    release_update.set()
+    with pytest.raises(asyncio.CancelledError):
+        await watcher._tasks.get("session-1", asyncio.create_task(asyncio.sleep(0)))
+    await asyncio.sleep(0)
+
+    assert "session-1" not in watcher._session_locks
+```
+
+If the second test is brittle because the task is popped by `forget`, store the task before calling `forget`:
+
+```python
+    task = watcher._tasks["session-1"]
+    watcher.forget("session-1")
+    ...
+    with pytest.raises(asyncio.CancelledError):
+        await task
+```
+
+Use the stored-task version in the final test file.
+
+- [ ] **Step 2: Run watcher tests and verify they fail**
+
+Run:
+
+```bash
+python -m pytest tests/test_agent_file_watcher.py -q
+```
+
+Expected: FAIL because lock cleanup helpers do not exist and `forget()` only pops an exact mtime key.
+
+- [ ] **Step 3: Implement watcher cleanup helpers**
+
+In `app/services/agent_file_watcher.py`, add these helpers after `stop_all`:
+
+```python
+    def _clear_seen_mtimes_for_session(self, session_id: str) -> None:
+        prefix = f"{session_id}:"
+        stale_keys = [key for key in self._seen_mtimes if key == session_id or key.startswith(prefix)]
+        for key in stale_keys:
+            self._seen_mtimes.pop(key, None)
+
+    def _cleanup_finished_session(self, *, session_id: str, task: asyncio.Task[None] | None) -> None:
+        active_task = self._tasks.get(session_id)
+        if active_task is not None and active_task is not task:
+            return
+        if active_task is task:
+            self._tasks.pop(session_id, None)
+        self._clear_seen_mtimes_for_session(session_id)
+        self._session_locks.pop(session_id, None)
+```
+
+Replace `forget`:
+
+```python
+    def forget(self, session_id: str) -> None:
+        task = self._tasks.pop(session_id, None)
+        self._clear_seen_mtimes_for_session(session_id)
+        if task is None or task.done():
+            self._session_locks.pop(session_id, None)
+            return
+        task.cancel()
+```
+
+At the end of `stop_all`, after awaiting all tasks, add:
+
+```python
+        self._session_locks.clear()
+```
+
+Replace the watcher `finally` block:
+
+```python
+        finally:
+            self._cleanup_finished_session(session_id=session_id, task=task)
+```
+
+- [ ] **Step 4: Run watcher tests**
+
+Run:
+
+```bash
+python -m pytest tests/test_agent_file_watcher.py tests/test_bootstrap_hooks.py::test_agent_file_watcher_syncs_when_subagent_file_changes tests/test_bootstrap_hooks.py::test_start_restores_agent_file_watcher_for_existing_subagent_container -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit Task 8**
+
+```bash
+git add app/services/agent_file_watcher.py tests/test_agent_file_watcher.py
+git commit -m "fix: clean agent watcher session state"
+```
+
+---
+
+### Task 9: Final verification and cleanup
+
+**Files:**
+- Verify all modified files.
+
+- [ ] **Step 1: Run targeted test groups**
+
+Run:
+
+```bash
+python -m pytest tests/test_upload_queue.py tests/test_file_upload_handler.py tests/test_run_event_streamer_upload_queue.py tests/test_permission_callback_registry.py tests/test_session_handlers.py tests/property/test_unbound_permission_properties.py tests/integration/test_external_session_pipeline.py tests/test_tmux_runner.py tests/test_agent_file_watcher.py tests/test_auth_settings.py tests/test_bootstrap_hooks.py -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run lint and formatting checks**
+
+Run:
+
+```bash
+python -m ruff check app tests && python -m ruff format --check app tests
+```
+
+Expected: both commands PASS.
+
+- [ ] **Step 3: Run full test suite**
+
+Run:
+
+```bash
+python -m pytest -q
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Inspect final diff**
+
+Run:
+
+```bash
+git status --short && git diff --stat
+```
+
+Expected: only intentional files are modified. No temporary files or debug artifacts remain.
+
+- [ ] **Step 5: Final commit if verification changed files**
+
+If formatting changed files in Step 2, commit those changes:
+
+```bash
+git add app tests deploy/env/.env.example
+git commit -m "chore: format priority stability fixes"
+```
+
+If Step 2 did not change files, skip this commit.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Upload queue behavior, metadata size rejection, count/byte bounds, restart-loss wording, and background post-final processing are covered by Tasks 1-3.
+- Permission short tokens, callback length, stale recovery, token expiry, collision retry, and normal/external callback resolution are covered by Tasks 4-6.
+- Unbound pending cleanup, first-responder-wins, tmux lock cleanup, and agent watcher cleanup are covered by Tasks 6-8.
+- Configuration summary and rollback-oriented off switch are covered by Task 1 settings and `.env.example` changes.
+
+**No-placeholder scan:** This plan contains concrete paths, code snippets, commands, and expected outcomes for each implementation step.
+
+**Type consistency:** `UploadQueueManager`, `PermissionCallbackRegistry`, `UnboundPermissionResponseResult`, `queued_upload_scheduler`, and `effective_upload_queue_max_bytes_per_user` use the same names across tests, implementation steps, and wiring steps.
diff --git a/docs/superpowers/specs/2026-05-23-memory-ttl-limits-design.md b/docs/superpowers/specs/2026-05-23-memory-ttl-limits-design.md
new file mode 100644
index 0000000..ccac7d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-23-memory-ttl-limits-design.md
@@ -0,0 +1,264 @@
+# 内存结构 TTL 与上限设计
+
+日期:2026-05-23
+
+## 背景
+
+当前项目是 Telegram CLI Gateway,长期运行时存在若干只增不减的内存结构:
+
+- `MemoryTaskStore._tasks`:任务记录常驻内存。
+- `RateLimitMiddleware._buckets`:访问过的用户桶会保留。
+- `PermissionService._permission_locks`:每个 `tool_use_id` 的锁会保留。
+- `AppContainer` 中的 `_jsonl_sync_locks` 与 `_session_event_locks`:每个 Claude session 的锁会保留。
+
+目标是在不引入后台清理服务、不改变主要架构的前提下,为这些结构增加可配置的 TTL/容量约束,降低长期运行的内存膨胀风险。
+
+## 目标
+
+1. 任务记录默认保留 7 天且最多 1000 条。
+2. 限流桶、权限锁、session 锁不再因历史用户或历史 session 无限增长。
+3. 清理逻辑采用懒清理:在现有访问路径中顺手清理,不新增长期运行协程。
+4. 热路径清理必须有上限,避免在单个请求里扫描全部历史桶或全部历史锁。
+5. 清理行为配置化,默认启用。
+6. 不影响现有限流、权限响应、JSONL sync、session event dispatch 的语义。
+
+## 非目标
+
+- 不引入 SQLite 或其他持久化任务存储。
+- 不新增统一后台 `MemoryCleanupService`。
+- 不重构 `AppContainer`、`bootstrap_mixins.py` 或任务服务架构。
+- 不清理已经持久化到磁盘的 session 状态文件。
+
+## 配置
+
+新增配置项:
+
+- `TASK_STORE_TTL_HOURS=168`
+- `TASK_STORE_MAX_RECORDS=1000`
+- `RATE_LIMIT_BUCKET_TTL_SEC`:未设置时使用 `RATE_LIMIT_WINDOW_SEC` 的有效值。
+- `RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60`
+- `RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50`
+- `PERMISSION_LOCK_TTL_SEC`:未设置时使用 `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC` 的有效值。
+- `SESSION_LOCK_TTL_SEC=3600`
+- `LOCK_CLEANUP_INTERVAL_SEC=60`
+- `LOCK_CLEANUP_BATCH_SIZE=50`
+
+所有配置的有效值必须为正整数。`.env.example` 同步补充默认值说明。
+
+`SESSION_LOCK_TTL_SEC` 使用独立默认值,不复用 `EXTERNAL_SESSION_STALE_TIMEOUT_SEC`。外部 session stale timeout 表示“多久没收到事件就认为外部 session 失活”,session lock TTL 表示“锁条目无人持有且无人等待后多久可以回收”,两者语义不同。
+
+## 组件设计
+
+### MemoryTaskStore
+
+`MemoryTaskStore` 构造函数增加:
+
+- `max_records: int`
+- `ttl_hours: int`
+
+当前类已有 `self._lock: asyncio.Lock`。所有公开 async 方法继续先获取该锁,再读写 `_tasks`。淘汰 helper 命名为 `_evict_expired_and_overflow_locked()`;`locked` 后缀只表示“调用方已经持有 `self._lock`”。该 helper 是同步函数,执行过程中不允许 `await`,避免在遍历/删除 dict 时发生协程交错。
+
+`TaskRecord` 已有 `ended_at: datetime | None` 字段(`app/domain/models.py:75`),因此 TTL 起算不需要变更数据模型。
+
+清理策略:
+
+1. 在 `add()`、`save()`、`list_by_user()`、`iter_all()` 中调用 `_evict_expired_and_overflow_locked()`。
+2. TTL 只删除 final 状态任务,即 `SUCCEEDED`、`FAILED`、`TIMEOUT`、`CANCELED`。
+3. TTL 起算点是 `ended_at`。如果 final 任务缺少 `ended_at`,使用 `created_at` 作为兼容兜底。
+4. 未 final 的任务不会因 TTL 或容量上限被删除。
+5. 容量超过 `max_records` 时,优先删除最旧的 final 任务,排序键为 `ended_at or created_at`。
+6. 如果 final 任务不足以降到上限以下,保留未 final 任务,允许短暂超过上限以避免破坏运行中任务查询。
+
+TTL 与容量的关系:
+
+1. 每次淘汰先执行 TTL 删除,移除所有超过 `TASK_STORE_TTL_HOURS` 的 final 任务。
+2. 再执行容量删除;如果剩余记录数仍超过 `TASK_STORE_MAX_RECORDS`,继续删除最旧 final 任务,即使这些 final 任务尚未超过 TTL。
+3. 因此二者是独立约束:TTL 可能让记录数低于上限,容量也可能在 TTL 未到期时删除旧 final 任务。
+4. 未 final 任务始终受保护;当未 final 任务数量本身超过上限时,store 可以超过 `TASK_STORE_MAX_RECORDS`。
+
+复杂度要求:TTL pass 是 O(N),容量删除通过一次候选收集和一次排序完成,避免嵌套循环导致 O(N²)。
+
+### RateLimitMiddleware
+
+`RateLimitMiddleware` 增加:
+
+- `bucket_ttl_sec`
+- `cleanup_interval_sec`
+- `cleanup_batch_size`
+
+每个用户桶内的时间戳数量仍由 `limit` 约束。当前请求只无条件清理当前用户桶,因此单请求固定成本为 O(limit)。
+
+全局陈旧桶清理必须节流并限量:
+
+1. middleware 维护 `_last_cleanup_ts`,只有距离上次全局清理超过 `cleanup_interval_sec` 时才启动一批全局清理。
+2. middleware 维护 `_cleanup_queue: deque[int]` 和 `_cleanup_queued: set[int]`;创建桶时只有 `user_id not in _cleanup_queued` 才入队。
+3. 桶被删除时必须同步从 `_cleanup_queued` 移除,确保同一用户后续重建桶时能重新进入清理轮转。
+4. 每批从队列左侧最多弹出 `cleanup_batch_size` 个 user_id;弹出时先从 `_cleanup_queued` 移除。
+5. 检查到空桶或最后一次请求已超过 `bucket_ttl_sec` 的桶时删除该 user_id,且不重新入队。
+6. 检查到仍活跃的桶时保留该桶,并在 `user_id not in _cleanup_queued` 时重新放回队列尾部。
+7. 保持现有限流判断不变。
+
+这样即使 `allow_all_users=true` 且历史用户很多,一个活跃用户的每次请求也不会遍历全部历史桶。
+
+### RefCountedLockRegistry
+
+权限锁、JSONL sync lock、session event lock 使用同一个小型工具类,避免复制三份 `ref_count + last_used + lock` 管理逻辑。
+
+新增 `RefCountedLockRegistry`,放在 `app/services/lock_registry.py`。它只负责内存锁生命周期,不依赖业务模型。
+
+核心结构:
+
+- `key -> LockEntry`
+- `LockEntry.lock: asyncio.Lock`
+- `LockEntry.ref_count: int`
+- `LockEntry.last_used: float`
+- `_cleanup_queue: deque[str]`
+- `_cleanup_queued: set[str]`
+- `_last_cleanup_ts: float`
+
+接口语义:
+
+1. `lock(key)` 返回 async context manager。
+2. 进入 context 前,在 registry 内部锁保护下创建或获取条目,`ref_count += 1`;创建条目时如果 `key not in _cleanup_queued`,把 key 入队。
+3. context 内部持有 `LockEntry.lock`,保持同一 key 串行化。
+4. 退出 context 后释放 `LockEntry.lock`,再在 registry 内部锁保护下 `ref_count -= 1` 并更新 `last_used`。
+5. 退出 context 后必须尝试清理当前 key。
+6. 删除条目时必须同步从 `_cleanup_queued` 移除,确保同一 key 后续重建时能重新进入清理轮转。
+7. 全局清理只有在距离上次清理超过 `cleanup_interval_sec` 时运行,单批最多检查 `cleanup_batch_size` 个 key;检查时从队列弹出并移除 `_cleanup_queued` 标记,仍活跃的 key 再重新入队。
+
+三种锁各自持有一个独立 registry 实例:
+
+- `PermissionService` 使用一个 registry,TTL 为 `PERMISSION_LOCK_TTL_SEC`。
+- JSONL sync 使用一个 registry,TTL 为 `SESSION_LOCK_TTL_SEC`。
+- Session event dispatch 使用一个 registry,TTL 为 `SESSION_LOCK_TTL_SEC`。
+
+三种 registry 共用 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 作为配置值,但各自维护独立的 `_last_cleanup_ts`、队列和条目表;清理频率参数相同,不代表共享同一个全局清理状态。
+
+### PermissionService 锁
+
+权限锁不能简单在使用后 `pop`,否则并发等待同一把锁的协程可能仍持有旧锁引用,而新请求会创建新锁,破坏串行化。
+
+`PermissionService` 使用 `RefCountedLockRegistry` 管理 `tool_use_id` 锁。`last_used` 使用事件循环单调时间,例如 `asyncio.get_running_loop().time()`,避免系统时间回拨影响 TTL 判断。
+
+权限响应完成后必须尝试清理当前 `tool_use_id` 的锁。全局过期锁清理受 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 约束,不能在热路径中无上限扫描全表。
+
+### JSONL sync locks
+
+`_jsonl_sync_locks` 替换为一个独立的 `RefCountedLockRegistry` 实例,使用 session_id 作为 key。
+
+清理策略:
+
+1. `sync_claude_session()` 完成后更新该 session lock 的 `last_used`。
+2. `_debounced_sync_claude_session()` 结束且没有 pending sync request 时必须尝试清理当前 session 的 sync lock。
+3. 全局过期 sync lock 清理受 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 约束。
+4. 只删除无引用、未锁定、超过 `SESSION_LOCK_TTL_SEC` 的条目。
+5. `stop()` 路径继续清空所有 JSONL sync 相关字典。
+
+活跃 session 不会因为创建时间超过 TTL 被清理;TTL 只从最后一次使用后开始计算。
+
+### Session event locks
+
+`_session_event_locks` 替换为一个独立的 `RefCountedLockRegistry` 实例,使用 session_id 作为 key。
+
+清理策略:
+
+1. `_dispatch_session_event()` 完成后更新 `last_used`。
+2. 收到 `SessionEnd` 事件后,必须立即尝试清理该 session 的 event lock,但仍必须满足无引用、未锁定条件。
+3. 全局过期 event lock 清理受 `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 约束,不能在热路径中无上限扫描全表。
+
+活跃 session 不会因为创建时间超过 TTL 被清理;TTL 只从最后一次使用后开始计算。
+
+## 数据流
+
+### 任务记录
+
+1. 新任务进入 `MemoryTaskStore.add()`。
+2. store 在 `self._lock` 保护下执行同步淘汰。
+3. 先删除超过 TTL 的 final 任务。
+4. 写入或更新当前任务。
+5. 如果记录数超过上限,删除最旧 final 任务直到达到上限或没有可删除 final 任务。
+6. 查询最近任务时只返回清理后的记录。
+
+### 限流桶
+
+1. Telegram 事件进入 `RateLimitMiddleware`。
+2. middleware 清理当前用户桶的过期时间戳。
+3. 判断是否超过限流。
+4. 允许通过时写入当前时间戳。
+5. 如果距离上次全局清理超过 `RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC`,最多清理 `RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE` 个历史用户桶。
+
+### 锁结构
+
+1. 访问方按 key 获取锁条目。
+2. 条目 `ref_count += 1`。
+3. 进入 `async with lock`。
+4. 临界区结束后 `ref_count -= 1`,更新 `last_used`。
+5. 当前 key 在释放后必须尝试清理。
+6. 全局锁清理只有在距离上次清理超过 `LOCK_CLEANUP_INTERVAL_SEC` 时运行,且单批最多检查 `LOCK_CLEANUP_BATCH_SIZE` 个 key。
+7. 懒清理删除无引用、未锁定且过期的锁条目。
+
+## 错误处理与并发约束
+
+- 清理逻辑不得阻断主流程。
+- 清理中遇到异常时记录 warning,并继续执行原操作。
+- 配置非法时沿用现有启动期校验风格,直接抛出配置错误。
+- `MemoryTaskStore` 淘汰 helper 是同步函数,在 `self._lock` 内执行,内部不允许 `await`。
+- 锁注册表删除条目前必须确认 `ref_count == 0` 且 `lock.locked() is False`。
+- 任务存储优先保证运行中任务可查询,必要时允许短暂超过 `TASK_STORE_MAX_RECORDS`。
+
+## 测试计划
+
+新增或扩展测试:
+
+1. `MemoryTaskStore`
+   - 过期 final 任务会按 `ended_at` 被清理。
+   - final 任务缺少 `ended_at` 时使用 `created_at` 兜底。
+   - 运行中任务不会因 TTL 被清理。
+   - 超过 `max_records` 时删除最旧 final 任务。
+   - final 任务不足时不会删除 running 任务。
+   - 10,000 条任务下执行一次淘汰,验证结果正确,避免实现退化成 O(N²)。该测试不使用严格耗时断言,重点验证大数据量路径可完成且排序/删除正确。
+   - 使用 `asyncio.gather` 并发调用 `add()`、`save()`、`list_by_user()`,验证不会出现 dict mutation 异常且最终记录满足保护 running 任务的约束。
+2. `RateLimitMiddleware`
+   - 限流行为保持不变。
+   - 窗口过后当前用户空桶会被删除。
+   - 历史用户桶只在 cleanup interval 到期后清理。
+   - 单次全局清理最多处理 `cleanup_batch_size` 个桶。
+   - 桶删除后同步移除 `_cleanup_queued`,同一用户后续重建桶会重新入队。
+   - 在大量历史桶和单个活跃用户场景下,请求路径不会扫描全部历史桶。
+3. `RefCountedLockRegistry`
+   - 同一 key 并发进入时仍串行。
+   - 当前 key 在无引用且过期后可被懒清理。
+   - 未过期或 `ref_count > 0` 的 key 不会被清理。
+   - 删除 key 时同步移除 `_cleanup_queued`,同一 key 后续重建会重新入队。
+   - 全局锁清理只在 cleanup interval 到期后运行,且单批最多处理 `LOCK_CLEANUP_BATCH_SIZE` 个 key。
+   - 不同 registry 实例拥有独立 `_last_cleanup_ts` 和队列状态。
+4. `PermissionService`
+   - 同一 `tool_use_id` 并发响应通过 registry 保持串行。
+5. session locks
+   - JSONL sync 完成且无 pending request 后,过期 sync lock 可被清理。
+   - `SessionEnd` 后 event lock 可被安全清理。
+   - 活跃 session 的 lock 因 `last_used` 更新不会被 TTL 误删。
+6. 配置
+   - 新配置项默认值正确。
+   - 派生默认值正确:`RATE_LIMIT_BUCKET_TTL_SEC` 未设置时使用 `RATE_LIMIT_WINDOW_SEC`,`PERMISSION_LOCK_TTL_SEC` 未设置时使用 `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC`。
+   - `SESSION_LOCK_TTL_SEC` 默认值独立为 3600,不受 `EXTERNAL_SESSION_STALE_TIMEOUT_SEC` 影响。
+   - `LOCK_CLEANUP_INTERVAL_SEC` 和 `LOCK_CLEANUP_BATCH_SIZE` 会传入三种 registry,但每个 registry 状态独立。
+   - 非正整数配置启动校验失败。
+
+验收命令:
+
+```bash
+pytest -q
+```
+
+必要时先单跑:
+
+```bash
+pytest -q tests/test_auth_settings.py tests/test_task_service.py tests/test_bootstrap_hooks.py
+```
+
+## 取舍
+
+选择“配置化 + 懒清理”是为了在内存风险、可调节性和改动范围之间取得平衡。后台清理服务更完整,但会增加生命周期管理和测试复杂度;固定默认值改动更少,但后续调参需要改代码。
+
+这版设计避免在请求热路径无上限扫描历史结构。任务记录仍可能在查询/写入时做 O(N) 或 O(N log N) 淘汰,但默认上限为 1000,且大数据量测试会防止实现出现 O(N²) 退化。
diff --git a/docs/superpowers/specs/2026-05-26-priority-fixes-design.md b/docs/superpowers/specs/2026-05-26-priority-fixes-design.md
new file mode 100644
index 0000000..96a94f5
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-26-priority-fixes-design.md
@@ -0,0 +1,226 @@
+# Priority Fixes Design
+
+## Scope
+
+This design covers the first repair batch approved by the user:
+
+1. Fix upload queue behavior and memory limits.
+2. Replace permission callback `tool_use_id` truncation with short tokens.
+3. Clean up pending permission state and session lock state.
+
+This batch intentionally does not include broad performance rewrites, AppContainer/TaskService decomposition, or persistent upload/permission state.
+
+## Goals
+
+- Files uploaded during an active task are processed after the task finishes.
+- Oversized uploads are rejected before download when Telegram exposes file size metadata.
+- Upload queues cannot grow without per-user bounds.
+- Telegram permission buttons work for long `tool_use_id` values without truncation or prefix matching.
+- Expired callback tokens produce clear recovery instructions.
+- Unbound permission pending entries and session lock entries do not accumulate indefinitely.
+- Existing command behavior and user-facing flows remain compatible.
+
+## Non-goals
+
+- Persist queued uploads across process restarts.
+- Persist callback tokens across process restarts.
+- Redesign all pending state registries into a single generic framework.
+- Replace filesystem scanning/diff behavior.
+- Refactor the full bootstrap or task service architecture.
+
+Restart behavior is intentionally explicit: queued uploads and callback tokens are in-memory only. User-facing messages must state that queued uploads can be lost if the bot restarts, and stale callback messages must tell users how to recover.
+
+## Configuration Summary
+
+| Setting | Type | Default | Purpose |
+| --- | --- | --- | --- |
+| `UPLOAD_MAX_FILE_SIZE_MB` | existing int | `20` | Maximum single uploaded file size. Used before download when Telegram metadata is available and again during final validation. |
+| `UPLOAD_QUEUE_MAX_FILES_PER_USER` | new int | `5` | Maximum number of files queued for one user while a task is running. Set to `0` to disable queuing for uploads received during a running task. |
+| `UPLOAD_QUEUE_MAX_BYTES_PER_USER` | new int | `UPLOAD_QUEUE_MAX_FILES_PER_USER * UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024` | Maximum total queued bytes per user. This default allows up to the configured number of maximum-size files. |
+| `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC` | existing int | `600` | TTL for pending permission requests and permission callback tokens. |
+| `SESSION_LOCK_TTL_SEC` | existing int | `3600` | TTL for ref-counted session locks where `RefCountedLockRegistry` is reused. |
+
+## Design 1: Upload Queue Repair
+
+### Current problem
+
+`app/bot/handlers/file_upload.py` queues uploads in `_pending_uploads` when a user has a running task, but `process_pending_uploads()` has no current app-level caller. The queue stores raw `bytes`, has no count or byte limit, and files are downloaded before size checks in `FileReceiverService`.
+
+### Proposed behavior
+
+Introduce a small upload queue manager for queued uploads. It remains in memory and user-scoped, but enforces:
+
+- `UPLOAD_QUEUE_MAX_FILES_PER_USER`, default `5`.
+- `UPLOAD_QUEUE_MAX_BYTES_PER_USER`, default `UPLOAD_QUEUE_MAX_FILES_PER_USER * UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024`.
+- FIFO processing order.
+
+The file upload handlers check Telegram file size metadata before downloading:
+
+- `document.file_size` for documents.
+- `photo.file_size` for photos when present.
+
+If the size is over `UPLOAD_MAX_FILE_SIZE_MB`, the handler rejects the file before downloading. After download, the existing `FileReceiverService` validation still runs as the final authority.
+
+When a task reaches a final state, `RunEventStreamer.stream_events()` first sends/edits the final task result message. It then schedules queued upload processing as a tracked background task so task completion feedback is not delayed by upload validation or save operations. The background task processes each queued file against the user's current session workdir by reusing the existing direct-upload processing path; this matches current direct-upload behavior, which resolves the workdir from `SessionService` at processing time. A failed file does not stop later queued files.
+
+The background task must be tracked with a done callback, similar to existing stream task tracking, so unexpected exceptions are logged instead of becoming silent unobserved task failures.
+
+### Sequence
+
+```mermaid
+sequenceDiagram
+    participant U as Telegram User
+    participant H as File Upload Handler
+    participant Q as Upload Queue Manager
+    participant R as RunEventStreamer
+    participant B as Background Upload Processor
+    participant F as FileReceiverService
+
+    U->>H: upload file while task is running
+    H->>H: check Telegram file_size before download
+    H->>Q: enqueue(filename, bytes, size)
+    Q-->>H: accepted or rejected by count/byte limits
+    H-->>U: queued warning or rejection
+
+    R->>U: send final task result
+    R->>B: schedule queue processing task
+    B->>Q: drain user queue FIFO
+    loop each queued file
+        B->>F: validate and save using current session workdir
+        F-->>B: success or validation error
+        B-->>U: per-file result message
+    end
+```
+
+### User-facing behavior
+
+- If a task is running and the queue has capacity, the bot replies that the file was queued and explicitly says queued files are in memory and will be lost if the bot restarts before the task finishes.
+- If the queue is full or byte limit is exceeded, the bot rejects the file with a clear reason.
+- After task completion, the final task result is shown first. Queued file processing messages follow asynchronously.
+- Each queued file produces the same success/rejection message as direct upload.
+
+### Tests
+
+Add or update tests for:
+
+- Download is skipped when Telegram file size exceeds the configured limit.
+- Queued uploads are processed after task completion via a background task, after the final task message is sent.
+- Queue count and byte limits reject additional files.
+- One failed queued file does not prevent later files from being processed.
+- Queued-upload reply includes restart-loss wording.
+
+## Design 2: Permission Callback Short Tokens
+
+### Current problem
+
+Permission callback data currently embeds `tool_use_id` and truncates it to fit Telegram's 64-byte callback data limit. The permission response path expects the full `tool_use_id`, so long IDs can make buttons fail as stale or expired.
+
+The same issue exists for external permission callbacks.
+
+### Proposed behavior
+
+Add `PermissionCallbackRegistry`, an in-memory TTL registry mapping short tokens to full `tool_use_id` values.
+
+Callback data changes to:
+
+- Normal permissions: `perm::`.
+- External permissions: `ext_perm::`.
+
+Button builders register the real `tool_use_id`, receive a short token, and place only that token into `callback_data`. Callback handlers resolve the token before calling permission services. If the token is missing or expired, the user sees a stale-button message that says the bot may have restarted or the request expired, and asks the user to trigger the operation again.
+
+### Token generation and collisions
+
+Tokens are generated with `secrets.token_urlsafe(6)`, producing roughly 8 URL-safe characters with 48 bits of entropy. Registration checks for an existing live token and retries generation if a collision occurs. Tokens are unique within the registry's live TTL window.
+
+The token TTL uses `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC`, so callback tokens do not outlive the permission request they represent.
+
+### Placement
+
+Place the registry in `app/services/permission_callback_registry.py`. Create one instance in `AppContainer` and inject it into:
+
+- Normal permission handlers.
+- External permission handlers.
+- Unbound permission handler button creation.
+
+This keeps token ownership explicit without introducing a broad state framework.
+
+### Tests
+
+Add or update tests for:
+
+- Long `tool_use_id` values are not truncated.
+- Generated callback data stays under 64 bytes.
+- Normal permission callbacks resolve tokens to the full ID.
+- External permission callbacks resolve tokens to the full ID.
+- Expired or unknown tokens produce a clear stale-button response with recovery instructions.
+- Token generation retries on a simulated live-token collision.
+
+## Design 3: Pending and Lock Cleanup
+
+### Unbound permissions
+
+`UnboundPermissionHandler` should remove entries from `_pending` when:
+
+- A user response is accepted and forwarded.
+- The TTL expiry path auto-denies the request.
+
+Concurrency should preserve first-responder-wins without holding locks across Telegram or hook socket I/O. Use a short critical section to atomically remove the pending entry and cancel its expiry task. The actual `hook_socket_server.respond_to_permission()` call runs after the entry has been claimed. This briefly serializes dictionary mutations only; responses for unrelated permission requests are not serialized on network or socket I/O.
+
+If forwarding fails after a user response has claimed the request, log the failure and report failure to that user where possible. Do not restore the pending entry, because another user responding later could send a conflicting decision.
+
+### Tmux session locks
+
+`TmuxRunner` currently keeps `_session_locks` indefinitely. Replace this dictionary with the existing `RefCountedLockRegistry`, or wrap the persistent-session critical section with an equivalent ref-counted cleanup path. Reusing `RefCountedLockRegistry` is preferred because it is already configured and tested elsewhere.
+
+Only persistent terminal runs need per-session serialization. Ephemeral runs keep current behavior. Lock cleanup must happen only after the critical section exits and must not remove locks with nonzero references or active waiters.
+
+### Agent file watcher locks
+
+`AgentFileWatcher.forget()` should cancel the watcher task and remove visible task tracking plus all mtime keys for that session immediately. If a watcher task is currently inside `_on_update` / `sync_claude_session`, lock cleanup must be deferred to the watcher's `finally` block after the current callback exits. If no task is running, `forget()` can clean the lock immediately.
+
+`stop_all()` continues to await all watcher tasks, so shutdown performs deterministic cleanup. The watcher `finally` block removes the session lock and any residual mtime keys only when no newer watcher task has been registered for the same session; this avoids deleting state owned by a replacement watcher.
+
+### Tests
+
+Add or update tests for:
+
+- Unbound permission response removes pending state and expiry task.
+- Unbound permission expiry removes pending state.
+- Concurrent responses for the same unbound permission preserve first-responder-wins.
+- Responses for different unbound permissions do not wait on hook socket I/O under a global lock.
+- Tmux persistent session lock count does not grow after repeated runs for completed sessions.
+- Agent watcher `forget()` clears mtime keys immediately and lock state after the watcher task exits.
+
+## Error Handling
+
+- Upload queue processing logs per-file failures and continues.
+- Queued upload background task failures are observed through a done callback and logged.
+- Callback token lookup failures return stale-button messages and do not call permission responders.
+- Stale-button messages must include recovery text: retry the action or wait for Claude to request permission again.
+- Unbound permission response failures are logged after the request has been claimed; the request is not restored.
+- Lock cleanup must never delete a lock while it is held or while another coroutine is waiting for it.
+
+## Rollback Plan
+
+- Upload queue changes are localized to the file upload handler, queue manager, and event-stream completion hook. If queued uploads misbehave, disable queueing by setting `UPLOAD_QUEUE_MAX_FILES_PER_USER=0`; uploads received during a running task are rejected instead of queued, while direct uploads when no task is running remain unchanged.
+- Permission token changes have no persisted data. If stale buttons spike because TTL is too low, first increase `CLAUDE_HOOK_PENDING_PERMISSION_TTL_SEC`. Users can still respond with `/approve` or `/deny` for the current pending permission. If token routing itself is faulty, revert the permission-token commit and redeploy; no migration is needed.
+- Cleanup changes are internal state-management changes. If they cause regressions, revert the cleanup commit; persisted session files are not migrated by this batch.
+
+## Implementation Notes
+
+- Keep changes focused and avoid changing unrelated command behavior.
+- Prefer existing service injection patterns in `AppContainer.wire()` and router registration.
+- Keep user-facing messages consistent with current Chinese bot messages where the surrounding handler already uses Chinese.
+- Do not add persistence unless a later batch explicitly asks for restart-safe behavior.
+- Avoid feature flags except the explicit upload-queue off switch; the main rollback path is commit revert because this batch has no schema migration.
+
+## Verification
+
+Run targeted tests for the changed modules, then the full test suite:
+
+- File upload handler tests.
+- Permission callback registry tests.
+- Permission handler tests.
+- External permission/unbound permission tests.
+- Tmux runner and agent watcher cleanup tests.
+- Full `pytest -q`.
diff --git a/pyproject.toml b/pyproject.toml
index 5c8121b..78cf8a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
 dev = [
   "pytest>=8.3.3,<9",
   "pytest-asyncio>=0.24.0,<1",
+  "hypothesis>=6.100,<7",
   "ruff>=0.8,<1",
   "mypy>=1.13,<2"
 ]
diff --git a/tests/fakes/cli.py b/tests/fakes/cli.py
index 841542e..14dc77c 100644
--- a/tests/fakes/cli.py
+++ b/tests/fakes/cli.py
@@ -94,6 +94,12 @@ async def ensure_claude_interactive_session(self, *, terminal_key: str, workdir:
         self._ensured_interactive_workdir = workdir
         return True, ""
 
+    async def ensure_claude_resume_session(self, *, terminal_key: str, workdir: str, session_id: str) -> tuple[bool, str]:
+        self._ensured_resume_terminal_key = terminal_key
+        self._ensured_resume_workdir = workdir
+        self._ensured_resume_session_id = session_id
+        return True, ""
+
     async def reveal_terminal(self, terminal_key: str) -> tuple[bool, str]:
         self._revealed_terminal_key = terminal_key
         return True, f"已在桌面打开 Terminal 并附着到 tgcli_{terminal_key}"
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/test_external_session_pipeline.py b/tests/integration/test_external_session_pipeline.py
new file mode 100644
index 0000000..44ce842
--- /dev/null
+++ b/tests/integration/test_external_session_pipeline.py
@@ -0,0 +1,329 @@
+"""Integration tests for the external session takeover pipeline.
+
+These tests exercise the actual service interactions (not just mocks)
+using temp directories for persistence.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.domain.hook_models import HookEvent
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.external_session_binder import ExternalSessionBinder
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+from app.services.external_session_push_notifier import ExternalSessionPushNotifier
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+from app.services.unbound_permission_handler import UnboundPermissionHandler
+
+
+@pytest.fixture
+def tmp_data_dir(tmp_path: Path) -> Path:
+    return tmp_path / "data"
+
+
+@pytest.fixture
+def projects_dir(tmp_path: Path) -> Path:
+    return tmp_path / "projects"
+
+
+def _make_hook_event(
+    *,
+    session_id: str = "sess-abc123",
+    cwd: str = "/home/user/project",
+    event: str = "PreToolUse",
+    status: str = "running_tool",
+    pid: int | None = None,
+    tool: str | None = None,
+    tool_use_id: str | None = None,
+    tool_input: dict | None = None,
+) -> HookEvent:
+    return HookEvent(
+        session_id=session_id,
+        cwd=cwd,
+        event=event,
+        status=status,
+        pid=pid,
+        tool=tool,
+        tool_use_id=tool_use_id,
+        tool_input=tool_input,
+    )
+
+
+class TestDiscoveryBindJsonlSync:
+    """Discovery → bind → JSONL sync pipeline."""
+
+    @pytest.mark.asyncio
+    async def test_full_discovery_bind_sync_pipeline(self, tmp_data_dir: Path, projects_dir: Path) -> None:
+        """Record hook events → verify in discovery → bind → verify binding created and JSONL sync triggered."""
+        # Setup services
+        discovery = ExternalSessionDiscoveryService()
+        binding_store = ExternalBindingStore(data_dir=tmp_data_dir)
+        sync_callback = AsyncMock()
+
+        binder = ExternalSessionBinder(
+            discovery=discovery,
+            binding_store=binding_store,
+            projects_dir=projects_dir,
+            sync_callback=sync_callback,
+        )
+
+        session_id = "sess-abc123"
+        cwd = "/home/user/project"
+        user_id = 42
+
+        # Step 1: Record hook events into discovery
+        event = _make_hook_event(session_id=session_id, cwd=cwd)
+        discovery.record_event(event)
+
+        # Verify session appears in discovery
+        unbound_sessions = discovery.list_unbound()
+        assert len(unbound_sessions) == 1
+        assert unbound_sessions[0].session_id == session_id
+        assert unbound_sessions[0].cwd == cwd
+
+        # Step 2: Bind the session
+        result = await binder.bind(user_id=user_id, session_id=session_id)
+
+        # Step 3: Verify binding created
+        assert result.success is True
+        assert result.session_id == session_id
+
+        stored_binding = binding_store.get_binding(session_id)
+        assert stored_binding is not None
+        assert stored_binding.user_id == user_id
+        assert stored_binding.cwd == cwd
+
+        # Step 4: Verify session removed from discovery
+        assert discovery.get(session_id) is None
+
+        # Step 5: Verify sync_callback triggered with correct args
+        sync_callback.assert_called_once_with(session_id, cwd)
+
+
+class TestPermissionRequestForwarding:
+    """Permission request forwarding for bound external sessions."""
+
+    @pytest.mark.asyncio
+    async def test_bound_session_permission_push_notification(self, tmp_data_dir: Path, projects_dir: Path) -> None:
+        """Create binding, simulate PermissionRequest, verify push notifier called."""
+        # Setup services
+        discovery = ExternalSessionDiscoveryService()
+        binding_store = ExternalBindingStore(data_dir=tmp_data_dir)
+        sync_callback = AsyncMock()
+
+        binder = ExternalSessionBinder(
+            discovery=discovery,
+            binding_store=binding_store,
+            projects_dir=projects_dir,
+            sync_callback=sync_callback,
+        )
+
+        session_id = "sess-perm001"
+        cwd = "/home/user/myapp"
+        user_id = 99
+
+        # Setup: discover and bind
+        event = _make_hook_event(session_id=session_id, cwd=cwd)
+        discovery.record_event(event)
+        result = await binder.bind(user_id=user_id, session_id=session_id)
+        assert result.success is True
+
+        # Create push notifier with mocked bot
+        mock_bot = AsyncMock()
+        push_notifier = ExternalSessionPushNotifier(
+            bot=mock_bot,
+            binding_store=binding_store,
+        )
+
+        # Simulate a PermissionRequest event for the bound session
+        delivered = await push_notifier.notify_permission_request(
+            user_id=user_id,
+            session_id=session_id,
+            tool_name="Bash",
+            tool_input={"command": "rm -rf /"},
+            tool_use_id="test-tool-use-id-123",
+            cwd=cwd,
+        )
+
+        # Verify push notification was sent
+        assert delivered is True
+        mock_bot.send_message.assert_called_once()
+        call_kwargs = mock_bot.send_message.call_args.kwargs
+        assert call_kwargs["chat_id"] == user_id
+        assert session_id[:8] in call_kwargs["text"]
+
+
+class TestUnboundPermissionBroadcast:
+    """Unbound permission broadcast → first user responds → decision forwarded."""
+
+    @pytest.mark.asyncio
+    async def test_broadcast_and_first_responder_wins(self) -> None:
+        """Broadcast to all users, respond from one user, verify decision forwarded."""
+        mock_bot = AsyncMock()
+        mock_hook_socket = AsyncMock()
+
+        allowed_users = {100, 200, 300}
+
+        handler = UnboundPermissionHandler(
+            bot=mock_bot,
+            hook_socket_server=mock_hook_socket,
+            allowed_user_ids=allowed_users,
+            permission_ttl_sec=60,
+            permission_callback_registry=PermissionCallbackRegistry(ttl_sec=60),
+        )
+
+        session_id = "sess-unbound01"
+        tool_use_id = "tooluse-xyz789"
+
+        # Simulate a PermissionRequest from an unbound session
+        event = _make_hook_event(
+            session_id=session_id,
+            cwd="/tmp/project",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Write",
+            tool_use_id=tool_use_id,
+            tool_input={"command": "echo hello"},
+        )
+
+        # Step 1: Handle unbound permission → broadcasts to all users
+        await handler.handle_unbound_permission(event)
+
+        # Verify broadcast sent to all allowed users
+        assert mock_bot.send_message.call_count == len(allowed_users)
+        notified_chat_ids = {call.kwargs["chat_id"] for call in mock_bot.send_message.call_args_list}
+        assert notified_chat_ids == allowed_users
+
+        # Step 2: First user responds with "approve"
+        first_response = await handler.handle_response(tool_use_id=tool_use_id, user_id=200, decision="approve")
+        assert first_response.accepted is True
+
+        # Verify decision forwarded to hook socket
+        mock_hook_socket.respond_to_permission.assert_called_once_with(
+            tool_use_id=tool_use_id,
+            decision="approve",
+            reason="responded by user 200",
+        )
+
+        # Step 3: Second user tries to respond (too late)
+        second_response = await handler.handle_response(tool_use_id=tool_use_id, user_id=100, decision="deny")
+        assert second_response.accepted is False
+
+        # Verify only one decision forwarded
+        mock_hook_socket.respond_to_permission.assert_called_once()
+
+
+class TestServerRestartBindingsRestored:
+    """Server restart → bindings restored from disk."""
+
+    def test_bindings_survive_restart(self, tmp_data_dir: Path) -> None:
+        """Save bindings via store, create new store instance, verify load_all matches."""
+        from app.domain.external_session_models import ExternalBinding
+        from app.domain.models import utc_now
+
+        # Create store and save bindings
+        store1 = ExternalBindingStore(data_dir=tmp_data_dir)
+
+        now = utc_now()
+        binding1 = ExternalBinding(
+            session_id="sess-restart01",
+            user_id=10,
+            cwd="/home/alice/proj",
+            bound_at=now,
+            jsonl_path="/tmp/projects/-home-alice-proj/sess-restart01.jsonl",
+        )
+        binding2 = ExternalBinding(
+            session_id="sess-restart02",
+            user_id=20,
+            cwd="/home/bob/work",
+            bound_at=now,
+            jsonl_path="/tmp/projects/-home-bob-work/sess-restart02.jsonl",
+        )
+
+        store1.save_binding(binding1)
+        store1.save_binding(binding2)
+
+        # Simulate server restart: create new store from same directory
+        store2 = ExternalBindingStore(data_dir=tmp_data_dir)
+
+        # Verify all bindings restored
+        loaded = store2.load_all()
+        assert len(loaded) == 2
+        assert "sess-restart01" in loaded
+        assert "sess-restart02" in loaded
+
+        restored1 = loaded["sess-restart01"]
+        assert restored1.user_id == 10
+        assert restored1.cwd == "/home/alice/proj"
+        assert restored1.jsonl_path == "/tmp/projects/-home-alice-proj/sess-restart01.jsonl"
+
+        restored2 = loaded["sess-restart02"]
+        assert restored2.user_id == 20
+        assert restored2.cwd == "/home/bob/work"
+        assert restored2.jsonl_path == "/tmp/projects/-home-bob-work/sess-restart02.jsonl"
+
+
+class TestUnboundPermissionKeyboardToken:
+    """Unbound permission keyboard uses external short token from registry."""
+
+    @pytest.mark.asyncio
+    async def test_unbound_permission_keyboard_uses_external_short_token(self) -> None:
+        """Keyboard callback_data uses ext_perm:{token}:{decision} format, all <= 64 bytes,
+        and registry resolves token back to full tool_use_id."""
+        mock_bot = AsyncMock()
+        mock_hook_socket = AsyncMock()
+
+        registry = PermissionCallbackRegistry(
+            ttl_sec=300,
+            token_factory=lambda: "tok12345",
+        )
+
+        handler = UnboundPermissionHandler(
+            bot=mock_bot,
+            hook_socket_server=mock_hook_socket,
+            allowed_user_ids={42},
+            permission_ttl_sec=60,
+            permission_callback_registry=registry,
+        )
+
+        # Use a long tool_use_id that would exceed 64 bytes with old format
+        long_tool_use_id = "tooluse-" + "a" * 80
+
+        event = HookEvent(
+            session_id="sess-keyboard01",
+            cwd="/tmp/proj",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Bash",
+            tool_use_id=long_tool_use_id,
+            tool_input={"command": "echo hello"},
+        )
+
+        await handler.handle_unbound_permission(event)
+
+        # Get the keyboard that was sent
+        call_kwargs = mock_bot.send_message.call_args.kwargs
+        keyboard = call_kwargs["reply_markup"]
+
+        # Extract callback_data from all buttons
+        callback_data = []
+        for row in keyboard.inline_keyboard:
+            for button in row:
+                callback_data.append(button.callback_data)
+
+        assert callback_data == [
+            "ext_perm:tok12345:allow",
+            "ext_perm:tok12345:deny",
+            "ext_perm:tok12345:auto_approve",
+        ]
+
+        # All callback_data must be <= 64 bytes
+        for cd in callback_data:
+            assert len(cd.encode("utf-8")) <= 64
+
+        # Registry must resolve token back to full tool_use_id
+        assert registry.resolve("tok12345") == long_tool_use_id
diff --git a/tests/property/__init__.py b/tests/property/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/property/test_binder_properties.py b/tests/property/test_binder_properties.py
new file mode 100644
index 0000000..536d3eb
--- /dev/null
+++ b/tests/property/test_binder_properties.py
@@ -0,0 +1,287 @@
+"""Property-based tests for ExternalSessionBinder.
+
+Feature: external-session-takeover
+"""
+
+from __future__ import annotations
+
+import asyncio
+import tempfile
+from pathlib import Path
+
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from app.domain.hook_models import HookEvent
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.external_session_binder import ExternalSessionBinder, _resolve_jsonl_path
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+
+# --- Strategies ---
+
+# session_id must match ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$
+session_id_chars = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-")
+session_id_first_char = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+session_ids = st.builds(
+    lambda first, rest: first + rest,
+    session_id_first_char,
+    st.text(session_id_chars, min_size=0, max_size=30),
+)
+
+# cwd must be an absolute path
+cwds = st.builds(
+    lambda parts: "/" + "/".join(parts),
+    st.lists(
+        st.text(
+            st.characters(whitelist_categories=("L", "N"), min_codepoint=65, max_codepoint=122),
+            min_size=1,
+            max_size=10,
+        ),
+        min_size=1,
+        max_size=4,
+    ),
+)
+
+# pid: optional non-negative int
+pids = st.one_of(st.none(), st.integers(min_value=0, max_value=2**31))
+
+hook_event_names = st.sampled_from(
+    [
+        "UserPromptSubmit",
+        "PreToolUse",
+        "PostToolUse",
+        "Stop",
+        "SessionStart",
+    ]
+)
+
+hook_statuses = st.sampled_from(
+    [
+        "starting",
+        "processing",
+        "running",
+        "ended",
+    ]
+)
+
+user_ids = st.integers(min_value=1, max_value=2**31)
+
+
+def hook_events(session_id_strategy=session_ids):
+    """Strategy that builds valid HookEvent instances."""
+    return st.builds(
+        HookEvent,
+        session_id=session_id_strategy,
+        cwd=cwds,
+        event=hook_event_names,
+        status=hook_statuses,
+        pid=pids,
+    )
+
+
+def _make_binder(tmp_path: Path):
+    """Create a fresh binder with temp directories."""
+    binding_store_dir = tmp_path / "bindings"
+    binding_store_dir.mkdir(parents=True, exist_ok=True)
+    projects_dir = tmp_path / "projects"
+    projects_dir.mkdir(parents=True, exist_ok=True)
+
+    discovery = ExternalSessionDiscoveryService()
+    store = ExternalBindingStore(data_dir=binding_store_dir)
+    binder = ExternalSessionBinder(
+        discovery=discovery,
+        binding_store=store,
+        projects_dir=projects_dir,
+    )
+    return discovery, store, binder, projects_dir
+
+
+# --- Property 3: Successful bind associates user and removes from discoverable ---
+
+
+class TestSuccessfulBind:
+    """Property 3: Successful bind associates user and removes from discoverable.
+
+    **Validates: Requirements 2.1, 2.2, 3.1**
+
+    For any valid user_id and session that exists in the discovery list,
+    bind succeeds, removes the session from unbound, stores the binding,
+    and resolves the JSONL path.
+    """
+
+    @settings(max_examples=100)
+    @given(event=hook_events(), user_id=user_ids)
+    def test_bind_succeeds_and_removes_from_unbound(self, event: HookEvent, user_id: int):
+        """Binding a discoverable session succeeds and removes it from unbound list."""
+        with tempfile.TemporaryDirectory() as tmp:
+            tmp_path = Path(tmp)
+            discovery, store, binder, projects_dir = _make_binder(tmp_path)
+
+            # Record event to make session discoverable
+            discovery.record_event(event)
+            assert discovery.get(event.session_id) is not None
+
+            # Bind
+            result = asyncio.get_event_loop().run_until_complete(binder.bind(user_id=user_id, session_id=event.session_id))
+
+            # Verify bind succeeded
+            assert result.success is True
+            assert result.session_id == event.session_id
+            assert result.jsonl_path is not None
+
+            # Session removed from discovery
+            assert discovery.get(event.session_id) is None
+            unbound_ids = {s.session_id for s in discovery.list_unbound()}
+            assert event.session_id not in unbound_ids
+
+            # Binding exists in store
+            binding = store.get_binding(event.session_id)
+            assert binding is not None
+            assert binding.user_id == user_id
+            assert binding.session_id == event.session_id
+            assert binding.cwd == event.cwd
+
+            # JSONL path is resolved correctly
+            expected_path = _resolve_jsonl_path(
+                session_id=event.session_id,
+                cwd=event.cwd,
+                projects_dir=projects_dir,
+            )
+            assert result.jsonl_path == expected_path
+
+
+# --- Property 4: Bind rejects invalid requests ---
+
+
+class TestBindRejectsInvalid:
+    """Property 4: Bind rejects invalid requests.
+
+    **Validates: Requirements 2.3, 2.4**
+
+    Bind attempts for non-existent sessions or already-bound sessions
+    fail without modifying system state.
+    """
+
+    @settings(max_examples=100)
+    @given(session_id=session_ids, user_id=user_ids)
+    def test_bind_non_existent_session_fails(self, session_id: str, user_id: int):
+        """Binding a session not in discovery fails."""
+        with tempfile.TemporaryDirectory() as tmp:
+            tmp_path = Path(tmp)
+            discovery, store, binder, _ = _make_binder(tmp_path)
+
+            # Don't record any events - session not discoverable
+            result = asyncio.get_event_loop().run_until_complete(binder.bind(user_id=user_id, session_id=session_id))
+
+            assert result.success is False
+            # State unchanged
+            assert discovery.list_unbound() == []
+            assert store.get_binding(session_id) is None
+
+    @settings(max_examples=100)
+    @given(event=hook_events(), user_id=user_ids, second_user_id=user_ids)
+    def test_bind_already_bound_session_fails(self, event: HookEvent, user_id: int, second_user_id: int):
+        """Binding an already-bound session fails and state is unchanged."""
+        with tempfile.TemporaryDirectory() as tmp:
+            tmp_path = Path(tmp)
+            discovery, store, binder, _ = _make_binder(tmp_path)
+
+            # Record and bind
+            discovery.record_event(event)
+            asyncio.get_event_loop().run_until_complete(binder.bind(user_id=user_id, session_id=event.session_id))
+
+            # Capture state after first bind
+            binding_after_first = store.get_binding(event.session_id)
+            assert binding_after_first is not None
+
+            # Try to bind again (session no longer in discovery)
+            result = asyncio.get_event_loop().run_until_complete(binder.bind(user_id=second_user_id, session_id=event.session_id))
+
+            assert result.success is False
+
+            # Binding unchanged - still belongs to first user
+            binding_after_second = store.get_binding(event.session_id)
+            assert binding_after_second is not None
+            assert binding_after_second.user_id == user_id
+
+
+# --- Property 5: Unbind round-trip restores discoverability ---
+
+
+class TestUnbindRoundTrip:
+    """Property 5: Unbind round-trip restores discoverability.
+
+    **Validates: Requirements 5.3**
+
+    After binding and then unbinding a session, the binding is removed
+    from the store. The session will be re-discovered on the next hook
+    event (not immediately added back to discovery by design).
+    """
+
+    @settings(max_examples=100)
+    @given(event=hook_events(), user_id=user_ids)
+    def test_unbind_removes_binding_from_store(self, event: HookEvent, user_id: int):
+        """Unbinding removes the binding from the store."""
+        with tempfile.TemporaryDirectory() as tmp:
+            tmp_path = Path(tmp)
+            discovery, store, binder, _ = _make_binder(tmp_path)
+
+            # Record, bind, then unbind
+            discovery.record_event(event)
+            bind_result = asyncio.get_event_loop().run_until_complete(binder.bind(user_id=user_id, session_id=event.session_id))
+            assert bind_result.success is True
+
+            unbind_result = asyncio.get_event_loop().run_until_complete(binder.unbind(user_id=user_id, session_id=event.session_id))
+
+            # Unbind succeeds
+            assert unbind_result.success is True
+            assert unbind_result.session_id == event.session_id
+
+            # Binding removed from store
+            assert store.get_binding(event.session_id) is None
+
+            # Session is NOT immediately back in discovery (by design)
+            # It will be re-discovered on next hook event
+            assert discovery.get(event.session_id) is None
+
+
+# --- Property 6: JSONL path resolution ---
+
+
+class TestJsonlPathResolution:
+    """Property 6: JSONL path resolution.
+
+    **Validates: Requirements 3.1**
+
+    For any valid session_id and cwd, the resolved JSONL path matches
+    the convention: projects_dir /  / .jsonl
+    where sanitized_cwd replaces '/' with '-' and '.' with '-'.
+    """
+
+    @settings(max_examples=100)
+    @given(session_id=session_ids, cwd=cwds)
+    def test_jsonl_path_matches_convention(self, session_id: str, cwd: str):
+        """Computed path matches projects_dir / sanitized_cwd / session_id.jsonl."""
+        with tempfile.TemporaryDirectory() as tmp:
+            projects_dir = Path(tmp) / "projects"
+            projects_dir.mkdir()
+
+            result = _resolve_jsonl_path(
+                session_id=session_id,
+                cwd=cwd,
+                projects_dir=projects_dir,
+            )
+
+            # Verify structure
+            sanitized_cwd = cwd.replace("/", "-").replace(".", "-")
+            expected = projects_dir / sanitized_cwd / f"{session_id}.jsonl"
+            assert result == expected
+
+            # Verify it's under projects_dir
+            assert str(result).startswith(str(projects_dir))
+
+            # Verify filename
+            assert result.name == f"{session_id}.jsonl"
+
+            # Verify parent dir name is sanitized cwd
+            assert result.parent.name == sanitized_cwd
diff --git a/tests/property/test_binding_store_properties.py b/tests/property/test_binding_store_properties.py
new file mode 100644
index 0000000..f793afa
--- /dev/null
+++ b/tests/property/test_binding_store_properties.py
@@ -0,0 +1,82 @@
+"""Property-based tests for ExternalBindingStore persistence round-trip.
+
+Feature: external-session-takeover, Property 8: Binding persistence round-trip
+
+**Validates: Requirements 5.5**
+"""
+
+from __future__ import annotations
+
+import tempfile
+from datetime import timezone
+from pathlib import Path
+
+import hypothesis.strategies as st
+from hypothesis import given, settings
+from hypothesis.strategies import characters, datetimes, integers, just, none, one_of, text
+
+from app.domain.external_session_models import ExternalBinding
+from app.services.external_binding_store import ExternalBindingStore
+
+
+# --- Strategies ---
+
+session_id_st = text(
+    min_size=1,
+    max_size=50,
+    alphabet=characters(whitelist_categories=("L", "N", "P")),
+)
+
+user_id_st = integers(min_value=1, max_value=10**9)
+
+cwd_st = text(min_size=1, max_size=200)
+
+bound_at_st = datetimes(timezones=just(timezone.utc))
+
+jsonl_path_st = one_of(none(), text(min_size=1, max_size=200))
+
+
+binding_st = st.builds(
+    ExternalBinding,
+    session_id=session_id_st,
+    user_id=user_id_st,
+    cwd=cwd_st,
+    bound_at=bound_at_st,
+    jsonl_path=jsonl_path_st,
+)
+
+
+@settings(max_examples=100)
+@given(bindings=st.lists(binding_st, min_size=0, max_size=20, unique_by=lambda b: b.session_id))
+def test_binding_persistence_round_trip(bindings: list[ExternalBinding]) -> None:
+    """Property 8: Binding persistence round-trip.
+
+    For any set of active bindings, persisting them and then loading from a NEW
+    store instance pointing to the same directory should produce an equivalent
+    set of bindings.
+
+    Feature: external-session-takeover, Property 8: Binding persistence round-trip
+    """
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        tmp_path = Path(tmp_dir)
+
+        # Create store and save all bindings
+        store = ExternalBindingStore(data_dir=tmp_path)
+        for binding in bindings:
+            store.save_binding(binding)
+
+        # Create a NEW store instance pointing to the same directory (simulating restart)
+        store2 = ExternalBindingStore(data_dir=tmp_path)
+        loaded = store2.load_all()
+
+        # Verify equivalence
+        assert len(loaded) == len(bindings), f"Expected {len(bindings)} bindings, got {len(loaded)}"
+
+        for binding in bindings:
+            assert binding.session_id in loaded, f"Missing session_id {binding.session_id!r} after reload"
+            reloaded = loaded[binding.session_id]
+            assert reloaded.session_id == binding.session_id
+            assert reloaded.user_id == binding.user_id
+            assert reloaded.cwd == binding.cwd
+            assert reloaded.bound_at == binding.bound_at
+            assert reloaded.jsonl_path == binding.jsonl_path
diff --git a/tests/property/test_discovery_properties.py b/tests/property/test_discovery_properties.py
new file mode 100644
index 0000000..872fcba
--- /dev/null
+++ b/tests/property/test_discovery_properties.py
@@ -0,0 +1,235 @@
+"""Property-based tests for ExternalSessionDiscoveryService.
+
+Feature: external-session-takeover
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from app.domain.hook_models import HookEvent
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+
+# --- Strategies ---
+
+# session_id must match ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$
+session_id_chars = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-")
+session_id_first_char = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+session_ids = st.builds(
+    lambda first, rest: first + rest,
+    session_id_first_char,
+    st.text(session_id_chars, min_size=0, max_size=30),
+)
+
+# cwd must be an absolute path
+cwds = st.builds(
+    lambda parts: "/" + "/".join(parts),
+    st.lists(
+        st.text(st.characters(whitelist_categories=("L", "N"), min_codepoint=65, max_codepoint=122), min_size=1, max_size=10),
+        min_size=1,
+        max_size=4,
+    ),
+)
+
+# pid: optional non-negative int
+pids = st.one_of(st.none(), st.integers(min_value=0, max_value=2**31))
+
+# valid hook event names
+hook_event_names = st.sampled_from(
+    [
+        "UserPromptSubmit",
+        "PreToolUse",
+        "PostToolUse",
+        "PermissionRequest",
+        "Notification",
+        "Stop",
+        "SubagentStop",
+        "SessionStart",
+        "SessionEnd",
+        "PreCompact",
+        "PostToolUseFailure",
+        "SubagentStart",
+        "PostCompact",
+        "StopFailure",
+        "PermissionDenied",
+    ]
+)
+
+hook_statuses = st.sampled_from(
+    [
+        "starting",
+        "processing",
+        "running",
+        "running_tool",
+        "waiting_for_approval",
+        "waiting_for_input",
+        "ended",
+        "failed",
+    ]
+)
+
+
+def hook_events(
+    session_id_strategy=session_ids,
+    event_strategy=hook_event_names,
+):
+    """Strategy that builds valid HookEvent instances."""
+    return st.builds(
+        HookEvent,
+        session_id=session_id_strategy,
+        cwd=cwds,
+        event=event_strategy,
+        status=hook_statuses,
+        pid=pids,
+    )
+
+
+# --- Property 1: Unbound event recording ---
+
+
+class TestUnboundEventRecording:
+    """Property 1: Unbound event recording.
+
+    **Validates: Requirements 1.1, 1.2, 1.4**
+
+    For any sequence of hook events recorded into the discovery service,
+    each unique session_id appears in the unbound list with correct metadata.
+    """
+
+    @settings(max_examples=150)
+    @given(events=st.lists(hook_events(), min_size=1, max_size=20))
+    def test_recorded_events_appear_in_unbound_list(self, events: list[HookEvent]):
+        """Every recorded session_id is discoverable (keyed by session_id)."""
+        svc = ExternalSessionDiscoveryService()
+        for ev in events:
+            svc.record_event(ev)
+
+        # Use _sessions directly to avoid pid liveness interference in tests
+        unbound_ids = set(svc._sessions.keys())
+
+        # Every unique session_id from events must be present
+        expected_ids = {ev.session_id for ev in events}
+        assert unbound_ids == expected_ids
+
+    @settings(max_examples=150)
+    @given(events=st.lists(hook_events(), min_size=1, max_size=20))
+    def test_metadata_correctness(self, events: list[HookEvent]):
+        """Unbound sessions have correct session_id, cwd, pid, and first_seen set."""
+        svc = ExternalSessionDiscoveryService()
+        for ev in events:
+            svc.record_event(ev)
+
+        for session in svc._sessions.values():
+            assert session.session_id != ""
+            assert session.cwd.startswith("/")
+            assert session.first_seen is not None
+            assert isinstance(session.first_seen, datetime)
+            assert session.first_seen.tzinfo is not None
+            # pid is either None or non-negative int
+            assert session.pid is None or session.pid >= 0
+
+
+# --- Property 2: SessionEnd removes from tracking ---
+
+
+class TestSessionEndRemoval:
+    """Property 2: SessionEnd removes from tracking.
+
+    **Validates: Requirements 1.3, 5.1**
+
+    After recording events for sessions and then removing them,
+    those sessions no longer appear in the discoverable list.
+    """
+
+    @settings(max_examples=150)
+    @given(
+        events=st.lists(hook_events(), min_size=1, max_size=15),
+        remove_fraction=st.floats(min_value=0.0, max_value=1.0),
+    )
+    def test_remove_session_removes_from_list(self, events: list[HookEvent], remove_fraction: float):
+        """Sessions removed via remove_session are no longer discoverable."""
+        svc = ExternalSessionDiscoveryService()
+        for ev in events:
+            svc.record_event(ev)
+
+        # After recording, all unique session_ids exist
+        all_ids = list(svc._sessions.keys())
+        # Remove a fraction of sessions
+        num_to_remove = int(len(all_ids) * remove_fraction)
+        to_remove = set(all_ids[:num_to_remove])
+
+        for sid in to_remove:
+            svc.remove_session(sid)
+
+        remaining = set(svc._sessions.keys())
+        # Removed sessions must not appear
+        assert remaining & to_remove == set()
+        # Non-removed sessions must still appear
+        assert remaining == set(all_ids) - to_remove
+
+
+# --- Property 7: Stale session pruning ---
+
+
+class TestStaleSessionPruning:
+    """Property 7: Stale session pruning.
+
+    **Validates: Requirements 5.4**
+
+    prune_stale removes exactly those sessions whose last_seen exceeds
+    stale_timeout_sec, and retains all others.
+    """
+
+    @settings(max_examples=150, suppress_health_check=[HealthCheck.too_slow])
+    @given(
+        data=st.data(),
+        num_sessions=st.integers(min_value=1, max_value=15),
+        stale_timeout=st.floats(min_value=1.0, max_value=3600.0),
+    )
+    def test_prune_removes_exactly_stale_sessions(self, data, num_sessions: int, stale_timeout: float):
+        """Only sessions with last_seen > stale_timeout_sec are pruned."""
+        svc = ExternalSessionDiscoveryService(stale_timeout_sec=stale_timeout)
+
+        # Generate unique session events and record them
+        generated_ids: list[str] = []
+        for _ in range(num_sessions):
+            ev = data.draw(hook_events())
+            # Ensure unique session_ids
+            while ev.session_id in generated_ids:
+                ev = data.draw(hook_events())
+            generated_ids.append(ev.session_id)
+            svc.record_event(ev)
+
+        # Now manipulate last_seen to create a mix of stale and fresh sessions
+        now = datetime.now(timezone.utc)
+        expected_stale: set[str] = set()
+        expected_fresh: set[str] = set()
+
+        for sid in generated_ids:
+            session = svc.get(sid)
+            assert session is not None
+            # Draw whether this session should be stale
+            is_stale = data.draw(st.booleans())
+            if is_stale:
+                # Set last_seen far in the past (beyond timeout)
+                offset = data.draw(st.floats(min_value=stale_timeout + 1, max_value=stale_timeout + 7200))
+                session.last_seen = now - timedelta(seconds=offset)
+                expected_stale.add(sid)
+            else:
+                # Set last_seen recently (within timeout)
+                offset = data.draw(st.floats(min_value=0.0, max_value=max(stale_timeout - 1, 0.0)))
+                session.last_seen = now - timedelta(seconds=offset)
+                expected_fresh.add(sid)
+
+        # Run prune
+        pruned_ids = set(svc.prune_stale())
+
+        # Pruned set should be exactly the stale sessions
+        assert pruned_ids == expected_stale
+
+        # Remaining should be exactly the fresh sessions
+        remaining_ids = {s.session_id for s in svc._sessions.values()}
+        assert remaining_ids == expected_fresh
diff --git a/tests/property/test_external_list_properties.py b/tests/property/test_external_list_properties.py
new file mode 100644
index 0000000..3669ec7
--- /dev/null
+++ b/tests/property/test_external_list_properties.py
@@ -0,0 +1,228 @@
+"""Property-based tests for external session list correctness.
+
+Feature: external-session-takeover, Property 16: List correctness per user
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from app.domain.external_session_models import ExternalBinding
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+from app.services.external_binding_store import ExternalBindingStore
+from app.domain.hook_models import HookEvent
+
+import tempfile
+from pathlib import Path
+
+# --- Strategies ---
+
+session_id_chars = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-")
+session_id_first_char = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+session_ids = st.builds(
+    lambda first, rest: first + rest,
+    session_id_first_char,
+    st.text(session_id_chars, min_size=1, max_size=30),
+)
+
+cwds = st.builds(
+    lambda parts: "/" + "/".join(parts),
+    st.lists(
+        st.text(
+            st.characters(whitelist_categories=("L", "N"), min_codepoint=65, max_codepoint=122),
+            min_size=1,
+            max_size=10,
+        ),
+        min_size=1,
+        max_size=4,
+    ),
+)
+
+user_ids = st.integers(min_value=1, max_value=10000)
+
+hook_event_names = st.sampled_from(
+    [
+        "UserPromptSubmit",
+        "PreToolUse",
+        "PostToolUse",
+        "PermissionRequest",
+        "Notification",
+        "Stop",
+        "SubagentStop",
+        "SessionStart",
+        "SessionEnd",
+    ]
+)
+
+hook_statuses = st.sampled_from(
+    [
+        "starting",
+        "processing",
+        "running",
+        "running_tool",
+        "waiting_for_approval",
+        "waiting_for_input",
+        "ended",
+        "failed",
+    ]
+)
+
+pids = st.one_of(st.none(), st.integers(min_value=0, max_value=2**31))
+
+
+def hook_events(session_id_strategy=session_ids):
+    """Strategy that builds valid HookEvent instances."""
+    return st.builds(
+        HookEvent,
+        session_id=session_id_strategy,
+        cwd=cwds,
+        event=hook_event_names,
+        status=hook_statuses,
+        pid=pids,
+    )
+
+
+# Strategy for generating a set of sessions, some unbound and some bound to different users
+@st.composite
+def session_mix(draw):
+    """Generate a mix of unbound sessions and sessions bound to different users.
+
+    Returns (unbound_session_ids, bindings_by_user) where bindings_by_user is
+    a dict mapping user_id -> list of (session_id, cwd) tuples.
+    """
+    num_sessions = draw(st.integers(min_value=1, max_value=20))
+    num_users = draw(st.integers(min_value=1, max_value=5))
+    users = list(range(1, num_users + 1))
+
+    # Generate unique session IDs
+    generated_ids: set[str] = set()
+    sessions: list[str] = []
+    for _ in range(num_sessions):
+        sid = draw(session_ids.filter(lambda s: s not in generated_ids))
+        generated_ids.add(sid)
+        sessions.append(sid)
+
+    # Assign each session: either unbound or bound to a random user
+    unbound_ids: list[tuple[str, str]] = []
+    bindings_by_user: dict[int, list[tuple[str, str]]] = {uid: [] for uid in users}
+
+    for sid in sessions:
+        cwd = draw(cwds)
+        is_bound = draw(st.booleans())
+        if is_bound:
+            uid = draw(st.sampled_from(users))
+            bindings_by_user[uid].append((sid, cwd))
+        else:
+            unbound_ids.append((sid, cwd))
+
+    return unbound_ids, bindings_by_user
+
+
+class TestListCorrectnessPerUser:
+    """Property 16: List correctness per user.
+
+    **Validates: Requirements 6.1**
+
+    Generate combinations of unbound sessions and sessions bound to different users.
+    Verify list_unbound() returns all unbound, list_bound_for_user(uid) returns only
+    that user's bindings. No sessions bound to other users should appear in a user's
+    bound list.
+    """
+
+    @settings(max_examples=150)
+    @given(data=session_mix())
+    def test_list_unbound_returns_all_unbound_sessions(self, data: tuple):
+        """list_unbound() returns exactly the set of unbound sessions."""
+        unbound_ids, bindings_by_user = data
+
+        discovery = ExternalSessionDiscoveryService()
+
+        # Record unbound sessions via hook events
+        for sid, cwd in unbound_ids:
+            event = HookEvent(
+                session_id=sid,
+                cwd=cwd,
+                event="UserPromptSubmit",
+                status="running",
+                pid=None,
+            )
+            discovery.record_event(event)
+
+        # Verify list_unbound returns exactly the unbound sessions
+        # Use _sessions to avoid pid liveness interference
+        unbound_session_ids = set(discovery._sessions.keys())
+        expected_unbound_ids = {sid for sid, _ in unbound_ids}
+
+        assert unbound_session_ids == expected_unbound_ids
+
+    @settings(max_examples=150)
+    @given(data=session_mix())
+    def test_list_bound_for_user_returns_only_that_users_bindings(self, data: tuple):
+        """get_bindings_for_user(uid) returns only sessions bound to uid."""
+        unbound_ids, bindings_by_user = data
+
+        with tempfile.TemporaryDirectory() as tmp:
+            store = ExternalBindingStore(data_dir=Path(tmp))
+
+            # Save all bindings
+            now = datetime.now(timezone.utc)
+            for uid, sessions in bindings_by_user.items():
+                for sid, cwd in sessions:
+                    binding = ExternalBinding(
+                        session_id=sid,
+                        user_id=uid,
+                        cwd=cwd,
+                        bound_at=now,
+                        jsonl_path=f"/tmp/{sid}.jsonl",
+                    )
+                    store.save_binding(binding)
+
+            # Verify each user only sees their own bindings
+            for uid, sessions in bindings_by_user.items():
+                user_bindings = store.get_bindings_for_user(uid)
+                user_binding_ids = {b.session_id for b in user_bindings}
+                expected_ids = {sid for sid, _ in sessions}
+
+                # User's bound list contains exactly their sessions
+                assert user_binding_ids == expected_ids
+
+                # No session bound to another user appears in this user's list
+                for other_uid, other_sessions in bindings_by_user.items():
+                    if other_uid == uid:
+                        continue
+                    other_ids = {sid for sid, _ in other_sessions}
+                    assert user_binding_ids & other_ids == set()
+
+    @settings(max_examples=150)
+    @given(data=session_mix())
+    def test_unbound_sessions_not_in_any_users_bound_list(self, data: tuple):
+        """Sessions that are unbound do not appear in any user's bound list."""
+        unbound_ids, bindings_by_user = data
+
+        with tempfile.TemporaryDirectory() as tmp:
+            store = ExternalBindingStore(data_dir=Path(tmp))
+
+            # Save all bindings
+            now = datetime.now(timezone.utc)
+            for uid, sessions in bindings_by_user.items():
+                for sid, cwd in sessions:
+                    binding = ExternalBinding(
+                        session_id=sid,
+                        user_id=uid,
+                        cwd=cwd,
+                        bound_at=now,
+                        jsonl_path=f"/tmp/{sid}.jsonl",
+                    )
+                    store.save_binding(binding)
+
+            # Verify no unbound session appears in any user's bound list
+            unbound_session_ids = {sid for sid, _ in unbound_ids}
+            all_users = list(bindings_by_user.keys())
+
+            for uid in all_users:
+                user_bindings = store.get_bindings_for_user(uid)
+                user_binding_ids = {b.session_id for b in user_bindings}
+                assert user_binding_ids & unbound_session_ids == set()
diff --git a/tests/property/test_ownership_resolver_properties.py b/tests/property/test_ownership_resolver_properties.py
new file mode 100644
index 0000000..fad0ea3
--- /dev/null
+++ b/tests/property/test_ownership_resolver_properties.py
@@ -0,0 +1,311 @@
+"""Property-based tests for SessionOwnershipResolver.
+
+Feature: external-session-takeover
+"""
+
+from __future__ import annotations
+
+import tempfile
+import pytest
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+from hypothesis import given, settings, HealthCheck, assume
+from hypothesis import strategies as st
+
+from app.domain.external_session_models import ExternalBinding, SessionOrigin
+from app.domain.models import SessionContext
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.session_ownership_resolver import SessionOwnershipResolver
+
+
+# --- Strategies ---
+
+session_ids = st.text(
+    alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="-_"),
+    min_size=4,
+    max_size=40,
+)
+
+user_ids = st.integers(min_value=1, max_value=999999)
+
+terminal_ids = st.text(
+    alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="-_"),
+    min_size=3,
+    max_size=20,
+)
+
+workdirs = st.text(
+    alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="/-_"),
+    min_size=2,
+    max_size=60,
+).map(lambda s: "/" + s.lstrip("/"))
+
+
+def _make_context(
+    *,
+    user_id: int,
+    claude_session_id: str,
+    terminal_id: str | None = None,
+    workdir: str = "/home/user/project",
+) -> SessionContext:
+    return SessionContext(
+        user_id=user_id,
+        session_id="internal-id",
+        provider="claude_code",
+        workdir=workdir,
+        terminal_mode=terminal_id is not None,
+        terminal_id=terminal_id,
+        claude_session_id=claude_session_id,
+    )
+
+
+def _make_binding(session_id: str, user_id: int, cwd: str = "/tmp") -> ExternalBinding:
+    return ExternalBinding(
+        session_id=session_id,
+        user_id=user_id,
+        cwd=cwd,
+        bound_at=datetime.now(timezone.utc),
+        jsonl_path=None,
+    )
+
+
+def _make_resolver(contexts: list[SessionContext], bindings: list[ExternalBinding] | None = None) -> SessionOwnershipResolver:
+    """Create a resolver with given contexts and optional bindings."""
+    with tempfile.TemporaryDirectory() as tmp:
+        store = ExternalBindingStore(data_dir=Path(tmp))
+        for b in bindings or []:
+            store.save_binding(b)
+
+        svc = AsyncMock()
+        svc.list_all = AsyncMock(return_value=contexts)
+
+        return SessionOwnershipResolver(session_service=svc, binding_store=store)
+
+
+# --- Property 9: Ownership resolver priority chain ---
+
+
+class TestOwnershipResolverPriorityChain:
+    """Feature: external-session-takeover, Property 9: Ownership resolver priority chain
+
+    Validates: Requirements 7.1, 7.2, 7.3, 7.4
+
+    For any session_id, the ownership resolver should return results according
+    to strict priority: tmux-owned > external-bound > unbound.
+    """
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(
+        target_session_id=session_ids,
+        tmux_user_id=user_ids,
+        tmux_terminal_id=terminal_ids,
+        binding_user_id=user_ids,
+    )
+    @pytest.mark.asyncio
+    async def test_tmux_always_wins_over_binding(
+        self,
+        target_session_id: str,
+        tmux_user_id: int,
+        tmux_terminal_id: str,
+        binding_user_id: int,
+    ) -> None:
+        """When both tmux ownership and external binding exist, tmux wins."""
+        contexts = [
+            _make_context(
+                user_id=tmux_user_id,
+                claude_session_id=target_session_id,
+                terminal_id=tmux_terminal_id,
+            ),
+        ]
+        bindings = [_make_binding(target_session_id, binding_user_id)]
+
+        resolver = _make_resolver(contexts, bindings)
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "owned"
+        assert result.origin == SessionOrigin.TMUX
+        assert result.owner_user_id == tmux_user_id
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(
+        target_session_id=session_ids,
+        binding_user_id=user_ids,
+        binding_cwd=workdirs,
+    )
+    @pytest.mark.asyncio
+    async def test_binding_wins_over_unbound(
+        self,
+        target_session_id: str,
+        binding_user_id: int,
+        binding_cwd: str,
+    ) -> None:
+        """When only external binding exists (no tmux), binding wins over unbound."""
+        bindings = [_make_binding(target_session_id, binding_user_id, binding_cwd)]
+
+        resolver = _make_resolver([], bindings)
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "bound"
+        assert result.origin == SessionOrigin.EXTERNAL
+        assert result.owner_user_id == binding_user_id
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(target_session_id=session_ids)
+    @pytest.mark.asyncio
+    async def test_unbound_when_no_ownership(
+        self,
+        target_session_id: str,
+    ) -> None:
+        """When neither tmux nor binding exists, returns unbound."""
+        resolver = _make_resolver([])
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "unbound"
+        assert result.origin == SessionOrigin.EXTERNAL
+        assert result.owner_user_id is None
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(
+        target_session_id=session_ids,
+        tmux_user_id=user_ids,
+        tmux_terminal_id=terminal_ids,
+        other_session_ids=st.lists(session_ids, min_size=0, max_size=5),
+        other_user_ids=st.lists(user_ids, min_size=0, max_size=5),
+    )
+    @pytest.mark.asyncio
+    async def test_tmux_match_requires_both_session_id_and_terminal_id(
+        self,
+        target_session_id: str,
+        tmux_user_id: int,
+        tmux_terminal_id: str,
+        other_session_ids: list[str],
+        other_user_ids: list[int],
+    ) -> None:
+        """Tmux ownership requires both matching claude_session_id AND terminal_id != None."""
+        contexts = []
+        # The target session HAS terminal_id → should be owned
+        contexts.append(
+            _make_context(
+                user_id=tmux_user_id,
+                claude_session_id=target_session_id,
+                terminal_id=tmux_terminal_id,
+            )
+        )
+        # Other sessions without terminal_id that should NOT interfere
+        for sid, uid in zip(other_session_ids, other_user_ids):
+            assume(sid != target_session_id)
+            contexts.append(_make_context(user_id=uid, claude_session_id=sid, terminal_id=None))
+
+        resolver = _make_resolver(contexts)
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "owned"
+        assert result.origin == SessionOrigin.TMUX
+        assert result.owner_user_id == tmux_user_id
+
+
+# --- Property 10: No workdir auto-bind for external sessions ---
+
+
+class TestNoWorkdirAutoBind:
+    """Feature: external-session-takeover, Property 10: No workdir auto-bind for external sessions
+
+    Validates: Requirements 7.5
+
+    For any unbound session whose cwd matches an existing user's workdir,
+    the resolver should still return "unbound".
+    """
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(
+        target_session_id=session_ids,
+        user_id=user_ids,
+        shared_workdir=workdirs,
+    )
+    @pytest.mark.asyncio
+    async def test_matching_workdir_no_terminal_id_still_unbound(
+        self,
+        target_session_id: str,
+        user_id: int,
+        shared_workdir: str,
+    ) -> None:
+        """Session without terminal_id is unbound even if cwd matches a user's workdir."""
+        contexts = [
+            _make_context(
+                user_id=user_id,
+                claude_session_id="some-other-session",
+                terminal_id=None,
+                workdir=shared_workdir,
+            ),
+        ]
+
+        resolver = _make_resolver(contexts)
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "unbound"
+        assert result.owner_user_id is None
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(
+        target_session_id=session_ids,
+        user_id=user_ids,
+        shared_workdir=workdirs,
+    )
+    @pytest.mark.asyncio
+    async def test_matching_session_id_but_no_terminal_still_unbound(
+        self,
+        target_session_id: str,
+        user_id: int,
+        shared_workdir: str,
+    ) -> None:
+        """Even if claude_session_id matches, without terminal_id it's still unbound.
+
+        This is the critical property: a context with matching session_id but
+        no terminal_id must NOT be treated as tmux-owned.
+        """
+        contexts = [
+            _make_context(
+                user_id=user_id,
+                claude_session_id=target_session_id,
+                terminal_id=None,
+                workdir=shared_workdir,
+            ),
+        ]
+
+        resolver = _make_resolver(contexts)
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "unbound"
+        assert result.owner_user_id is None
+
+    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
+    @given(
+        target_session_id=session_ids,
+        user_ids_list=st.lists(user_ids, min_size=1, max_size=5),
+        shared_workdir=workdirs,
+    )
+    @pytest.mark.asyncio
+    async def test_multiple_users_same_workdir_still_unbound(
+        self,
+        target_session_id: str,
+        user_ids_list: list[int],
+        shared_workdir: str,
+    ) -> None:
+        """Multiple users with same workdir, none with terminal_id → still unbound."""
+        contexts = [
+            _make_context(
+                user_id=uid,
+                claude_session_id=f"other-{i}",
+                terminal_id=None,
+                workdir=shared_workdir,
+            )
+            for i, uid in enumerate(user_ids_list)
+        ]
+
+        resolver = _make_resolver(contexts)
+        result = await resolver.resolve(target_session_id)
+
+        assert result.ownership_state == "unbound"
+        assert result.owner_user_id is None
diff --git a/tests/property/test_push_notifier_properties.py b/tests/property/test_push_notifier_properties.py
new file mode 100644
index 0000000..5f38f7a
--- /dev/null
+++ b/tests/property/test_push_notifier_properties.py
@@ -0,0 +1,149 @@
+"""Property-based tests for ExternalSessionPushNotifier.
+
+Feature: external-session-takeover
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.domain.session_models import SessionPhase
+from app.services.external_session_push_notifier import ExternalSessionPushNotifier
+
+
+# --- Property 15: Phase transitions trigger push notifications ---
+
+
+class TestPhaseTransitionPushNotifications:
+    """Property 15: Phase transitions trigger push notifications.
+
+    **Validates: Requirements 6.1, 6.2**
+
+    When a bound external session transitions phases, the bound user
+    receives a push notification containing session context.
+    """
+
+    @pytest.mark.asyncio
+    async def test_phase_change_sends_notification_to_bound_user(self):
+        """Phase change triggers push notification with session context."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        binding_store = MagicMock()
+
+        notifier = ExternalSessionPushNotifier(
+            bot=bot,
+            binding_store=binding_store,
+        )
+
+        user_id = 42
+        session_id = "abc12345-session"
+        cwd = "/home/user/project"
+
+        result = await notifier.notify_phase_change(
+            user_id=user_id,
+            session_id=session_id,
+            old_phase=SessionPhase.IDLE,
+            new_phase=SessionPhase.PROCESSING,
+            cwd=cwd,
+        )
+
+        assert result is True
+        bot.send_message.assert_called_once()
+        call_kwargs = bot.send_message.call_args.kwargs
+        assert call_kwargs["chat_id"] == user_id
+        # Message should contain session info and phase transition
+        text = call_kwargs["text"]
+        assert session_id[:8] in text
+        assert SessionPhase.IDLE.value in text
+        assert SessionPhase.PROCESSING.value in text
+        assert cwd in text
+
+    @pytest.mark.asyncio
+    async def test_all_phase_transitions_trigger_notification(self):
+        """Various phase transitions all produce push notifications."""
+        transitions = [
+            (SessionPhase.IDLE, SessionPhase.PROCESSING),
+            (SessionPhase.PROCESSING, SessionPhase.WAITING_FOR_INPUT),
+            (SessionPhase.WAITING_FOR_INPUT, SessionPhase.PROCESSING),
+            (SessionPhase.PROCESSING, SessionPhase.WAITING_FOR_APPROVAL),
+            (SessionPhase.PROCESSING, SessionPhase.ENDED),
+            (SessionPhase.PROCESSING, SessionPhase.COMPACTING),
+        ]
+
+        for old_phase, new_phase in transitions:
+            bot = MagicMock()
+            bot.send_message = AsyncMock()
+            binding_store = MagicMock()
+
+            notifier = ExternalSessionPushNotifier(
+                bot=bot,
+                binding_store=binding_store,
+            )
+
+            result = await notifier.notify_phase_change(
+                user_id=100,
+                session_id="session-xyz",
+                old_phase=old_phase,
+                new_phase=new_phase,
+                cwd="/tmp/work",
+            )
+
+            assert result is True, f"Failed for {old_phase} → {new_phase}"
+            bot.send_message.assert_called_once()
+            text = bot.send_message.call_args.kwargs["text"]
+            assert old_phase.value in text
+            assert new_phase.value in text
+
+    @pytest.mark.asyncio
+    async def test_notification_failure_returns_false(self):
+        """When bot.send_message fails, notify_phase_change returns False."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock(side_effect=Exception("Network error"))
+        binding_store = MagicMock()
+
+        notifier = ExternalSessionPushNotifier(
+            bot=bot,
+            binding_store=binding_store,
+            retry_count=0,
+        )
+
+        result = await notifier.notify_phase_change(
+            user_id=999,
+            session_id="failing-session",
+            old_phase=SessionPhase.IDLE,
+            new_phase=SessionPhase.PROCESSING,
+            cwd="/tmp/fail",
+        )
+
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_notification_contains_session_context(self):
+        """Push notification message includes session_id prefix and cwd."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        binding_store = MagicMock()
+
+        notifier = ExternalSessionPushNotifier(
+            bot=bot,
+            binding_store=binding_store,
+        )
+
+        session_id = "longsession12345"
+        cwd = "/home/dev/myproject"
+
+        await notifier.notify_phase_change(
+            user_id=7,
+            session_id=session_id,
+            old_phase=SessionPhase.PROCESSING,
+            new_phase=SessionPhase.ENDED,
+            cwd=cwd,
+        )
+
+        text = bot.send_message.call_args.kwargs["text"]
+        # Should contain short session id (first 8 chars)
+        assert session_id[:8] in text
+        # Should contain cwd
+        assert cwd in text
diff --git a/tests/property/test_session_id_utils.py b/tests/property/test_session_id_utils.py
new file mode 100644
index 0000000..d7e7f0e
--- /dev/null
+++ b/tests/property/test_session_id_utils.py
@@ -0,0 +1,67 @@
+# Feature: deduplicate-session-id-utils, Property tests for is_claude_session_id
+"""Property-based tests for the centralized is_claude_session_id function."""
+
+from __future__ import annotations
+
+import re
+import uuid
+
+from hypothesis import given, settings, assume
+from hypothesis import strategies as st
+
+from app.domain.session_models import _UUID_SESSION_RE, is_claude_session_id
+
+
+# Reference implementation for behavioral equivalence
+def _reference_is_claude_session_id(session_id: str | None) -> bool:
+    if not session_id:
+        return False
+    text = str(session_id).strip()
+    if not text:
+        return False
+    return text.startswith("claude-session-") or bool(
+        re.match(
+            r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
+            text,
+            re.IGNORECASE,
+        )
+    )
+
+
+# Strategy: valid claude session IDs
+_valid_uuid_strategy = st.builds(lambda: str(uuid.uuid4()))
+_valid_prefixed_strategy = st.builds(lambda u: f"claude-session-{u}", _valid_uuid_strategy)
+_valid_session_id_strategy = st.one_of(_valid_uuid_strategy, _valid_prefixed_strategy)
+
+
+@given(text=st.text())
+@settings(max_examples=200)
+def test_property_behavioral_equivalence_arbitrary_text(text: str) -> None:
+    """Property 1: For any string, is_claude_session_id matches reference implementation."""
+    assert is_claude_session_id(text) == _reference_is_claude_session_id(text)
+
+
+@given(session_id=_valid_session_id_strategy)
+@settings(max_examples=100)
+def test_property_valid_ids_accepted(session_id: str) -> None:
+    """Valid claude session IDs (UUID or prefixed UUID) are always accepted."""
+    assert is_claude_session_id(session_id) is True
+
+
+@given(text=st.text().filter(lambda t: not t.strip().startswith("claude-session-") and not _UUID_SESSION_RE.match(t.strip())))
+@settings(max_examples=200)
+def test_property_invalid_input_rejection(text: str) -> None:
+    """Property 2: Strings without the prefix and not matching UUID pattern are rejected."""
+    assume(text.strip())  # non-empty after strip
+    assert is_claude_session_id(text) is False
+
+
+def test_none_rejected() -> None:
+    """None input always returns False."""
+    assert is_claude_session_id(None) is False
+
+
+def test_empty_string_rejected() -> None:
+    """Empty string always returns False."""
+    assert is_claude_session_id("") is False
+    assert is_claude_session_id("   ") is False
diff --git a/tests/property/test_unbound_permission_properties.py b/tests/property/test_unbound_permission_properties.py
new file mode 100644
index 0000000..1d4f240
--- /dev/null
+++ b/tests/property/test_unbound_permission_properties.py
@@ -0,0 +1,435 @@
+"""Property-based tests for UnboundPermissionHandler.
+
+Feature: external-session-takeover
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from app.domain.hook_models import HookEvent
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+from app.services.unbound_permission_handler import UnboundPermissionHandler
+
+# --- Strategies ---
+
+session_id_chars = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-")
+session_id_first_char = st.sampled_from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+session_ids = st.builds(
+    lambda first, rest: first + rest,
+    session_id_first_char,
+    st.text(session_id_chars, min_size=1, max_size=20),
+)
+
+tool_use_ids = st.builds(
+    lambda first, rest: first + rest,
+    session_id_first_char,
+    st.text(session_id_chars, min_size=1, max_size=20),
+)
+
+cwds = st.builds(
+    lambda parts: "/" + "/".join(parts),
+    st.lists(
+        st.text(
+            st.characters(whitelist_categories=("L", "N"), min_codepoint=65, max_codepoint=122),
+            min_size=1,
+            max_size=10,
+        ),
+        min_size=1,
+        max_size=4,
+    ),
+)
+
+user_ids = st.integers(min_value=1, max_value=999999999)
+
+allowed_user_sets = st.frozensets(user_ids, min_size=1, max_size=5)
+
+tool_names = st.sampled_from(["Read", "Write", "Bash", "Edit", "ListDir", "Grep"])
+
+
+def _registry(token: str = "tokTest123") -> PermissionCallbackRegistry:
+    return PermissionCallbackRegistry(ttl_sec=300, token_factory=lambda: token)
+
+
+def permission_events(
+    session_id_st=session_ids,
+    tool_use_id_st=tool_use_ids,
+):
+    """Strategy for HookEvent representing a PermissionRequest."""
+    return st.builds(
+        lambda sid, cwd, tuid, tool: HookEvent(
+            session_id=sid,
+            cwd=cwd,
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool=tool,
+            tool_use_id=tuid,
+        ),
+        sid=session_id_st,
+        cwd=cwds,
+        tuid=tool_use_id_st,
+        tool=tool_names,
+    )
+
+
+# --- Property 11: Unbound permission broadcast to all allowed users ---
+
+
+class TestUnboundPermissionBroadcast:
+    """Property 11: Unbound permission broadcast to all allowed users.
+
+    **Validates: Requirements 4.1, 4.2**
+
+    When an unbound permission event is handled, every user in allowed_user_ids
+    gets notified with a message containing session_id and cwd.
+    """
+
+    @settings(max_examples=100)
+    @given(
+        event=permission_events(),
+        allowed_users=allowed_user_sets,
+    )
+    def test_all_allowed_users_notified(self, event: HookEvent, allowed_users: frozenset[int]):
+        """All users in allowed set receive notification with session_id and cwd."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids=set(allowed_users),
+            permission_callback_registry=_registry(),
+        )
+
+        asyncio.run(handler.handle_unbound_permission(event))
+
+        # Every allowed user should have been sent a message
+        notified_user_ids = {call.kwargs["chat_id"] for call in bot.send_message.call_args_list}
+        assert notified_user_ids == set(allowed_users)
+
+        # Each message should contain session short_id and cwd
+        for call in bot.send_message.call_args_list:
+            text = call.kwargs["text"]
+            assert event.session_id[:8] in text
+            assert event.cwd in text
+
+
+# --- Property 12: First-responder-wins for unbound permissions ---
+
+
+class TestFirstResponderWins:
+    """Property 12: First-responder-wins for unbound permissions.
+
+    **Validates: Requirements 4.3**
+
+    Only the first response to an unbound permission request is accepted;
+    all subsequent responses are rejected. respond_to_permission is called once.
+    """
+
+    @settings(max_examples=100)
+    @given(
+        event=permission_events(),
+        responders=st.lists(user_ids, min_size=2, max_size=6),
+    )
+    def test_only_first_response_wins(self, event: HookEvent, responders: list[int]):
+        """Only the first responder's decision is forwarded; others return False."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={responders[0]},
+            permission_callback_registry=_registry(),
+        )
+
+        asyncio.run(handler.handle_unbound_permission(event))
+
+        tool_use_id = event.tool_use_id
+
+        # First response should return True
+        result_first = asyncio.run(handler.handle_response(tool_use_id=tool_use_id, user_id=responders[0], decision="approve"))
+        assert result_first.accepted is True
+
+        # All subsequent responses should return False
+        for user_id in responders[1:]:
+            result = asyncio.run(handler.handle_response(tool_use_id=tool_use_id, user_id=user_id, decision="approve"))
+            assert result.accepted is False
+
+        # respond_to_permission called exactly once
+        assert hook_socket_server.respond_to_permission.call_count == 1
+
+
+# --- Property 13: Permission approval doesn't auto-bind ---
+
+
+class TestPermissionApprovalNoAutoBind:
+    """Property 13: Permission approval doesn't auto-bind.
+
+    **Validates: Requirements 4.4**
+
+    After approving an unbound permission, the session remains in the
+    discovery service's unbound list.
+    """
+
+    @settings(max_examples=100)
+    @given(
+        event=permission_events(),
+        approver=user_ids,
+    )
+    def test_session_remains_in_discovery_after_approval(self, event: HookEvent, approver: int):
+        """Approving a permission does not remove session from discovery."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        discovery = ExternalSessionDiscoveryService()
+        # Record session in discovery first
+        discovery.record_event(event)
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={approver},
+            permission_callback_registry=_registry(),
+        )
+
+        # Handle permission and approve
+        asyncio.run(handler.handle_unbound_permission(event))
+        asyncio.run(handler.handle_response(tool_use_id=event.tool_use_id, user_id=approver, decision="approve"))
+
+        # Session must still be in discovery (not auto-removed)
+        unbound_ids = {s.session_id for s in discovery.list_unbound()}
+        assert event.session_id in unbound_ids
+
+
+# --- Property 14: TTL expiry auto-denies ---
+
+
+class TestTTLExpiryAutoDenies:
+    """Property 14: TTL expiry auto-denies.
+
+    **Validates: Requirements 4.5**
+
+    If no user responds within TTL, the permission is auto-denied via
+    respond_to_permission with decision="deny".
+    """
+
+    @pytest.mark.asyncio
+    async def test_ttl_expiry_triggers_auto_deny(self):
+        """After TTL expires without response, permission is auto-denied."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={12345},
+            permission_ttl_sec=0,  # Expire immediately
+            permission_callback_registry=_registry(),
+        )
+
+        event = HookEvent(
+            session_id="sess001",
+            cwd="/tmp/project",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Bash",
+            tool_use_id="tuid001",
+        )
+
+        await handler.handle_unbound_permission(event)
+
+        # Wait for the expiry task to fire
+        await asyncio.sleep(0.05)
+
+        # respond_to_permission should have been called with "deny"
+        hook_socket_server.respond_to_permission.assert_called_once_with(
+            tool_use_id="tuid001",
+            decision="deny",
+            reason="no user responded within TTL",
+        )
+
+    @pytest.mark.asyncio
+    async def test_ttl_expiry_prevents_late_response(self):
+        """After TTL auto-deny, late user responses are rejected."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={12345},
+            permission_ttl_sec=0,
+            permission_callback_registry=_registry(),
+        )
+
+        event = HookEvent(
+            session_id="sess002",
+            cwd="/tmp/work",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Write",
+            tool_use_id="tuid002",
+        )
+
+        await handler.handle_unbound_permission(event)
+        await asyncio.sleep(0.05)
+
+        # Late response should return False
+        result = await handler.handle_response(tool_use_id="tuid002", user_id=12345, decision="approve")
+        assert result.accepted is False
+
+
+# --- Task 6: Response removes pending and expiry, concurrent first-responder-wins ---
+
+
+class TestResponseRemovesPendingAndExpiry:
+    """After handle_response, _pending and _expiry_tasks are cleaned up."""
+
+    @pytest.mark.asyncio
+    async def test_response_removes_unbound_pending_and_expiry_task(self):
+        """handle_response returns accepted=True, forwarded=True and cleans up state."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={42},
+            permission_ttl_sec=60,
+            permission_callback_registry=_registry(),
+        )
+
+        event = HookEvent(
+            session_id="sess-cleanup01",
+            cwd="/tmp/proj",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Bash",
+            tool_use_id="tuid-cleanup01",
+        )
+
+        await handler.handle_unbound_permission(event)
+        assert "tuid-cleanup01" in handler._pending
+        assert "tuid-cleanup01" in handler._expiry_tasks
+
+        result = await handler.handle_response(tool_use_id="tuid-cleanup01", user_id=42, decision="allow")
+        assert result.accepted is True
+        assert result.forwarded is True
+
+        # State must be cleaned up
+        assert "tuid-cleanup01" not in handler._pending
+        assert "tuid-cleanup01" not in handler._expiry_tasks
+
+
+class TestExpiryRemovesPendingAndExpiry:
+    """After TTL expiry, _pending and _expiry_tasks are cleaned up."""
+
+    @pytest.mark.asyncio
+    async def test_expiry_removes_unbound_pending_and_expiry_task(self):
+        """With TTL=0, after short sleep _pending and _expiry_tasks are empty."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={42},
+            permission_ttl_sec=0,
+            permission_callback_registry=_registry(),
+        )
+
+        event = HookEvent(
+            session_id="sess-expiry01",
+            cwd="/tmp/proj",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Bash",
+            tool_use_id="tuid-expiry01",
+        )
+
+        await handler.handle_unbound_permission(event)
+        assert "tuid-expiry01" in handler._pending
+
+        await asyncio.sleep(0.05)
+
+        assert "tuid-expiry01" not in handler._pending
+        assert "tuid-expiry01" not in handler._expiry_tasks
+
+
+class TestConcurrentUnboundResponses:
+    """Concurrent responses: exactly one accepted, respond_to_permission called once."""
+
+    @pytest.mark.asyncio
+    async def test_concurrent_unbound_responses_preserve_first_responder_wins(self):
+        """Multiple concurrent handle_response tasks: exactly 1 accepted."""
+        bot = MagicMock()
+        bot.send_message = AsyncMock()
+        hook_socket_server = MagicMock()
+        hook_socket_server.respond_to_permission = AsyncMock()
+
+        handler = UnboundPermissionHandler(
+            bot=bot,
+            hook_socket_server=hook_socket_server,
+            allowed_user_ids={100, 200, 300},
+            permission_ttl_sec=60,
+            permission_callback_registry=_registry(),
+        )
+
+        event = HookEvent(
+            session_id="sess-concurrent01",
+            cwd="/tmp/proj",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool="Bash",
+            tool_use_id="tuid-concurrent01",
+        )
+
+        await handler.handle_unbound_permission(event)
+
+        # Create a gate so all tasks attempt nearly simultaneously
+        gate = asyncio.Event()
+
+        async def respond(user_id: int, decision: str):
+            await gate.wait()
+            return await handler.handle_response(
+                tool_use_id="tuid-concurrent01",
+                user_id=user_id,
+                decision=decision,
+            )
+
+        tasks = [
+            asyncio.create_task(respond(100, "allow")),
+            asyncio.create_task(respond(200, "deny")),
+            asyncio.create_task(respond(300, "allow")),
+        ]
+
+        # Release all tasks at once
+        gate.set()
+        results = await asyncio.gather(*tasks)
+
+        accepted_count = sum(1 for r in results if r.accepted is True)
+        assert accepted_count == 1
+
+        # respond_to_permission called exactly once
+        hook_socket_server.respond_to_permission.assert_called_once()
diff --git a/tests/test_agent_file_watcher.py b/tests/test_agent_file_watcher.py
new file mode 100644
index 0000000..6bb577a
--- /dev/null
+++ b/tests/test_agent_file_watcher.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from app.services.agent_file_watcher import AgentFileWatcher
+
+
+class DummySessionStore:
+    def get(self, session_id: str):
+        return None
+
+
+class DummyParser:
+    def subagent_file_path(self, *, session_id: str, agent_id: str, cwd: str):
+        raise AssertionError("subagent_file_path should not be called in these cleanup tests")
+
+    def reset_state(self, session_id: str) -> None:
+        return None
+
+
+@pytest.mark.asyncio
+async def test_forget_clears_all_seen_mtime_keys_for_session() -> None:
+    watcher = AgentFileWatcher(
+        session_store=DummySessionStore(),
+        claude_jsonl_parser=DummyParser(),
+        on_update=lambda session_id, workdir: asyncio.sleep(0),
+    )
+    watcher._seen_mtimes = {
+        "session-1:tool-a:agent-a": 1.0,
+        "session-1:tool-b:agent-b": 2.0,
+        "session-2:tool-c:agent-c": 3.0,
+    }
+    watcher._session_locks["session-1"] = asyncio.Lock()
+
+    watcher.forget("session-1")
+
+    assert watcher._seen_mtimes == {"session-2:tool-c:agent-c": 3.0}
+    assert "session-1" not in watcher._session_locks
+
+
+@pytest.mark.asyncio
+async def test_forget_defers_lock_cleanup_until_running_watcher_exits() -> None:
+    release_update = asyncio.Event()
+    update_started = asyncio.Event()
+
+    async def on_update(session_id: str, workdir: str) -> None:
+        update_started.set()
+        await release_update.wait()
+
+    watcher = AgentFileWatcher(
+        session_store=DummySessionStore(),
+        claude_jsonl_parser=DummyParser(),
+        on_update=on_update,
+    )
+
+    async def fake_watch_session(*, session_id: str, workdir: str) -> None:
+        lock = watcher._session_locks.setdefault(session_id, asyncio.Lock())
+        task = asyncio.current_task()
+        try:
+            async with lock:
+                await on_update(session_id, workdir)
+        finally:
+            watcher._cleanup_finished_session(session_id=session_id, task=task)
+
+    watcher._tasks["session-1"] = asyncio.create_task(fake_watch_session(session_id="session-1", workdir="/tmp/project"))
+    await update_started.wait()
+    watcher._seen_mtimes = {"session-1:tool-a:agent-a": 1.0}
+
+    # Store task before forget pops it
+    task = watcher._tasks["session-1"]
+    watcher.forget("session-1")
+
+    assert "session-1:tool-a:agent-a" not in watcher._seen_mtimes
+    assert "session-1" in watcher._session_locks
+
+    release_update.set()
+    with pytest.raises(asyncio.CancelledError):
+        await task
+    await asyncio.sleep(0)
+
+    assert "session-1" not in watcher._session_locks
diff --git a/tests/test_auth_settings.py b/tests/test_auth_settings.py
index 3c48ead..45f4350 100644
--- a/tests/test_auth_settings.py
+++ b/tests/test_auth_settings.py
@@ -1,3 +1,4 @@
+import asyncio
 from pathlib import Path
 from types import SimpleNamespace
 
@@ -18,6 +19,18 @@ async def answer(self, text: str, show_alert: bool = False) -> None:
         self.answers.append(text)
 
 
+class SlowAnswerCallbackQuery(DummyCallbackQuery):
+    def __init__(self, user_id: int | None = 1) -> None:
+        super().__init__(user_id)
+        self.answer_started = asyncio.Event()
+        self.release_answer = asyncio.Event()
+
+    async def answer(self, text: str, show_alert: bool = False) -> None:
+        self.answer_started.set()
+        await self.release_answer.wait()
+        await super().answer(text, show_alert)
+
+
 async def _passing_handler(event, data):
     data["called"] = True
     return "ok"
@@ -119,6 +132,135 @@ def test_env_example_matches_supported_claude_settings() -> None:
     assert "CLAUDE_PERIODIC_RECHECK_MS=500" in content
 
 
+_BASE_PAYLOAD = {
+    "TG_BOT_TOKEN": "token",
+    "TG_ALLOWED_USER_IDS": "1",
+    "DEFAULT_PROVIDER": "claude_code",
+    "DEFAULT_TIMEOUT_SEC": 10,
+    "MAX_CONCURRENT_TASKS": 1,
+    "CLAUDE_TMUX_MODE": False,
+    "CLAUDE_CLI_BIN": "claude",
+    "CODEX_CLI_BIN": "codex",
+    "GEMINI_CLI_BIN": "gemini",
+    "ALLOWED_WORKDIRS": "/tmp",
+}
+
+
+def test_settings_new_fields_defaults() -> None:
+    settings = Settings.model_validate(_BASE_PAYLOAD)
+    assert settings.task_store_ttl_hours == 168
+    assert settings.task_store_max_records == 1000
+    assert settings.rate_limit_bucket_ttl_sec is None
+    assert settings.rate_limit_bucket_cleanup_interval_sec == 60
+    assert settings.rate_limit_bucket_cleanup_batch_size == 50
+    assert settings.permission_lock_ttl_sec is None
+    assert settings.session_lock_ttl_sec == 3600
+    assert settings.lock_cleanup_interval_sec == 60
+    assert settings.lock_cleanup_batch_size == 50
+    assert settings.upload_queue_max_files_per_user == 5
+    assert settings.upload_queue_max_bytes_per_user is None
+    assert settings.effective_upload_queue_max_bytes_per_user == 5 * 20 * 1024 * 1024
+
+
+def test_settings_derived_defaults() -> None:
+    settings = Settings.model_validate(_BASE_PAYLOAD)
+    assert settings.effective_rate_limit_bucket_ttl_sec == settings.rate_limit_window_sec
+    assert settings.effective_permission_lock_ttl_sec == settings.claude_hook_pending_permission_ttl_sec
+
+
+def test_settings_treats_blank_optional_cleanup_fields_as_none() -> None:
+    settings = Settings.model_validate({**_BASE_PAYLOAD, "RATE_LIMIT_BUCKET_TTL_SEC": "", "PERMISSION_LOCK_TTL_SEC": ""})
+
+    assert settings.rate_limit_bucket_ttl_sec is None
+    assert settings.permission_lock_ttl_sec is None
+
+
+def test_settings_explicit_override_new_fields() -> None:
+    payload = {
+        **_BASE_PAYLOAD,
+        "TASK_STORE_TTL_HOURS": 72,
+        "TASK_STORE_MAX_RECORDS": 500,
+        "RATE_LIMIT_BUCKET_TTL_SEC": 30,
+        "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC": 120,
+        "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE": 10,
+        "PERMISSION_LOCK_TTL_SEC": 120,
+        "SESSION_LOCK_TTL_SEC": 7200,
+        "LOCK_CLEANUP_INTERVAL_SEC": 30,
+        "LOCK_CLEANUP_BATCH_SIZE": 25,
+        "UPLOAD_QUEUE_MAX_FILES_PER_USER": 2,
+        "UPLOAD_QUEUE_MAX_BYTES_PER_USER": 1234,
+    }
+    settings = Settings.model_validate(payload)
+    assert settings.task_store_ttl_hours == 72
+    assert settings.task_store_max_records == 500
+    assert settings.rate_limit_bucket_ttl_sec == 30
+    assert settings.rate_limit_bucket_cleanup_interval_sec == 120
+    assert settings.rate_limit_bucket_cleanup_batch_size == 10
+    assert settings.permission_lock_ttl_sec == 120
+    assert settings.session_lock_ttl_sec == 7200
+    assert settings.lock_cleanup_interval_sec == 30
+    assert settings.lock_cleanup_batch_size == 25
+    assert settings.upload_queue_max_files_per_user == 2
+    assert settings.upload_queue_max_bytes_per_user == 1234
+    assert settings.effective_upload_queue_max_bytes_per_user == 1234
+    assert settings.effective_rate_limit_bucket_ttl_sec == 30
+    assert settings.effective_permission_lock_ttl_sec == 120
+
+
+def test_settings_allows_upload_queue_disabled_with_zero_files() -> None:
+    settings = Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_FILES_PER_USER": 0})
+    assert settings.upload_queue_max_files_per_user == 0
+    assert settings.effective_upload_queue_max_bytes_per_user == 0
+
+
+def test_settings_rejects_invalid_upload_queue_values() -> None:
+    with pytest.raises(ValidationError):
+        Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_FILES_PER_USER": -1})
+    with pytest.raises(ValidationError):
+        Settings.model_validate({**_BASE_PAYLOAD, "UPLOAD_QUEUE_MAX_BYTES_PER_USER": 0})
+
+
+def test_settings_rejects_bucket_ttl_below_rate_limit_window() -> None:
+    with pytest.raises(ValidationError):
+        Settings.model_validate({**_BASE_PAYLOAD, "RATE_LIMIT_WINDOW_SEC": 20, "RATE_LIMIT_BUCKET_TTL_SEC": 10})
+
+
+def test_settings_rejects_non_positive_new_fields() -> None:
+    for field in (
+        "TASK_STORE_TTL_HOURS",
+        "TASK_STORE_MAX_RECORDS",
+        "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC",
+        "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE",
+        "SESSION_LOCK_TTL_SEC",
+        "LOCK_CLEANUP_INTERVAL_SEC",
+        "LOCK_CLEANUP_BATCH_SIZE",
+    ):
+        with pytest.raises(ValidationError):
+            Settings.model_validate({**_BASE_PAYLOAD, field: 0})
+
+    for field in ("RATE_LIMIT_BUCKET_TTL_SEC", "PERMISSION_LOCK_TTL_SEC"):
+        with pytest.raises(ValidationError):
+            Settings.model_validate({**_BASE_PAYLOAD, field: 0})
+        with pytest.raises(ValidationError):
+            Settings.model_validate({**_BASE_PAYLOAD, field: -1})
+
+
+def test_env_example_contains_new_entries() -> None:
+    content = (Path(__file__).resolve().parents[1] / "deploy" / "env" / ".env.example").read_text(encoding="utf-8")
+    assert "RATE_LIMIT_BUCKET_TTL_SEC=" in content
+    assert "RATE_LIMIT_BUCKET_CLEANUP_INTERVAL_SEC=60" in content
+    assert "RATE_LIMIT_BUCKET_CLEANUP_BATCH_SIZE=50" in content
+    assert "TASK_STORE_TTL_HOURS=168" in content
+    assert "TASK_STORE_MAX_RECORDS=1000" in content
+    assert "PERMISSION_LOCK_TTL_SEC=" in content
+    assert "SESSION_LOCK_TTL_SEC=3600" in content
+    assert "LOCK_CLEANUP_INTERVAL_SEC=60" in content
+    assert "LOCK_CLEANUP_BATCH_SIZE=50" in content
+    assert "UPLOAD_MAX_FILE_SIZE_MB=20" in content
+    assert "UPLOAD_QUEUE_MAX_FILES_PER_USER=5" in content
+    assert "UPLOAD_QUEUE_MAX_BYTES_PER_USER=" in content
+
+
 @pytest.mark.asyncio
 async def test_auth_middleware_rejects_callback_query_user() -> None:
     middleware = AuthMiddleware({1})
@@ -156,3 +298,185 @@ async def test_rate_limit_middleware_limits_callback_query_user() -> None:
     assert first == "ok"
     assert second is None
     assert callback.answers == ["请求过于频繁,请稍后再试。"]
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_slow_answer_does_not_block_other_users() -> None:
+    middleware = RateLimitMiddleware(limit=1, window_sec=20)
+    limited_user = SlowAnswerCallbackQuery(user_id=1)
+    await middleware(_passing_handler, limited_user, {})
+
+    limited_task = asyncio.create_task(middleware(_passing_handler, limited_user, {}))
+    await limited_user.answer_started.wait()
+
+    other_user = DummyCallbackQuery(user_id=2)
+    other_task = asyncio.create_task(middleware(_passing_handler, other_user, {}))
+    try:
+        assert await asyncio.wait_for(other_task, timeout=0.1) == "ok"
+    finally:
+        limited_user.release_answer.set()
+        await limited_task
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_window_cleanup_allows_after_window() -> None:
+    """After the rate-limit window expires, the user can pass again."""
+    middleware = RateLimitMiddleware(limit=1, window_sec=5)
+    callback = DummyCallbackQuery(user_id=1)
+
+    first = await middleware(_passing_handler, callback, {})
+    assert first == "ok"
+
+    # Second request within window is blocked.
+    second = await middleware(_passing_handler, callback, {})
+    assert second is None
+
+    # Advance time past the window by manipulating the bucket timestamps.
+    bucket = middleware._buckets[1]
+    bucket[0] -= 6.0  # make the timestamp 6 seconds ago
+
+    third = await middleware(_passing_handler, callback, {})
+    assert third == "ok"
+
+
+def test_rate_limit_rejects_bucket_ttl_below_window() -> None:
+    with pytest.raises(ValueError):
+        RateLimitMiddleware(limit=2, window_sec=20, bucket_ttl_sec=10)
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_bucket_ttl_defaults_to_window_sec() -> None:
+    middleware = RateLimitMiddleware(limit=2, window_sec=1, cleanup_interval_sec=60, cleanup_batch_size=50)
+    await middleware(_passing_handler, DummyCallbackQuery(user_id=1), {})
+
+    middleware._buckets[1][-1] -= 2.0
+    middleware._last_cleanup_ts = 0.0
+
+    await middleware(_passing_handler, DummyCallbackQuery(user_id=2), {})
+
+    assert 1 not in middleware._buckets
+
+
+@pytest.mark.asyncio
+async def test_global_cleanup_respects_interval_and_batch() -> None:
+    """Global cleanup runs only after the interval and processes at most batch_size items."""
+    middleware = RateLimitMiddleware(
+        limit=1,
+        window_sec=1,
+        bucket_ttl_sec=1,
+        cleanup_interval_sec=100,
+        cleanup_batch_size=2,
+    )
+
+    # Create 4 buckets by sending requests from 4 users.
+    for uid in range(1, 5):
+        cb = DummyCallbackQuery(user_id=uid)
+        await middleware(_passing_handler, cb, {})
+
+    assert len(middleware._buckets) == 4
+    assert len(middleware._cleanup_queue) == 4
+
+    # Make all buckets stale (timestamps far in the past).
+    for uid in range(1, 5):
+        middleware._buckets[uid][-1] -= 200.0
+
+    # Reset _last_cleanup_ts so the next request triggers global cleanup.
+    middleware._last_cleanup_ts = 0.0
+
+    # Trigger a request from a fresh user to force global cleanup.
+    # cleanup_batch_size=2, so at most 2 stale buckets are removed.
+    cb_fresh = DummyCallbackQuery(user_id=99)
+    await middleware(_passing_handler, cb_fresh, {})
+
+    # At least 2 stale buckets should have been cleaned (batch_size=2).
+    # user 99 also got a new bucket. Original 4 minus at least 2 cleaned = at most 2 + user 99.
+    assert len(middleware._buckets) <= 3  # 4 originals - 2 cleaned + 1 new
+
+
+@pytest.mark.asyncio
+async def test_cleanup_queue_re_enqueue_after_bucket_recreation() -> None:
+    """After a bucket is deleted, re-creating it puts it back in the cleanup queue."""
+    middleware = RateLimitMiddleware(
+        limit=2,
+        window_sec=1,
+        bucket_ttl_sec=1,
+        cleanup_interval_sec=60,
+        cleanup_batch_size=100,
+    )
+
+    cb = DummyCallbackQuery(user_id=42)
+    await middleware(_passing_handler, cb, {})
+    assert 42 in middleware._cleanup_queued
+
+    # Make the bucket stale and reset _last_cleanup_ts to force cleanup on next request.
+    middleware._buckets[42][-1] -= 200.0
+    middleware._last_cleanup_ts = 0.0
+
+    cb2 = DummyCallbackQuery(user_id=999)
+    await middleware(_passing_handler, cb2, {})
+
+    # Bucket 42 should have been cleaned away (stale for 200s > TTL 1s).
+    assert 42 not in middleware._buckets
+    assert 42 not in middleware._cleanup_queued
+
+    # Re-create the bucket for user 42.
+    await middleware(_passing_handler, cb, {})
+    assert 42 in middleware._buckets
+    assert 42 in middleware._cleanup_queued
+
+
+def _container_payload(tmp_path: Path, **overrides: object) -> dict[str, object]:
+    """Build a Settings payload valid for AppContainer construction."""
+    base: dict[str, object] = {
+        "TG_BOT_TOKEN": "123456:TESTTOKEN",
+        "TG_ALLOWED_USER_IDS": "1",
+        "DEFAULT_PROVIDER": "claude_code",
+        "DEFAULT_TIMEOUT_SEC": 10,
+        "MAX_CONCURRENT_TASKS": 1,
+        "CLAUDE_TMUX_MODE": False,
+        "CLAUDE_CLI_BIN": "claude",
+        "CODEX_CLI_BIN": "codex",
+        "GEMINI_CLI_BIN": "gemini",
+        "TMUX_DATA_DIR": str(tmp_path),
+        "CLAUDE_CONFIG_DIR": str(tmp_path / ".claude"),
+        "CLAUDE_HOOK_SOCKET_PATH": str(tmp_path / "hook.sock"),
+        "ALLOWED_WORKDIRS": str(tmp_path),
+    }
+    base.update(overrides)
+    return base
+
+
+def test_container_wiring_passes_settings_to_task_store(tmp_path: Path) -> None:
+    """AppContainer passes TASK_STORE_TTL_HOURS and TASK_STORE_MAX_RECORDS to task_store."""
+    from app.bootstrap import AppContainer
+
+    settings = Settings.model_validate(_container_payload(tmp_path, TASK_STORE_TTL_HOURS=72, TASK_STORE_MAX_RECORDS=500))
+    container = AppContainer(settings)
+
+    assert container.task_store._max_records == 500
+    assert container.task_store._ttl.total_seconds() == 72 * 3600
+    assert container.upload_queue._max_files_per_user == settings.upload_queue_max_files_per_user
+    assert container.upload_queue._max_bytes_per_user == settings.effective_upload_queue_max_bytes_per_user
+
+    # wire() should succeed and register RateLimitMiddleware on dispatcher
+    container.wire()
+
+    # Verify RateLimitMiddleware is registered on dispatcher message middleware
+    outer = container.dispatcher.message.middleware
+    registered = [m for m in outer._middlewares if isinstance(m, RateLimitMiddleware)]
+    assert len(registered) == 1
+    rl = registered[0]
+    assert rl._cleanup_interval_sec == settings.rate_limit_bucket_cleanup_interval_sec
+    assert rl._cleanup_batch_size == settings.rate_limit_bucket_cleanup_batch_size
+    assert rl._bucket_ttl_sec == settings.effective_rate_limit_bucket_ttl_sec
+
+
+def test_container_wiring_task_store_defaults(tmp_path: Path) -> None:
+    """AppContainer uses default settings when TASK_STORE_* fields are not overridden."""
+    from app.bootstrap import AppContainer
+
+    settings = Settings.model_validate(_container_payload(tmp_path))
+    container = AppContainer(settings)
+
+    assert container.task_store._max_records == 1000
+    assert container.task_store._ttl.total_seconds() == 168 * 3600
diff --git a/tests/test_bootstrap_hooks.py b/tests/test_bootstrap_hooks.py
index eda1293..a22e4ad 100644
--- a/tests/test_bootstrap_hooks.py
+++ b/tests/test_bootstrap_hooks.py
@@ -9,7 +9,7 @@
 from app.config.settings import Settings
 from app.domain.hook_models import HookEvent
 from app.domain.models import TaskRecord, TaskStatus
-from app.domain.session_models import ConversationTurn, SessionPhase, ToolCallRecord, ToolStatus
+from app.domain.session_models import ConversationTurn, SessionEvent, SessionEventType, SessionPhase, ToolCallRecord, ToolStatus
 from app.services.agent_file_watcher import AgentFileWatcher
 from app.services.interrupt_watcher import InterruptWatcher
 
@@ -45,6 +45,10 @@ def make_settings(tmp_path, *, install_hooks: bool = True) -> Settings:
     )
 
 
+def use_legacy_hook_binding_path(container: AppContainer) -> None:
+    delattr(container, "ownership_resolver")
+
+
 @pytest.mark.asyncio
 async def test_app_container_start_installs_hooks_and_starts_server(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=True))
@@ -107,6 +111,7 @@ async def fake_close():
 @pytest.mark.asyncio
 async def test_handle_hook_event_binds_session_and_syncs_jsonl(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
 
     session = await container.session_service.switch(
         user_id=1,
@@ -130,9 +135,9 @@ async def test_handle_hook_event_binds_session_and_syncs_jsonl(tmp_path, monkeyp
 
     async def fake_sync(session_id: str, cwd: str) -> None:
         await container._dispatch_session_event(
-            container.structured_session_store.process.__globals__["SessionEvent"](
+            SessionEvent(
                 session_id=session_id,
-                type=container.structured_session_store.process.__globals__["SessionEventType"].FILE_SYNCED,
+                type=SessionEventType.FILE_SYNCED,
                 payload={
                     "cwd": cwd,
                     "claude_session_id": session_id,
@@ -182,6 +187,7 @@ async def fake_sync(session_id: str, cwd: str) -> None:
 @pytest.mark.asyncio
 async def test_handle_hook_event_binds_session_by_unique_active_claude_chat_workdir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
 
     session = await container.session_service.switch(
         user_id=1,
@@ -296,6 +302,7 @@ async def test_handle_hook_event_binds_session_when_terminal_state_has_content_w
     tmp_path, monkeypatch: pytest.MonkeyPatch
 ) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
 
     session = await container.session_service.switch(
         user_id=1,
@@ -352,6 +359,7 @@ async def test_handle_hook_event_binds_session_when_pending_interactive_task_mat
     tmp_path, monkeypatch: pytest.MonkeyPatch
 ) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
 
     session = await container.session_service.switch(
         user_id=1,
@@ -444,6 +452,7 @@ async def fake_sync(session_id: str, cwd: str) -> None:
 @pytest.mark.asyncio
 async def test_handle_hook_event_runs_bind_before_dispatch_and_sync(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
     seen: list[str] = []
 
     async def fake_bind(event: HookEvent) -> None:
@@ -502,6 +511,7 @@ def fake_schedule(session_id: str, cwd: str) -> None:
 @pytest.mark.asyncio
 async def test_handle_hook_event_debounces_jsonl_sync(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
     seen: list[tuple[str, str]] = []
 
     async def fake_sync(session_id: str, cwd: str) -> None:
@@ -539,9 +549,8 @@ def fake_parse_incremental(*, session_id: str, cwd: str):
 
     monkeypatch.setattr(container.claude_jsonl_parser, "parse_incremental", fake_parse_incremental)
 
-    lock = asyncio.Lock()
-    await lock.acquire()
-    container._jsonl_sync_locks["claude-session-1"] = lock
+    held_lock = container._jsonl_sync_locks.lock("claude-session-1")
+    await held_lock.__aenter__()
 
     first = asyncio.create_task(container.sync_claude_session("claude-session-1", str(tmp_path)))
     second = asyncio.create_task(container.sync_claude_session("claude-session-1", str(tmp_path)))
@@ -550,7 +559,7 @@ def fake_parse_incremental(*, session_id: str, cwd: str):
     assert first.done() is False
     assert second.done() is False
 
-    lock.release()
+    await held_lock.__aexit__(None, None, None)
     await first
     await second
 
@@ -590,7 +599,7 @@ async def fake_sync(session_id: str, cwd: str) -> None:
 
     assert container._jsonl_sync_tasks == {}
     assert container._jsonl_sync_requests == {}
-    assert container._jsonl_sync_locks == {}
+    assert len(container._jsonl_sync_locks) == 0
     assert container._periodic_recheck_task is None
 
 
@@ -654,6 +663,7 @@ async def fake_sync(session_id: str, cwd: str) -> None:
 @pytest.mark.asyncio
 async def test_session_end_keeps_pending_sync_until_flushed(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
+    use_legacy_hook_binding_path(container)
     seen: list[tuple[str, str]] = []
 
     await container.session_service.switch(
@@ -675,6 +685,34 @@ async def fake_sync(session_id: str, cwd: str) -> None:
     assert seen == [("claude-session-1", str(tmp_path))]
 
 
+@pytest.mark.asyncio
+async def test_session_end_cleans_event_lock_registry(tmp_path) -> None:
+    container = AppContainer(make_settings(tmp_path, install_hooks=False))
+
+    await container._dispatch_session_event(
+        SessionEvent(
+            session_id="claude-session-ended",
+            type=SessionEventType.SESSION_ENDED,
+            payload={"cwd": str(tmp_path)},
+        )
+    )
+
+    assert len(container._session_event_locks) == 0
+
+
+@pytest.mark.asyncio
+async def test_container_uses_independent_session_lock_registries(tmp_path) -> None:
+    settings = make_settings(tmp_path, install_hooks=False)
+    container = AppContainer(settings)
+
+    assert container._jsonl_sync_locks._ttl_sec == settings.session_lock_ttl_sec
+    assert container._session_event_locks._ttl_sec == settings.session_lock_ttl_sec
+    assert container._jsonl_sync_locks._cleanup_interval_sec == settings.lock_cleanup_interval_sec
+    assert container._session_event_locks._cleanup_interval_sec == settings.lock_cleanup_interval_sec
+    assert container._jsonl_sync_locks is not container._session_event_locks
+    assert container.permission_callback_registry._ttl_sec == settings.claude_hook_pending_permission_ttl_sec
+
+
 @pytest.mark.asyncio
 async def test_match_session_context_does_not_fallback_on_workdir_collision(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
     container = AppContainer(make_settings(tmp_path, install_hooks=False))
@@ -908,9 +946,9 @@ async def fake_close():
 
     async def fake_sync(session_id: str, cwd: str) -> None:
         await second._dispatch_session_event(
-            second.structured_session_store.process.__globals__["SessionEvent"](
+            SessionEvent(
                 session_id=session_id,
-                type=second.structured_session_store.process.__globals__["SessionEventType"].FILE_SYNCED,
+                type=SessionEventType.FILE_SYNCED,
                 payload={
                     "cwd": cwd,
                     "claude_session_id": session_id,
diff --git a/tests/test_bot_commands_registration.py b/tests/test_bot_commands_registration.py
new file mode 100644
index 0000000..0e7157e
--- /dev/null
+++ b/tests/test_bot_commands_registration.py
@@ -0,0 +1,77 @@
+"""Tests for bot command registration during startup."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.bootstrap import AppContainer
+from app.bot.commands import BOT_COMMANDS
+from app.config.settings import Settings
+
+
+def make_settings(tmp_path) -> Settings:
+    return Settings.model_validate(
+        {
+            "TG_BOT_TOKEN": "123456:TESTTOKEN",
+            "TG_ALLOWED_USER_IDS": "1",
+            "DEFAULT_PROVIDER": "claude_code",
+            "DEFAULT_TIMEOUT_SEC": 10,
+            "MAX_CONCURRENT_TASKS": 1,
+            "CLAUDE_TMUX_MODE": False,
+            "TMUX_DATA_DIR": str(tmp_path),
+            "CLAUDE_CLI_BIN": "claude",
+            "CLAUDE_INSTALL_HOOKS": False,
+            "CLAUDE_CONFIG_DIR": str(tmp_path / ".claude"),
+            "CLAUDE_HOOK_SOCKET_PATH": str(tmp_path / "hook.sock"),
+            "CLAUDE_JSONL_SYNC_DEBOUNCE_MS": 10,
+            "CLAUDE_PERIODIC_RECHECK_MS": 10,
+            "CODEX_CLI_BIN": "codex",
+            "GEMINI_CLI_BIN": "gemini",
+            "ALLOWED_WORKDIRS": str(tmp_path),
+        }
+    )
+
+
+@pytest.mark.asyncio
+async def test_start_registers_commands(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+    """Verify set_my_commands is called with BOT_COMMANDS during startup."""
+    container = AppContainer(make_settings(tmp_path))
+
+    mock_set_commands = AsyncMock()
+    monkeypatch.setattr(container.bot, "set_my_commands", mock_set_commands)
+    # Stub out remaining startup steps
+    monkeypatch.setattr(container.hook_socket_server, "start", AsyncMock())
+    monkeypatch.setattr(container.session_registry, "start_health_check", AsyncMock())
+    monkeypatch.setattr(container.upload_cleanup, "start", AsyncMock())
+    monkeypatch.setattr(container, "_restore_session_bindings", AsyncMock())
+    monkeypatch.setattr(container, "_start_interrupt_watchers", lambda: None)
+    monkeypatch.setattr(container, "_start_agent_file_watchers", lambda: None)
+
+    await container.start()
+
+    mock_set_commands.assert_called_once_with(BOT_COMMANDS)
+    await container.stop()
+
+
+@pytest.mark.asyncio
+async def test_start_survives_command_registration_failure(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
+    """Verify startup completes even when set_my_commands raises."""
+    container = AppContainer(make_settings(tmp_path))
+
+    mock_set_commands = AsyncMock(side_effect=RuntimeError("Network error"))
+    monkeypatch.setattr(container.bot, "set_my_commands", mock_set_commands)
+    # Stub out remaining startup steps
+    monkeypatch.setattr(container.hook_socket_server, "start", AsyncMock())
+    monkeypatch.setattr(container.session_registry, "start_health_check", AsyncMock())
+    monkeypatch.setattr(container.upload_cleanup, "start", AsyncMock())
+    monkeypatch.setattr(container, "_restore_session_bindings", AsyncMock())
+    monkeypatch.setattr(container, "_start_interrupt_watchers", lambda: None)
+    monkeypatch.setattr(container, "_start_agent_file_watchers", lambda: None)
+
+    await container.start()
+
+    # Startup completed despite the exception
+    assert container._started is True
+    await container.stop()
diff --git a/tests/test_bound_auto_approve_no_double_prompt.py b/tests/test_bound_auto_approve_no_double_prompt.py
new file mode 100644
index 0000000..ab1a2a8
--- /dev/null
+++ b/tests/test_bound_auto_approve_no_double_prompt.py
@@ -0,0 +1,237 @@
+"""Regression: a bound external session with auto-approve active must NOT
+also receive the permission button push notification.
+
+Bug history: in the bound branch of _build_stage_list, both
+`auto_approve_check` and `push_notification` stages were scheduled
+unconditionally. When auto-approve was active, the user got both the silent
+"🟢 Auto-approved" message AND the redundant "🔐 请求权限: ..." button
+prompt.
+
+Fix: `_run_auto_approve_check` raises `_StageShortCircuit` which terminates
+the pipeline loop before the push_notification stage runs.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.bootstrap_mixins import HookHandlingMixin, _StageShortCircuit
+from app.domain.hook_models import HookEvent
+from app.services.auto_approve_service import AutoApproveService
+
+
+def _make_event(*, tool: str = "Edit", expects_response: bool = True) -> HookEvent:
+    """Build a HookEvent. `expects_response` is a property derived from
+    event=="PermissionRequest" and status=="waiting_for_approval"."""
+    if expects_response:
+        return HookEvent(
+            session_id="sess-123",
+            cwd="/Users/jack/project/remote-coding",
+            event="PermissionRequest",
+            status="waiting_for_approval",
+            tool=tool,
+            tool_input={"file_path": "/x.py"},
+            tool_use_id="toolu_abc",
+            pid=None,
+        )
+    return HookEvent(
+        session_id="sess-123",
+        cwd="/Users/jack/project/remote-coding",
+        event="PostToolUse",
+        status="processing",
+        tool=tool,
+        tool_input={"file_path": "/x.py"},
+        tool_use_id="toolu_abc",
+        pid=None,
+    )
+
+
+def _make_ownership(state: str = "bound", user_id: int = 42):
+    """Create a minimal OwnershipResult-like object."""
+    from enum import Enum
+
+    class Origin(Enum):
+        EXTERNAL = "external"
+
+    return SimpleNamespace(
+        ownership_state=state,
+        origin=Origin.EXTERNAL,
+        owner_user_id=user_id,
+    )
+
+
+class _Container(HookHandlingMixin):
+    """Minimal container satisfying HookHandlingMixin's hasattr() checks."""
+
+    def __init__(self, *, auto_approve_active: bool) -> None:
+        self.push_notifier = MagicMock()
+        self.push_notifier.notify_permission_request = AsyncMock(return_value=True)
+        self.push_notifier.notify_user_question = AsyncMock(return_value=True)
+        self.push_notifier.notify_info = AsyncMock(return_value=True)
+        self.push_notifier.notify_session_end = AsyncMock(return_value=True)
+
+        self.auto_approve_service = AutoApproveService()
+        if auto_approve_active:
+            self.auto_approve_service.activate("sess-123", user_id=42)
+
+        self.hook_socket_server = SimpleNamespace(
+            respond_to_permission=AsyncMock(return_value=True),
+        )
+        self.bot = MagicMock()
+        self.bot.send_message = AsyncMock()
+
+        self.claude_jsonl_parser = SimpleNamespace(
+            extract_session_title=lambda session_id, cwd: None,
+        )
+
+    # Stubs for methods used by _build_stage_list's eagerly-constructed coroutines.
+    async def _dispatch_session_event(self, event) -> None:
+        pass
+
+    def _schedule_jsonl_sync(self, session_id: str, cwd: str) -> None:
+        pass
+
+    async def _bind_hook_session(self, event) -> None:
+        pass
+
+    def _maybe_auto_file_send(self, event, owner_user_id) -> None:
+        pass
+
+
+@pytest.mark.asyncio
+async def test_auto_approve_check_raises_short_circuit() -> None:
+    """_run_auto_approve_check must raise _StageShortCircuit when auto-approve
+    is active, terminating the pipeline before push_notification runs."""
+    container = _Container(auto_approve_active=True)
+    event = _make_event(tool="Edit")
+
+    with pytest.raises(_StageShortCircuit, match="auto-approved"):
+        await container._run_auto_approve_check(event)
+
+
+@pytest.mark.asyncio
+async def test_auto_approve_check_does_not_short_circuit_when_inactive() -> None:
+    """When auto-approve is NOT active, the stage must NOT raise."""
+    container = _Container(auto_approve_active=False)
+    event = _make_event(tool="Edit")
+
+    # Should return normally (no exception)
+    await container._run_auto_approve_check(event)
+
+
+@pytest.mark.asyncio
+async def test_auto_approve_check_does_not_short_circuit_for_ask_user_question() -> None:
+    """AskUserQuestion is never auto-approved; the stage must not raise."""
+    container = _Container(auto_approve_active=True)
+    event = _make_event(tool="AskUserQuestion")
+
+    await container._run_auto_approve_check(event)
+
+
+@pytest.mark.asyncio
+async def test_pipeline_bound_auto_approve_skips_push_notification() -> None:
+    """Full pipeline integration: with auto-approve active on a bound session,
+    the push_notification stage must NOT execute."""
+    container = _Container(auto_approve_active=True)
+    event = _make_event(tool="Edit")
+    ownership = _make_ownership(state="bound", user_id=42)
+
+    stages = container._build_stage_list(event, ownership)
+    stage_names = [name for name, _ in stages]
+
+    # Both stages are in the list (they are built unconditionally)
+    assert "auto_approve_check" in stage_names
+    assert "push_notification" in stage_names
+
+    # Simulate pipeline execution
+    executed_stages: list[str] = []
+    short_circuited_at = -1
+    for i, (stage_name, stage_coro) in enumerate(stages):
+        try:
+            await stage_coro
+            executed_stages.append(stage_name)
+        except _StageShortCircuit:
+            executed_stages.append(f"{stage_name}:short-circuit")
+            short_circuited_at = i
+            break
+
+    # Close unawaited coroutines (mimic production cleanup)
+    for j in range(short_circuited_at + 1, len(stages)):
+        coro = stages[j][1]
+        if hasattr(coro, "close"):
+            coro.close()
+
+    # auto_approve_check must have short-circuited
+    assert "auto_approve_check:short-circuit" in executed_stages
+    # push_notification must NOT have been reached
+    assert "push_notification" not in executed_stages
+    # Permission was responded
+    container.hook_socket_server.respond_to_permission.assert_awaited_once()
+    # No redundant button prompt
+    container.push_notifier.notify_permission_request.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_pipeline_bound_no_auto_approve_sends_push_notification() -> None:
+    """Without auto-approve, the push_notification stage runs normally and
+    sends the permission-button prompt."""
+    container = _Container(auto_approve_active=False)
+    event = _make_event(tool="Edit")
+    ownership = _make_ownership(state="bound", user_id=42)
+
+    stages = container._build_stage_list(event, ownership)
+
+    # Simulate pipeline execution
+    executed_stages: list[str] = []
+    for i, (stage_name, stage_coro) in enumerate(stages):
+        try:
+            await stage_coro
+            executed_stages.append(stage_name)
+        except _StageShortCircuit:
+            executed_stages.append(f"{stage_name}:short-circuit")
+            # Close remaining
+            for j in range(i + 1, len(stages)):
+                coro = stages[j][1]
+                if hasattr(coro, "close"):
+                    coro.close()
+            break
+
+    assert "push_notification" in executed_stages
+    container.push_notifier.notify_permission_request.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_pipeline_owned_auto_approve_short_circuits() -> None:
+    """Owned sessions short-circuit on auto-approve, but session_binding,
+    event_dispatch, and jsonl_sync still run before the short-circuit."""
+    container = _Container(auto_approve_active=True)
+    event = _make_event(tool="Edit")
+    ownership = _make_ownership(state="owned", user_id=42)
+
+    stages = container._build_stage_list(event, ownership)
+
+    executed_stages: list[str] = []
+    short_circuited_at = -1
+    for i, (stage_name, stage_coro) in enumerate(stages):
+        try:
+            await stage_coro
+            executed_stages.append(stage_name)
+        except _StageShortCircuit:
+            executed_stages.append(f"{stage_name}:short-circuit")
+            short_circuited_at = i
+            break
+
+    # Close unawaited coroutines
+    for j in range(short_circuited_at + 1, len(stages)):
+        coro = stages[j][1]
+        if hasattr(coro, "close"):
+            coro.close()
+
+    assert "auto_approve_check:short-circuit" in executed_stages
+    # session_binding MUST have run before the short-circuit
+    assert "session_binding" in executed_stages
+    # auto_file_send must NOT have been reached (after auto_approve_check)
+    assert "auto_file_send" not in executed_stages
diff --git a/tests/test_command_run.py b/tests/test_command_run.py
index d54b323..ea04f26 100644
--- a/tests/test_command_run.py
+++ b/tests/test_command_run.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import asyncio
+from contextlib import suppress
 from datetime import timedelta
 from types import SimpleNamespace
 from unittest.mock import AsyncMock
@@ -8,12 +9,15 @@
 import pytest
 from aiogram.enums import ParseMode
 
-from app.bot.handlers.command_run import _ACTIVE_STREAM_TASKS, run_prompt_and_stream
+from app.bot.handlers import command_run as command_run_module
+from app.bot.handlers import run_event_streamer as run_event_streamer_module
+from app.bot.handlers.command_run import _ABANDONED_STREAM_TASKS, _ACTIVE_STREAM_TASKS, run_prompt_and_stream
 from app.bot.presenters.chunk_sender import ChunkSender
 from app.bot.presenters.structured_reply_presenter import build_permission_prompt, build_tool_progress_message, build_user_question_prompt
 from app.bot.presenters.telegram_formatting import render_markdownish_to_telegram_html
 from app.domain.models import CLIEvent, EventType, TaskRecord, TaskStatus, utc_now
 from app.domain.session_models import ConversationTurn, PendingPermission, SessionPhase, SubagentToolCall, ToolCallRecord, ToolStatus
+from app.services.permission_callback_registry import PermissionCallbackRegistry
 from tests.fakes.structured import make_structured_session as _structured_session
 from tests.fakes.telegram import DummyMessage
 
@@ -120,7 +124,13 @@ async def _stream(self):
             yield event
 
 
-async def _run_and_wait(*, message: DummyMessage, task_service: DummyTaskService, wait_sec: float = 0.05) -> None:
+async def _run_and_wait(
+    *,
+    message: DummyMessage,
+    task_service: DummyTaskService,
+    wait_sec: float = 0.05,
+    permission_callback_registry: PermissionCallbackRegistry | None = None,
+) -> None:
     task = await run_prompt_and_stream(
         message=message,
         task_service=task_service,
@@ -129,6 +139,7 @@ async def _run_and_wait(*, message: DummyMessage, task_service: DummyTaskService
         provider="claude_code",
         prompt="hello",
         workdir="/tmp",
+        permission_callback_registry=permission_callback_registry,
     )
     await asyncio.sleep(wait_sec)
     if task is not None:
@@ -205,6 +216,727 @@ async def test_run_prompt_and_stream_keeps_background_task_referenced_until_done
     assert task not in _ACTIVE_STREAM_TASKS
 
 
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_watchdog_cancels_stuck_stream(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+
+    class StuckTaskService(DummyTaskService):
+        def __init__(self) -> None:
+            super().__init__([], _status(task_status=TaskStatus.RUNNING))
+            self.cancel_called = False
+            self.timeout_marked = False
+
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-stuck")
+                await asyncio.Event().wait()
+
+            task = _status(task_status=TaskStatus.RUNNING)
+            task.task_id = "t-stuck"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=False)
+
+        async def cancel(self, task_id: str, user_id: int) -> bool:
+            self.cancel_called = True
+            return True
+
+        async def mark_stream_timeout(self, task_id: str, user_id: int, *, reason: str) -> bool:
+            self.timeout_marked = True
+            self._status.status = TaskStatus.TIMEOUT
+            return True
+
+    message = DummyMessage()
+    task_service = StuckTaskService()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.2)
+        assert task.done()
+        assert task not in _ACTIVE_STREAM_TASKS
+        assert task_service.cancel_called is True
+        assert task_service.timeout_marked is True
+        assert task_service._status.status == TaskStatus.TIMEOUT
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_watchdog_timeout_schedules_queued_uploads(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    upload_scheduled = False
+
+    class StuckTaskService(DummyTaskService):
+        def __init__(self) -> None:
+            super().__init__([], _status(task_status=TaskStatus.RUNNING))
+
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-timeout-upload")
+                await asyncio.Event().wait()
+
+            task = _status(task_status=TaskStatus.RUNNING)
+            task.task_id = "t-timeout-upload"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=False)
+
+        async def cancel(self, task_id: str, user_id: int) -> bool:
+            return True
+
+        async def mark_stream_timeout(self, task_id: str, user_id: int, *, reason: str) -> bool:
+            self._status.status = TaskStatus.TIMEOUT
+            self._status.failure_reason = reason
+            return True
+
+    def queued_upload_scheduler(message: DummyMessage, user_id: int, task_id: str) -> None:
+        nonlocal upload_scheduled
+        upload_scheduled = True
+
+    message = DummyMessage()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=StuckTaskService(),
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+        queued_upload_scheduler=queued_upload_scheduler,
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert upload_scheduled is True
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_watchdog_ignores_late_exit_after_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CANCEL_GRACE_SEC", 0.005, raising=False)
+
+    class LateExitTaskService(DummyTaskService):
+        def __init__(self) -> None:
+            super().__init__([], _status(task_status=TaskStatus.RUNNING))
+
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-late")
+                try:
+                    await asyncio.Event().wait()
+                except asyncio.CancelledError:
+                    await asyncio.sleep(0.02)
+                    yield CLIEvent(type=EventType.EXITED, task_id="t-late", exit_code=0)
+
+            task = _status(task_status=TaskStatus.RUNNING)
+            task.task_id = "t-late"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=False)
+
+        async def cancel(self, task_id: str, user_id: int) -> bool:
+            return True
+
+        async def mark_stream_timeout(self, task_id: str, user_id: int, *, reason: str) -> bool:
+            self._status.status = TaskStatus.TIMEOUT
+            self._status.failure_reason = reason
+            return True
+
+    message = DummyMessage()
+    task_service = LateExitTaskService()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task_service._status.status == TaskStatus.TIMEOUT
+        lifecycle = message.sent_messages[0]
+        assert not any("✅ 完成 [t-late]" in edit for edit in lifecycle.edits)
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_marks_timeout_before_cancel_race(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    cancel_released = asyncio.Event()
+
+    class CancelRaceTaskService(DummyTaskService):
+        def __init__(self) -> None:
+            super().__init__([], _status(task_status=TaskStatus.RUNNING))
+
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-race")
+                await cancel_released.wait()
+                if self._status.status == TaskStatus.RUNNING:
+                    self._status.status = TaskStatus.SUCCEEDED
+                yield CLIEvent(type=EventType.EXITED, task_id="t-race", exit_code=0)
+
+            task = _status(task_status=TaskStatus.RUNNING)
+            task.task_id = "t-race"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=False)
+
+        async def cancel(self, task_id: str, user_id: int) -> bool:
+            cancel_released.set()
+            await asyncio.sleep(0.02)
+            return True
+
+        async def mark_stream_timeout(self, task_id: str, user_id: int, *, reason: str) -> bool:
+            if self._status.status != TaskStatus.RUNNING:
+                return False
+            self._status.status = TaskStatus.TIMEOUT
+            self._status.failure_reason = reason
+            return True
+
+    message = DummyMessage()
+    task_service = CancelRaceTaskService()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task_service._status.status == TaskStatus.TIMEOUT
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_does_not_abandon_terminal_event_when_timeout_mark_loses_race(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    terminal_released = asyncio.Event()
+
+    class TerminalRaceTaskService(DummyTaskService):
+        def __init__(self) -> None:
+            super().__init__([], _status(task_status=TaskStatus.RUNNING))
+            self.cancel_called = False
+
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-terminal-race")
+                await terminal_released.wait()
+                self._status.status = TaskStatus.SUCCEEDED
+                self._status.exit_code = 0
+                yield CLIEvent(type=EventType.EXITED, task_id="t-terminal-race", exit_code=0)
+
+            task = _status(task_status=TaskStatus.RUNNING)
+            task.task_id = "t-terminal-race"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=False)
+
+        async def cancel(self, task_id: str, user_id: int) -> bool:
+            self.cancel_called = True
+            return True
+
+        async def mark_stream_timeout(self, task_id: str, user_id: int, *, reason: str) -> bool:
+            terminal_released.set()
+            await asyncio.sleep(0.02)
+            if self._status.status != TaskStatus.RUNNING:
+                return False
+            self._status.status = TaskStatus.TIMEOUT
+            self._status.failure_reason = reason
+            return True
+
+    message = DummyMessage()
+    task_service = TerminalRaceTaskService()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task_service._status.status == TaskStatus.SUCCEEDED
+        assert task_service.cancel_called is False
+        assert not any("任务流处理超时" in answer for answer in message.answers)
+        lifecycle = message.sent_messages[0]
+        assert any("✅ 完成 [t-termin]" in edit for edit in lifecycle.edits)
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_watchdog_allows_interactive_progress(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.03, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+
+    class ProgressTaskService(DummyTaskService):
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-progress")
+                for index in range(3):
+                    await asyncio.sleep(0.02)
+                    yield CLIEvent(type=EventType.STDOUT, task_id="t-progress", content=f"progress {index}\n")
+                yield CLIEvent(type=EventType.EXITED, task_id="t-progress", exit_code=0)
+
+            task = _status(task_status=TaskStatus.SUCCEEDED)
+            task.task_id = "t-progress"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=True)
+
+    message = DummyMessage()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=ProgressTaskService([], _status(task_status=TaskStatus.SUCCEEDED), interactive=True),
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    await task
+
+    lifecycle = message.sent_messages[0]
+    assert any("✅ 完成 [t-progre]" in edit for edit in lifecycle.edits)
+    assert not any("任务流处理超时" in answer for answer in message.answers)
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_watchdog_allows_structured_progress(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_BUFFER_SEC", 0.03, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_MIN_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+
+    class StructuredProgressTaskService(DummyTaskService):
+        def __init__(self) -> None:
+            super().__init__([], _status(task_status=TaskStatus.SUCCEEDED), interactive=True)
+            self._cursor = 0
+
+        async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+            async def stream():
+                yield CLIEvent(type=EventType.STARTED, task_id="t-structured")
+                await asyncio.sleep(0.06)
+                yield CLIEvent(type=EventType.EXITED, task_id="t-structured", exit_code=0)
+
+            task = _status(task_status=TaskStatus.SUCCEEDED)
+            task.task_id = "t-structured"
+            task.timeout_sec = 0.01
+            return SimpleNamespace(task=task, events=stream(), interactive=True)
+
+        async def get_structured_session_cursor(self, user_id: int, *, task_id: str | None = None) -> int:
+            self._cursor += 1
+            return self._cursor
+
+    message = DummyMessage()
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=StructuredProgressTaskService(),
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    await task
+
+    lifecycle = message.sent_messages[0]
+    assert any("✅ 完成 [t-struct]" in edit for edit in lifecycle.edits)
+    assert not any("任务流处理超时" in answer for answer in message.answers)
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_watchdog_cancels_stuck_finalization(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_FINALIZE_GRACE_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CANCEL_GRACE_SEC", 0.01, raising=False)
+    cleanup_started = asyncio.Event()
+    release_cleanup = asyncio.Event()
+
+    async def stuck_after_terminal(self):
+        try:
+            async for event in self._start.events:
+                if event.type == EventType.EXITED:
+                    await asyncio.Event().wait()
+        except asyncio.CancelledError:
+            cleanup_started.set()
+            await release_cleanup.wait()
+
+    monkeypatch.setattr(command_run_module.RunEventStreamer, "stream_events", stuck_after_terminal)
+
+    message = DummyMessage()
+    task_service = DummyTaskService(
+        [
+            CLIEvent(type=EventType.STARTED, task_id="t-final"),
+            CLIEvent(type=EventType.EXITED, task_id="t-final", exit_code=0),
+        ],
+        _status(task_status=TaskStatus.SUCCEEDED),
+    )
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task not in _ACTIVE_STREAM_TASKS
+        assert cleanup_started.is_set()
+    finally:
+        release_cleanup.set()
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_tracks_abandoned_stream_and_force_cleans_interactive_pump(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_FINALIZE_GRACE_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CANCEL_GRACE_SEC", 0.01, raising=False)
+    pump_task: asyncio.Task | None = None
+    inner_stream_task: asyncio.Task | None = None
+    release_stream = asyncio.Event()
+
+    async def stuck_after_terminal(self):
+        nonlocal pump_task, inner_stream_task
+        inner_stream_task = asyncio.current_task()
+        pump_task = asyncio.create_task(asyncio.Event().wait())
+        self._interactive_pump = pump_task
+        try:
+            async for event in self._start.events:
+                if event.type == EventType.EXITED:
+                    await asyncio.Event().wait()
+        except asyncio.CancelledError:
+            await release_stream.wait()
+
+    monkeypatch.setattr(command_run_module.RunEventStreamer, "stream_events", stuck_after_terminal)
+
+    message = DummyMessage()
+    task_service = DummyTaskService(
+        [
+            CLIEvent(type=EventType.STARTED, task_id="t-pump-cleanup"),
+            CLIEvent(type=EventType.EXITED, task_id="t-pump-cleanup", exit_code=0),
+        ],
+        _status(task_status=TaskStatus.SUCCEEDED),
+        interactive=True,
+    )
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task not in _ACTIVE_STREAM_TASKS
+        assert inner_stream_task is not None
+        assert inner_stream_task in _ABANDONED_STREAM_TASKS
+        assert pump_task is not None
+        assert pump_task.cancelled()
+    finally:
+        release_stream.set()
+        if inner_stream_task is not None and not inner_stream_task.done():
+            await inner_stream_task
+        if pump_task is not None and not pump_task.done():
+            pump_task.cancel()
+            with suppress(asyncio.CancelledError):
+                await pump_task
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_force_cleanup_does_not_block_on_uncancellable_interactive_pump(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_FINALIZE_GRACE_SEC", 0.01, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CANCEL_GRACE_SEC", 0.01, raising=False)
+    pump_task: asyncio.Task | None = None
+    release_pump = asyncio.Event()
+    release_stream = asyncio.Event()
+
+    async def uncancellable_pump():
+        try:
+            await asyncio.Event().wait()
+        except asyncio.CancelledError:
+            await release_pump.wait()
+
+    async def stuck_after_terminal(self):
+        nonlocal pump_task
+        pump_task = asyncio.create_task(uncancellable_pump())
+        self._interactive_pump = pump_task
+        try:
+            async for event in self._start.events:
+                if event.type == EventType.EXITED:
+                    await asyncio.Event().wait()
+        except asyncio.CancelledError:
+            await release_stream.wait()
+
+    monkeypatch.setattr(command_run_module.RunEventStreamer, "stream_events", stuck_after_terminal)
+
+    message = DummyMessage()
+    task_service = DummyTaskService(
+        [
+            CLIEvent(type=EventType.STARTED, task_id="t-uncancellable-pump"),
+            CLIEvent(type=EventType.EXITED, task_id="t-uncancellable-pump", exit_code=0),
+        ],
+        _status(task_status=TaskStatus.SUCCEEDED),
+        interactive=True,
+    )
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task not in _ACTIVE_STREAM_TASKS
+        assert pump_task is not None
+        assert not pump_task.done()
+    finally:
+        release_pump.set()
+        release_stream.set()
+        if pump_task is not None and not pump_task.done():
+            await pump_task
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_real_finally_tracks_uncancellable_interactive_pump(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_FINALIZE_GRACE_SEC", 0.02, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CANCEL_GRACE_SEC", 0.01, raising=False)
+    monkeypatch.setattr(run_event_streamer_module, "_INTERACTIVE_PUMP_CANCEL_GRACE_SEC", 0.005, raising=False)
+    pump_task: asyncio.Task | None = None
+    release_pump = asyncio.Event()
+
+    async def uncancellable_pump(self):
+        nonlocal pump_task
+        pump_task = asyncio.current_task()
+        while not release_pump.is_set():
+            try:
+                await release_pump.wait()
+            except asyncio.CancelledError:
+                continue
+
+    monkeypatch.setattr(command_run_module.RunEventStreamer, "pump_structured_reply", uncancellable_pump)
+
+    message = DummyMessage()
+    task_service = DummyTaskService(
+        [
+            CLIEvent(type=EventType.STARTED, task_id="t-real-pump"),
+            CLIEvent(type=EventType.EXITED, task_id="t-real-pump", exit_code=0),
+        ],
+        _status(task_status=TaskStatus.SUCCEEDED),
+        interactive=True,
+    )
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert pump_task is not None
+        assert pump_task in run_event_streamer_module._ABANDONED_INTERACTIVE_PUMP_TASKS
+    finally:
+        release_pump.set()
+        if pump_task is not None and not pump_task.done():
+            await pump_task
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_schedules_queued_uploads_before_interactive_finalization_timeout(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_FINALIZE_GRACE_SEC", 0.01, raising=False)
+    upload_scheduled = False
+
+    def queued_upload_scheduler(message: DummyMessage, user_id: int, task_id: str) -> None:
+        nonlocal upload_scheduled
+        upload_scheduled = True
+
+    message = DummyMessage()
+    task_service = DummyTaskService(
+        [
+            CLIEvent(type=EventType.STARTED, task_id="t-final-upload"),
+            CLIEvent(type=EventType.EXITED, task_id="t-final-upload", exit_code=0),
+        ],
+        _status(task_status=TaskStatus.SUCCEEDED),
+        interactive=True,
+    )
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+        queued_upload_scheduler=queued_upload_scheduler,
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task not in _ACTIVE_STREAM_TASKS
+        assert upload_scheduled is True
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
+@pytest.mark.asyncio
+async def test_run_prompt_and_stream_schedules_queued_uploads_when_terminal_flush_is_canceled(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_CHECK_INTERVAL_SEC", 0.005, raising=False)
+    monkeypatch.setattr(command_run_module, "_STREAM_WATCHDOG_FINALIZE_GRACE_SEC", 0.01, raising=False)
+    upload_scheduled = False
+
+    async def stuck_flush(self) -> bool:
+        await asyncio.Event().wait()
+        return True
+
+    monkeypatch.setattr(command_run_module.PresenterOutputDispatcher, "flush", stuck_flush)
+
+    def queued_upload_scheduler(message: DummyMessage, user_id: int, task_id: str) -> None:
+        nonlocal upload_scheduled
+        upload_scheduled = True
+
+    message = DummyMessage()
+    task_service = DummyTaskService(
+        [
+            CLIEvent(type=EventType.STARTED, task_id="t-flush"),
+            CLIEvent(type=EventType.EXITED, task_id="t-flush", exit_code=0),
+        ],
+        _status(task_status=TaskStatus.SUCCEEDED),
+    )
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp",
+        queued_upload_scheduler=queued_upload_scheduler,
+    )
+
+    assert task is not None
+    try:
+        await asyncio.sleep(0.1)
+        assert task.done()
+        assert task not in _ACTIVE_STREAM_TASKS
+        assert upload_scheduled is True
+    finally:
+        if not task.done():
+            task.cancel()
+        with suppress(asyncio.CancelledError):
+            await task
+
+
 @pytest.mark.asyncio
 async def test_run_prompt_and_stream_reports_started_output_and_success() -> None:
     message = DummyMessage()
@@ -1017,6 +1749,7 @@ async def drift_context_and_append_reply() -> None:
 @pytest.mark.asyncio
 async def test_run_prompt_and_stream_interactive_reports_pending_permission_once() -> None:
     message = DummyMessage()
+    registry = PermissionCallbackRegistry(ttl_sec=600)
     pending = PendingPermission(tool_use_id="tool-1", tool_name="Bash", tool_input={"command": "pwd"})
     turns = [ConversationTurn(turn_id="turn-1", role="assistant", text="\n已完成回复\n", is_complete=True)]
     task_service = DummyTaskService(
@@ -1040,15 +1773,29 @@ async def get_structured_session(user_id: int, *, log_missing: bool = True):
 
     task_service.get_structured_session = AsyncMock(side_effect=get_structured_session)
 
-    await _run_and_wait(message=message, task_service=task_service, wait_sec=0.14)
+    await _run_and_wait(message=message, task_service=task_service, wait_sec=0.14, permission_callback_registry=registry)
 
     expected_prompt = build_permission_prompt(tool_name="Bash", tool_input={"command": "pwd"})
-    assert message.answers.count(expected_prompt) == 1
-    permission_index = message.answers.index(expected_prompt)
+    # The messenger renders markdownish text to Telegram HTML before sending
+    from app.bot.presenters.telegram_formatting import render_markdownish_to_telegram_html
+
+    expected_rendered = render_markdownish_to_telegram_html(expected_prompt)
+    assert message.answers.count(expected_rendered) == 1
+    permission_index = message.answers.index(expected_rendered)
     reply_markup = message.reply_markups[permission_index]
     assert reply_markup is not None
     assert [button.text for button in reply_markup.inline_keyboard[0]] == ["允许", "拒绝"]
-    assert [button.callback_data for button in reply_markup.inline_keyboard[0]] == ["perm:allow:tool-1", "perm:deny:tool-1"]
+    allow_data = reply_markup.inline_keyboard[0][0].callback_data
+    deny_data = reply_markup.inline_keyboard[0][1].callback_data
+    assert allow_data.startswith("perm:allow:")
+    assert deny_data.startswith("perm:deny:")
+    # Resolve the token back to the original tool_use_id
+    from app.bot.handlers.command_permission import parse_permission_callback_data
+
+    _, allow_token = parse_permission_callback_data(allow_data)
+    _, deny_token = parse_permission_callback_data(deny_data)
+    assert allow_token == deny_token
+    assert registry.resolve(allow_token) == "tool-1"
     assert task_service._structured_permission_key == "tool-1:Bash"
 
 
diff --git a/tests/test_file_upload_handler.py b/tests/test_file_upload_handler.py
index 034a4cd..1f9b7fa 100644
--- a/tests/test_file_upload_handler.py
+++ b/tests/test_file_upload_handler.py
@@ -3,17 +3,18 @@
 from __future__ import annotations
 
 import io
+from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock
 
 import pytest
 
 from app.bot.handlers.file_upload import (
     _format_size,
-    _pending_uploads,
     _user_has_running_task,
 )
 from app.domain.file_models import FileUploadResult, FileValidationError
 from app.domain.models import TaskRecord, TaskStatus
+from app.services.upload_queue import UploadQueueManager
 
 
 # --- Unit tests for helper functions ---
@@ -68,6 +69,18 @@ async def test_user_has_running_task_false_when_no_tasks() -> None:
 # --- Integration tests for the handler ---
 
 
+class DummyRouter:
+    def __init__(self) -> None:
+        self.handlers = []
+
+    def message(self, *filters, **kwargs):
+        def decorator(handler):
+            self.handlers.append(handler)
+            return handler
+
+        return decorator
+
+
 def _make_message(user_id: int = 42) -> MagicMock:
     message = AsyncMock(spec=["from_user", "bot", "document", "photo", "answer"])
     message.from_user = MagicMock()
@@ -78,25 +91,88 @@ def _make_message(user_id: int = 42) -> MagicMock:
 
 def _make_services():
     file_receiver = AsyncMock()
+    file_receiver.receive_file = AsyncMock()
     session_service = AsyncMock()
     task_service = AsyncMock()
     task_service.list_recent = AsyncMock(return_value=[])
     return file_receiver, session_service, task_service
 
 
-@pytest.fixture(autouse=True)
-def clear_pending_uploads():
-    """Clear the in-memory pending uploads between tests."""
-    _pending_uploads.clear()
-    yield
-    _pending_uploads.clear()
+def _running_task() -> MagicMock:
+    running_task = MagicMock(spec=TaskRecord)
+    running_task.status = TaskStatus.RUNNING
+    return running_task
+
+
+def _register_upload_handlers(
+    *,
+    upload_max_file_size_mb: int = 20,
+    upload_queue_max_files_per_user: int = 5,
+    upload_queue_max_bytes_per_user: int = 20 * 1024 * 1024,
+):
+    from app.bot.handlers.file_upload import register_file_upload_handler
+
+    router = DummyRouter()
+    file_receiver, session_service, task_service = _make_services()
+    upload_queue = UploadQueueManager(
+        max_files_per_user=upload_queue_max_files_per_user,
+        max_bytes_per_user=upload_queue_max_bytes_per_user,
+    )
+
+    register_file_upload_handler(
+        router,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        task_service=task_service,
+        upload_queue=upload_queue,
+        upload_max_file_size_mb=upload_max_file_size_mb,
+    )
+
+    assert len(router.handlers) == 2
+    document_handler, photo_handler = router.handlers
+    return document_handler, photo_handler, upload_queue, file_receiver, session_service, task_service
+
+
+def _attach_document(
+    message: MagicMock, *, filename: str = "test.py", file_size: int | None = 11, data: bytes = b"hello world"
+) -> AsyncMock:
+    message.document = MagicMock()
+    message.document.file_name = filename
+    message.document.file_id = f"file-{filename}"
+    message.document.file_size = file_size
+
+    bot = AsyncMock()
+    message.bot = bot
+    file_obj = MagicMock()
+    file_obj.file_path = f"documents/{filename}"
+    bot.get_file = AsyncMock(return_value=file_obj)
+    bot.download_file = AsyncMock(return_value=io.BytesIO(data))
+    return bot
+
+
+def _attach_photo(message: MagicMock, *, largest_file_size: int | None = 11, data: bytes = b"hello world") -> AsyncMock:
+    small = MagicMock()
+    small.file_id = "small-photo"
+    small.file_unique_id = "small"
+    small.file_size = 5
+    largest = MagicMock()
+    largest.file_id = "large-photo"
+    largest.file_unique_id = "large"
+    largest.file_size = largest_file_size
+    message.photo = [small, largest]
+
+    bot = AsyncMock()
+    message.bot = bot
+    file_obj = MagicMock()
+    file_obj.file_path = "photos/large.jpg"
+    bot.get_file = AsyncMock(return_value=file_obj)
+    bot.download_file = AsyncMock(return_value=io.BytesIO(data))
+    return bot
 
 
 @pytest.mark.asyncio
 async def test_handle_document_success() -> None:
     """Document upload should download, process, and reply with confirmation."""
-    from pathlib import Path
-
     file_receiver, session_service, task_service = _make_services()
 
     session = MagicMock()
@@ -194,25 +270,110 @@ async def test_handle_document_no_session() -> None:
 
 
 @pytest.mark.asyncio
-async def test_queues_upload_when_task_running() -> None:
-    """Uploads should be queued when user has a running task."""
-    file_receiver, session_service, task_service = _make_services()
-    running_task = MagicMock(spec=TaskRecord)
-    running_task.status = TaskStatus.RUNNING
-    task_service.list_recent = AsyncMock(return_value=[running_task])
+async def test_document_size_metadata_rejects_before_download() -> None:
+    document_handler, _photo_handler, _queue, _file_receiver, _session_service, _task_service = _register_upload_handlers(
+        upload_max_file_size_mb=1
+    )
+    message = _make_message()
+    bot = _attach_document(message, filename="large.py", file_size=1024 * 1024 + 1)
 
-    assert await _user_has_running_task(task_service, user_id=42) is True
+    await document_handler(message)
 
-    # Simulate queuing
-    _pending_uploads[42].append(("test.py", b"data"))
-    assert len(_pending_uploads[42]) == 1
+    bot.get_file.assert_not_awaited()
+    bot.download_file.assert_not_awaited()
+    message.answer.assert_awaited_once()
+    reply = message.answer.call_args[0][0]
+    assert "文件被拒绝" in reply
+    assert "1 MB" in reply
+
+
+@pytest.mark.asyncio
+async def test_photo_size_metadata_rejects_before_download() -> None:
+    _document_handler, photo_handler, _queue, _file_receiver, _session_service, _task_service = _register_upload_handlers(
+        upload_max_file_size_mb=1
+    )
+    message = _make_message()
+    bot = _attach_photo(message, largest_file_size=1024 * 1024 + 1)
+
+    await photo_handler(message)
+
+    bot.get_file.assert_not_awaited()
+    bot.download_file.assert_not_awaited()
+    message.answer.assert_awaited_once()
+    reply = message.answer.call_args[0][0]
+    assert "文件被拒绝" in reply
+    assert "1 MB" in reply
+
+
+@pytest.mark.asyncio
+async def test_running_task_queue_reply_mentions_restart_loss() -> None:
+    document_handler, _photo_handler, queue, _file_receiver, _session_service, task_service = _register_upload_handlers(
+        upload_max_file_size_mb=1
+    )
+    task_service.list_recent = AsyncMock(return_value=[_running_task()])
+    message = _make_message()
+    _attach_document(message, filename="queued.py", file_size=4, data=b"data")
+
+    await document_handler(message)
+
+    assert await queue.queued_count(user_id=42) == 1
+    message.answer.assert_awaited_once()
+    reply = message.answer.call_args[0][0]
+    assert "已加入队列" in reply
+    assert "bot" in reply
+    assert "重启" in reply
+    assert "丢失" in reply
+    assert "60 分钟" in reply
+    assert "过期" in reply
+
+
+@pytest.mark.asyncio
+async def test_running_task_rejects_when_queue_count_limit_reached() -> None:
+    document_handler, _photo_handler, queue, _file_receiver, _session_service, task_service = _register_upload_handlers(
+        upload_max_file_size_mb=1,
+        upload_queue_max_files_per_user=1,
+    )
+    task_service.list_recent = AsyncMock(return_value=[_running_task()])
+
+    first = _make_message()
+    _attach_document(first, filename="first.py", file_size=5, data=b"first")
+    await document_handler(first)
+
+    second = _make_message()
+    _attach_document(second, filename="second.py", file_size=6, data=b"second")
+    await document_handler(second)
+
+    assert await queue.queued_count(user_id=42) == 1
+    second.answer.assert_awaited_once()
+    reply = second.answer.call_args[0][0]
+    assert "文件未加入队列" in reply
+    assert "队列已满" in reply
+
+
+@pytest.mark.asyncio
+async def test_running_task_rejects_downloaded_file_over_size_limit_before_queueing() -> None:
+    document_handler, _photo_handler, queue, _file_receiver, _session_service, task_service = _register_upload_handlers(
+        upload_max_file_size_mb=1
+    )
+    task_service.list_recent = AsyncMock(return_value=[_running_task()])
+    message = _make_message()
+    bot = _attach_document(message, filename="no-metadata.bin", file_size=None, data=b"x" * (1024 * 1024 + 1))
+
+    await document_handler(message)
+
+    bot.get_file.assert_awaited_once()
+    bot.download_file.assert_awaited_once()
+    assert await queue.queued_count(user_id=42) == 0
+    message.answer.assert_awaited_once()
+    reply = message.answer.call_args[0][0]
+    assert "文件被拒绝" in reply
+    assert "1 MB" in reply
+    assert "已加入队列" not in reply
 
 
 @pytest.mark.asyncio
 async def test_process_pending_uploads() -> None:
     """process_pending_uploads should process all queued files."""
-    from pathlib import Path
-
     from app.bot.handlers.file_upload import process_pending_uploads
 
     file_receiver, session_service, task_service = _make_services()
@@ -225,8 +386,9 @@ async def test_process_pending_uploads() -> None:
         return_value=FileUploadResult(filename="queued.py", size_bytes=100, path=Path("/tmp/work/.tg-uploads/42/queued.py"))
     )
 
-    # Queue a file
-    _pending_uploads[42].append(("queued.py", b"content"))
+    upload_queue = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=100)
+    result = await upload_queue.enqueue(user_id=42, filename="queued.py", data=b"content")
+    assert result.accepted is True
 
     message = _make_message()
 
@@ -234,11 +396,12 @@ async def test_process_pending_uploads() -> None:
         message,
         file_receiver=file_receiver,
         session_service=session_service,
+        upload_queue=upload_queue,
         user_id=42,
     )
 
     # Queue should be cleared
-    assert 42 not in _pending_uploads
+    assert await upload_queue.queued_count(user_id=42) == 0
     file_receiver.receive_file.assert_awaited_once()
     message.answer.assert_awaited_once()
 
diff --git a/tests/test_lock_registry.py b/tests/test_lock_registry.py
new file mode 100644
index 0000000..74b31ba
--- /dev/null
+++ b/tests/test_lock_registry.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from app.services.lock_registry import RefCountedLockRegistry
+
+
+@pytest.mark.asyncio
+async def test_registry_serializes_same_key() -> None:
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=60, cleanup_batch_size=50)
+    entered: list[str] = []
+    release_first = asyncio.Event()
+    first_entered = asyncio.Event()
+
+    async def worker(name: str) -> None:
+        async with registry.lock("tool-1"):
+            entered.append(name)
+            if name == "first":
+                first_entered.set()
+                await release_first.wait()
+
+    first = asyncio.create_task(worker("first"))
+    await first_entered.wait()
+    second = asyncio.create_task(worker("second"))
+    await asyncio.sleep(0)
+
+    assert entered == ["first"]
+
+    release_first.set()
+    await first
+    await second
+
+    assert entered == ["first", "second"]
+
+
+@pytest.mark.asyncio
+async def test_registry_keeps_referenced_key_during_cleanup() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock)
+    lock_cm = registry.lock("tool-1")
+    entered = await lock_cm.__aenter__()
+    assert entered is None
+
+    now = 200.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 1
+
+    await lock_cm.__aexit__(None, None, None)
+    await registry.cleanup_expired()
+
+    assert len(registry) == 1
+
+    now = 211.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 0
+
+
+@pytest.mark.asyncio
+async def test_registry_requeues_key_after_delete_and_recreate() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock)
+
+    async with registry.lock("tool-1"):
+        pass
+
+    now = 200.0
+    await registry.cleanup_expired()
+    assert len(registry) == 0
+
+    async with registry.lock("tool-1"):
+        pass
+
+    assert len(registry) == 1
+
+
+@pytest.mark.asyncio
+async def test_cleanup_key_removes_deleted_key_from_cleanup_batch() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock)
+    async with registry.lock("deleted"):
+        pass
+    await registry.cleanup_key("deleted", require_expired=False)
+
+    async with registry.lock("stale"):
+        pass
+
+    now = 200.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 0
+
+
+@pytest.mark.asyncio
+async def test_registry_cleanup_batch_size_limits_work_per_pass() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=2, clock=clock)
+    for key in ("a", "b", "c"):
+        async with registry.lock(key):
+            pass
+
+    now = 200.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 1
+
+    now = 202.0
+    await registry.cleanup_expired()
+
+    assert len(registry) == 0
+
+
+@pytest.mark.asyncio
+async def test_registry_runs_global_cleanup_on_lock_hot_path() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=50, clock=clock)
+    async with registry.lock("old"):
+        pass
+
+    now = 111.0
+    async with registry.lock("new"):
+        pass
+
+    assert len(registry) == 1
+
+
+@pytest.mark.asyncio
+async def test_registry_instances_keep_cleanup_state_independent() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    first = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock)
+    second = RefCountedLockRegistry(ttl_sec=10, cleanup_interval_sec=1, cleanup_batch_size=1, clock=clock)
+
+    async with first.lock("a"):
+        pass
+    async with second.lock("b"):
+        pass
+
+    now = 200.0
+    await first.cleanup_expired()
+
+    assert len(first) == 0
+    assert len(second) == 1
+
+    await second.cleanup_expired()
+    assert len(second) == 0
diff --git a/tests/test_memory_task_store.py b/tests/test_memory_task_store.py
new file mode 100644
index 0000000..4b084d6
--- /dev/null
+++ b/tests/test_memory_task_store.py
@@ -0,0 +1,297 @@
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timedelta
+
+import pytest
+
+from app.adapters.storage.memory import MemoryTaskStore
+from app.domain.models import TaskRecord, TaskStatus, utc_now
+
+
+def _make_task(
+    task_id: str = "t1",
+    user_id: int = 1,
+    status: TaskStatus = TaskStatus.PENDING,
+    created_at: datetime | None = None,
+    ended_at: datetime | None = None,
+) -> TaskRecord:
+    return TaskRecord(
+        task_id=task_id,
+        session_id="s1",
+        user_id=user_id,
+        provider="test",
+        prompt="test",
+        workdir="/tmp",
+        timeout_sec=60,
+        status=status,
+        created_at=created_at or utc_now(),
+        ended_at=ended_at,
+    )
+
+
+# --- constructor validation ---
+
+
+def test_negative_max_records_raises():
+    with pytest.raises(ValueError, match="max_records"):
+        MemoryTaskStore(max_records=0)
+
+
+def test_negative_ttl_raises():
+    with pytest.raises(ValueError, match="ttl_hours"):
+        MemoryTaskStore(ttl_hours=-1)
+
+
+# --- TTL eviction ---
+
+
+@pytest.mark.asyncio
+async def test_expired_final_cleaned_by_ended_at():
+    now = utc_now()
+    store = MemoryTaskStore(ttl_hours=24, max_records=100)
+
+    old = _make_task(
+        task_id="old",
+        status=TaskStatus.SUCCEEDED,
+        created_at=now - timedelta(days=2),
+        ended_at=now - timedelta(days=2),
+    )
+    await store.add(old)
+
+    result = await store.get("old")
+    assert result is None
+
+
+@pytest.mark.asyncio
+async def test_not_yet_expired_final_kept():
+    now = utc_now()
+    store = MemoryTaskStore(ttl_hours=24, max_records=100)
+
+    recent = _make_task(
+        task_id="recent",
+        status=TaskStatus.FAILED,
+        created_at=now - timedelta(hours=1),
+        ended_at=now - timedelta(hours=1),
+    )
+    await store.add(recent)
+
+    result = await store.get("recent")
+    assert result is not None
+
+
+@pytest.mark.asyncio
+async def test_expired_final_ended_at_none_uses_created_at():
+    now = utc_now()
+    store = MemoryTaskStore(ttl_hours=24, max_records=100)
+
+    old = _make_task(
+        task_id="old",
+        status=TaskStatus.CANCELED,
+        created_at=now - timedelta(days=2),
+        ended_at=None,
+    )
+    await store.add(old)
+
+    result = await store.get("old")
+    assert result is None
+
+
+@pytest.mark.asyncio
+async def test_running_not_cleaned_by_ttl():
+    now = utc_now()
+    store = MemoryTaskStore(ttl_hours=1, max_records=100)
+
+    running = _make_task(
+        task_id="run",
+        status=TaskStatus.RUNNING,
+        created_at=now - timedelta(days=30),
+        ended_at=None,
+    )
+    await store.add(running)
+
+    result = await store.get("run")
+    assert result is not None
+
+
+@pytest.mark.asyncio
+async def test_pending_not_cleaned_by_ttl():
+    now = utc_now()
+    store = MemoryTaskStore(ttl_hours=1, max_records=100)
+
+    pending = _make_task(
+        task_id="pend",
+        status=TaskStatus.PENDING,
+        created_at=now - timedelta(days=30),
+        ended_at=None,
+    )
+    await store.add(pending)
+
+    result = await store.get("pend")
+    assert result is not None
+
+
+# --- capacity eviction ---
+
+
+@pytest.mark.asyncio
+async def test_overflow_deletes_oldest_final():
+    now = utc_now()
+    store = MemoryTaskStore(max_records=3, ttl_hours=9999)
+
+    for i in range(3):
+        await store.add(
+            _make_task(
+                task_id=f"f{i}",
+                status=TaskStatus.SUCCEEDED,
+                created_at=now - timedelta(hours=3 - i),
+                ended_at=now - timedelta(hours=3 - i),
+            )
+        )
+
+    items = await store.iter_all()
+    assert len(items) == 3
+
+    await store.add(
+        _make_task(
+            task_id="f3",
+            status=TaskStatus.FAILED,
+            created_at=now,
+            ended_at=now,
+        )
+    )
+
+    result = await store.get("f0")
+    assert result is None
+
+    result = await store.get("f3")
+    assert result is not None
+
+
+@pytest.mark.asyncio
+async def test_running_not_deleted_by_capacity():
+    now = utc_now()
+    store = MemoryTaskStore(max_records=3, ttl_hours=9999)
+
+    for i in range(3):
+        await store.add(
+            _make_task(
+                task_id=f"r{i}",
+                status=TaskStatus.RUNNING,
+                created_at=now - timedelta(hours=3 - i),
+            )
+        )
+
+    assert len(await store.iter_all()) == 3
+
+    await store.add(
+        _make_task(
+            task_id="new_final",
+            status=TaskStatus.SUCCEEDED,
+            created_at=now,
+            ended_at=now,
+        )
+    )
+
+    all_items = await store.iter_all()
+    ids = {r.task_id for r in all_items}
+    assert "r0" in ids
+    assert "r1" in ids
+    assert "r2" in ids
+    assert "new_final" not in ids
+
+
+# --- capacity keeps latest records ---
+
+
+@pytest.mark.asyncio
+async def test_capacity_preserves_latest_records():
+    now = utc_now()
+    store = MemoryTaskStore(max_records=10, ttl_hours=999999)
+
+    for i in range(30):
+        await store.add(
+            _make_task(
+                task_id=f"t{i}",
+                status=TaskStatus.SUCCEEDED,
+                created_at=now - timedelta(hours=30 - i),
+                ended_at=now - timedelta(hours=30 - i),
+            )
+        )
+
+    all_items = await store.iter_all()
+    assert len(all_items) <= 10
+
+    result = await store.get("t29")
+    assert result is not None
+
+    result = await store.get("t0")
+    assert result is None
+
+
+# --- concurrent access ---
+
+
+@pytest.mark.asyncio
+async def test_concurrent_access_preserves_running():
+    now = utc_now()
+    store = MemoryTaskStore(max_records=10, ttl_hours=24)
+
+    for i in range(5):
+        await store.add(
+            _make_task(
+                task_id=f"run{i}",
+                status=TaskStatus.RUNNING,
+                created_at=now - timedelta(hours=i),
+            )
+        )
+
+    async def add_final(idx: int) -> None:
+        await store.add(
+            _make_task(
+                task_id=f"f{idx}",
+                status=TaskStatus.SUCCEEDED,
+                created_at=now + timedelta(seconds=idx),
+                ended_at=now + timedelta(seconds=idx),
+            )
+        )
+
+    await asyncio.gather(*(add_final(i) for i in range(20)))
+
+    all_items = await store.iter_all()
+    running_ids = {r.task_id for r in all_items if r.status == TaskStatus.RUNNING}
+    assert len(running_ids) == 5
+    for i in range(5):
+        assert f"run{i}" in running_ids
+
+
+# --- save also triggers eviction ---
+
+
+@pytest.mark.asyncio
+async def test_save_triggers_eviction():
+    now = utc_now()
+    store = MemoryTaskStore(max_records=5, ttl_hours=9999)
+
+    for i in range(5):
+        await store.add(
+            _make_task(
+                task_id=f"t{i}",
+                status=TaskStatus.SUCCEEDED,
+                created_at=now - timedelta(hours=5 - i),
+                ended_at=now - timedelta(hours=5 - i),
+            )
+        )
+
+    updated = _make_task(
+        task_id="t5",
+        status=TaskStatus.SUCCEEDED,
+        created_at=now,
+        ended_at=now,
+    )
+    await store.save(updated)
+
+    all_items = await store.iter_all()
+    assert len(all_items) <= 5
+    result = await store.get("t5")
+    assert result is not None
diff --git a/tests/test_pending_lock_cleanup.py b/tests/test_pending_lock_cleanup.py
new file mode 100644
index 0000000..1fd3326
--- /dev/null
+++ b/tests/test_pending_lock_cleanup.py
@@ -0,0 +1,421 @@
+"""Tests for pending permission state and lock cleanup (Task 3.4).
+
+Covers:
+- UnboundPermissionHandler: response removes pending state and cancels expiry task
+- UnboundPermissionHandler: expiry removes pending state
+- Concurrent responses preserve first-responder-wins
+- Different permissions don't serialize on socket I/O
+- TmuxRunner: RefCountedLockRegistry lock count stays bounded
+- AgentFileWatcher: forget() clears mtime and defers lock cleanup
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.domain.hook_models import HookEvent
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+from app.services.unbound_permission_handler import UnboundPermissionHandler
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_handler(
+    *,
+    allowed_user_ids: set[int] | None = None,
+    permission_ttl_sec: int = 600,
+) -> tuple[UnboundPermissionHandler, MagicMock, MagicMock]:
+    bot = MagicMock()
+    bot.send_message = AsyncMock()
+    hook_socket_server = MagicMock()
+    hook_socket_server.respond_to_permission = AsyncMock()
+    handler = UnboundPermissionHandler(
+        bot=bot,
+        hook_socket_server=hook_socket_server,
+        allowed_user_ids=allowed_user_ids or {111},
+        permission_callback_registry=PermissionCallbackRegistry(ttl_sec=max(permission_ttl_sec, 1)),
+        permission_ttl_sec=permission_ttl_sec,
+    )
+    return handler, bot, hook_socket_server
+
+
+def _make_event(tool_use_id: str = "tuid-1", session_id: str = "sess-1") -> HookEvent:
+    return HookEvent(
+        session_id=session_id,
+        cwd="/tmp/project",
+        event="PermissionRequest",
+        status="waiting_for_approval",
+        tool="Bash",
+        tool_use_id=tool_use_id,
+    )
+
+
+# ---------------------------------------------------------------------------
+# UnboundPermissionHandler: response removes pending state and expiry task
+# ---------------------------------------------------------------------------
+
+
+class TestResponseRemovesPendingAndExpiryTask:
+    """handle_response removes entry from _pending and cancels expiry task."""
+
+    @pytest.mark.asyncio
+    async def test_response_removes_pending_entry(self):
+        handler, _, _ = _make_handler()
+        event = _make_event("tuid-a")
+        await handler.handle_unbound_permission(event)
+
+        assert "tuid-a" in handler._pending
+        assert "tuid-a" in handler._expiry_tasks
+
+        result = await handler.handle_response(tool_use_id="tuid-a", user_id=111, decision="allow")
+
+        assert result.accepted is True
+        assert "tuid-a" not in handler._pending
+        assert "tuid-a" not in handler._expiry_tasks
+
+    @pytest.mark.asyncio
+    async def test_response_cancels_expiry_task(self):
+        handler, _, _ = _make_handler(permission_ttl_sec=3600)
+        event = _make_event("tuid-b")
+        await handler.handle_unbound_permission(event)
+
+        expiry_task = handler._expiry_tasks["tuid-b"]
+        assert not expiry_task.done()
+
+        await handler.handle_response(tool_use_id="tuid-b", user_id=111, decision="deny")
+
+        # Allow cancellation to propagate
+        await asyncio.sleep(0)
+
+        # Task should be cancelled
+        assert expiry_task.cancelled() or expiry_task.done()
+
+
+# ---------------------------------------------------------------------------
+# UnboundPermissionHandler: expiry removes pending state
+# ---------------------------------------------------------------------------
+
+
+class TestExpiryRemovesPendingState:
+    """TTL expiry removes entry from _pending."""
+
+    @pytest.mark.asyncio
+    async def test_expiry_removes_pending_entry(self):
+        handler, _, hook_socket = _make_handler(permission_ttl_sec=0)
+        event = _make_event("tuid-expire")
+        await handler.handle_unbound_permission(event)
+
+        # Wait for expiry task to fire
+        await asyncio.sleep(0.05)
+
+        assert "tuid-expire" not in handler._pending
+        hook_socket.respond_to_permission.assert_called_once_with(
+            tool_use_id="tuid-expire",
+            decision="deny",
+            reason="no user responded within TTL",
+        )
+
+    @pytest.mark.asyncio
+    async def test_expiry_removes_expiry_task_reference(self):
+        handler, _, _ = _make_handler(permission_ttl_sec=0)
+        event = _make_event("tuid-expire2")
+        await handler.handle_unbound_permission(event)
+
+        await asyncio.sleep(0.05)
+
+        assert "tuid-expire2" not in handler._expiry_tasks
+
+
+# ---------------------------------------------------------------------------
+# Concurrent responses preserve first-responder-wins
+# ---------------------------------------------------------------------------
+
+
+class TestConcurrentFirstResponderWins:
+    """Only first concurrent response is accepted; others return False."""
+
+    @pytest.mark.asyncio
+    async def test_concurrent_responses_only_first_wins(self):
+        handler, _, hook_socket = _make_handler(allowed_user_ids={111, 222, 333})
+        event = _make_event("tuid-concurrent")
+        await handler.handle_unbound_permission(event)
+
+        # Fire concurrent responses
+        results = await asyncio.gather(
+            handler.handle_response(tool_use_id="tuid-concurrent", user_id=111, decision="allow"),
+            handler.handle_response(tool_use_id="tuid-concurrent", user_id=222, decision="allow"),
+            handler.handle_response(tool_use_id="tuid-concurrent", user_id=333, decision="deny"),
+        )
+
+        # Exactly one wins
+        assert sum(1 for result in results if result.accepted) == 1
+        assert sum(1 for result in results if not result.accepted) == 2
+
+        # respond_to_permission called exactly once
+        assert hook_socket.respond_to_permission.call_count == 1
+
+    @pytest.mark.asyncio
+    async def test_late_response_after_first_wins_returns_false(self):
+        handler, _, _ = _make_handler(allowed_user_ids={111, 222})
+        event = _make_event("tuid-late")
+        await handler.handle_unbound_permission(event)
+
+        first = await handler.handle_response(tool_use_id="tuid-late", user_id=111, decision="allow")
+        assert first.accepted is True
+
+        second = await handler.handle_response(tool_use_id="tuid-late", user_id=222, decision="deny")
+        assert second.accepted is False
+
+
+# ---------------------------------------------------------------------------
+# Different permissions don't serialize on socket I/O
+# ---------------------------------------------------------------------------
+
+
+class TestDifferentPermissionsDontSerialize:
+    """Responses to different tool_use_ids run concurrently, not serialized."""
+
+    @pytest.mark.asyncio
+    async def test_different_permissions_run_concurrently(self):
+        handler, _, hook_socket = _make_handler()
+
+        # Track timing to verify concurrency
+        call_order: list[str] = []
+
+        async def slow_respond(*, tool_use_id: str, decision: str, reason: str):
+            call_order.append(f"start:{tool_use_id}")
+            await asyncio.sleep(0.02)
+            call_order.append(f"end:{tool_use_id}")
+
+        hook_socket.respond_to_permission = slow_respond
+
+        event_a = _make_event("tuid-A", session_id="sess-A")
+        event_b = _make_event("tuid-B", session_id="sess-B")
+        await handler.handle_unbound_permission(event_a)
+        await handler.handle_unbound_permission(event_b)
+
+        # Fire concurrent responses to different permissions
+        await asyncio.gather(
+            handler.handle_response(tool_use_id="tuid-A", user_id=111, decision="allow"),
+            handler.handle_response(tool_use_id="tuid-B", user_id=111, decision="deny"),
+        )
+
+        # Both started before either finished (concurrent execution)
+        assert "start:tuid-A" in call_order
+        assert "start:tuid-B" in call_order
+        # Both start calls should appear before both end calls
+        start_indices = [call_order.index("start:tuid-A"), call_order.index("start:tuid-B")]
+        end_indices = [call_order.index("end:tuid-A"), call_order.index("end:tuid-B")]
+        assert max(start_indices) < max(end_indices)
+
+
+# ---------------------------------------------------------------------------
+# TmuxRunner: RefCountedLockRegistry lock count stable after repeated runs
+# ---------------------------------------------------------------------------
+
+
+class TestTmuxLockCountStable:
+    """RefCountedLockRegistry lock count stays bounded after completed sessions."""
+
+    @pytest.mark.asyncio
+    async def test_lock_count_stable_after_repeated_runs(self, tmp_path):
+        from app.adapters.process.tmux_runner import TmuxRunner
+
+        runner = TmuxRunner(
+            data_dir=str(tmp_path),
+            session_lock_ttl_sec=1,
+            lock_cleanup_interval_sec=1,
+            lock_cleanup_batch_size=50,
+        )
+
+        # Verify the runner uses RefCountedLockRegistry
+        from app.services.lock_registry import RefCountedLockRegistry
+
+        assert isinstance(runner._session_locks, RefCountedLockRegistry)
+
+        # Simulate multiple lock acquisitions and releases (as persistent terminal runs would do)
+        for i in range(10):
+            async with runner._session_locks.lock(f"session-{i}"):
+                pass
+
+        # After releasing, all locks should be eligible for cleanup
+        # Force time advancement by using a clock override isn't available here,
+        # but we can verify entries exist and will be cleaned
+        initial_count = len(runner._session_locks)
+        assert initial_count <= 10
+
+        # Reuse same key repeatedly - should not grow unbounded
+        for _ in range(20):
+            async with runner._session_locks.lock("session-reuse"):
+                pass
+
+        # Count should not have grown unboundedly
+        assert len(runner._session_locks) <= 11  # 10 unique + 1 reused
+
+    @pytest.mark.asyncio
+    async def test_lock_cleanup_after_ttl(self, tmp_path):
+        from app.services.lock_registry import RefCountedLockRegistry
+
+        now = 100.0
+
+        def clock() -> float:
+            return now
+
+        registry = RefCountedLockRegistry(
+            ttl_sec=10,
+            cleanup_interval_sec=1,
+            cleanup_batch_size=50,
+            clock=clock,
+        )
+
+        # Simulate completing several session locks
+        for i in range(5):
+            async with registry.lock(f"session-{i}"):
+                pass
+
+        assert len(registry) == 5
+
+        # Advance past TTL
+        now = 200.0
+        await registry.cleanup_expired()
+
+        assert len(registry) == 0
+
+
+# ---------------------------------------------------------------------------
+# AgentFileWatcher: forget() clears mtime and defers lock cleanup
+# ---------------------------------------------------------------------------
+
+
+class TestAgentFileWatcherForget:
+    """forget() removes mtime keys immediately and defers lock cleanup."""
+
+    @pytest.mark.asyncio
+    async def test_forget_removes_mtime_keys(self):
+        from unittest.mock import MagicMock as SyncMock
+
+        from app.services.agent_file_watcher import AgentFileWatcher
+
+        session_store = SyncMock()
+        session_store.get.return_value = None
+        claude_parser = SyncMock()
+        on_update = AsyncMock()
+
+        watcher = AgentFileWatcher(
+            session_store=session_store,
+            claude_jsonl_parser=claude_parser,
+            on_update=on_update,
+            poll_interval_sec=0.01,
+        )
+
+        # Simulate mtime entries for a session
+        watcher._seen_mtimes["sess-1:tool-1:agent-1"] = 1000.0
+        watcher._seen_mtimes["sess-1:tool-2:agent-2"] = 2000.0
+        watcher._seen_mtimes["sess-2:tool-1:agent-1"] = 3000.0
+
+        # Start a fake task so forget has something to cancel
+        watcher._tasks["sess-1"] = asyncio.create_task(asyncio.sleep(100))
+
+        watcher.forget("sess-1")
+
+        # mtime keys for sess-1 removed immediately
+        assert "sess-1:tool-1:agent-1" not in watcher._seen_mtimes
+        assert "sess-1:tool-2:agent-2" not in watcher._seen_mtimes
+        # Other session's mtime keys preserved
+        assert "sess-2:tool-1:agent-1" in watcher._seen_mtimes
+        # Task removed from _tasks
+        assert "sess-1" not in watcher._tasks
+
+    @pytest.mark.asyncio
+    async def test_forget_cancels_watcher_task(self):
+        from app.services.agent_file_watcher import AgentFileWatcher
+
+        session_store = MagicMock()
+        session_store.get.return_value = None
+        claude_parser = MagicMock()
+        on_update = AsyncMock()
+
+        watcher = AgentFileWatcher(
+            session_store=session_store,
+            claude_jsonl_parser=claude_parser,
+            on_update=on_update,
+            poll_interval_sec=0.01,
+        )
+
+        # Create a long-running task
+        task = asyncio.create_task(asyncio.sleep(100))
+        watcher._tasks["sess-cancel"] = task
+
+        watcher.forget("sess-cancel")
+
+        # Allow cancellation to propagate
+        await asyncio.sleep(0)
+
+        assert task.cancelled()
+
+    @pytest.mark.asyncio
+    async def test_finished_watcher_cleans_own_lock(self):
+        """Watcher's finally block cleans up its own completed task and lock."""
+        from app.services.agent_file_watcher import AgentFileWatcher
+
+        session_store = MagicMock()
+        # Return None so _watch_session exits early
+        session_store.get.return_value = None
+        claude_parser = MagicMock()
+        on_update = AsyncMock()
+
+        watcher = AgentFileWatcher(
+            session_store=session_store,
+            claude_jsonl_parser=claude_parser,
+            on_update=on_update,
+            poll_interval_sec=0.01,
+        )
+        watcher._active = True
+
+        watcher.watch(session_id="sess-gen", workdir="/tmp")
+        task = watcher._tasks["sess-gen"]
+        await task
+
+        assert "sess-gen" not in watcher._tasks
+        assert "sess-gen" not in watcher._session_locks
+
+    @pytest.mark.asyncio
+    async def test_newer_watcher_prevents_old_lock_cleanup(self):
+        """If a newer watcher is registered, old cleanup doesn't remove its lock."""
+        from app.services.agent_file_watcher import AgentFileWatcher
+
+        session_store = MagicMock()
+        session_store.get.return_value = None
+        claude_parser = MagicMock()
+        on_update = AsyncMock()
+
+        watcher = AgentFileWatcher(
+            session_store=session_store,
+            claude_jsonl_parser=claude_parser,
+            on_update=on_update,
+            poll_interval_sec=0.01,
+        )
+
+        old_task = asyncio.create_task(asyncio.sleep(100))
+        new_task = asyncio.create_task(asyncio.sleep(100))
+        watcher._tasks["sess-overlap"] = new_task
+        watcher._session_locks["sess-overlap"] = asyncio.Lock()
+
+        try:
+            watcher._cleanup_finished_session(session_id="sess-overlap", task=old_task)
+
+            assert watcher._tasks["sess-overlap"] is new_task
+            assert "sess-overlap" in watcher._session_locks
+        finally:
+            for task in (old_task, new_task):
+                task.cancel()
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
diff --git a/tests/test_permission_callback_registry.py b/tests/test_permission_callback_registry.py
new file mode 100644
index 0000000..9d56b09
--- /dev/null
+++ b/tests/test_permission_callback_registry.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+
+
+def test_registry_resolves_full_tool_use_id_from_short_token() -> None:
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "abc12345", clock=lambda: 100.0)
+    tool_use_id = "toolu_" + "x" * 200
+
+    token = registry.register(tool_use_id)
+
+    assert token == "abc12345"
+    assert registry.resolve(token) == tool_use_id
+    assert len(token.encode("utf-8")) < len(tool_use_id.encode("utf-8"))
+
+
+def test_registry_expires_tokens() -> None:
+    now = 100.0
+
+    def clock() -> float:
+        return now
+
+    registry = PermissionCallbackRegistry(ttl_sec=10, token_factory=lambda: "token001", clock=clock)
+    token = registry.register("tool-1")
+
+    now = 111.0
+
+    assert registry.resolve(token) is None
+
+
+def test_registry_retries_live_token_collision() -> None:
+    tokens = iter(["same001", "same001", "next002"])
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: next(tokens), clock=lambda: 100.0)
+
+    first = registry.register("tool-1")
+    second = registry.register("tool-2")
+
+    assert first == "same001"
+    assert second == "next002"
+    assert registry.resolve(first) == "tool-1"
+    assert registry.resolve(second) == "tool-2"
diff --git a/tests/test_permission_registry_sharing.py b/tests/test_permission_registry_sharing.py
new file mode 100644
index 0000000..9cfe6a3
--- /dev/null
+++ b/tests/test_permission_registry_sharing.py
@@ -0,0 +1,142 @@
+"""Regression tests: an empty PermissionCallbackRegistry must not be replaced
+by a fallback instance just because `__len__` makes it falsy.
+
+History: PermissionCallbackRegistry defines __len__, so an empty (newly
+constructed) registry evaluates to False in a boolean context. Several call
+sites used `registry or PermissionCallbackRegistry(...)` as a fallback, which
+silently replaced a freshly injected empty registry with a brand-new instance.
+The result was two registries in the same process: one used by the push
+notifier (where tokens were registered) and another used by the
+external_permission callback handler (where tokens were resolved). All
+callbacks then failed with "ext_perm token resolve failed".
+
+These tests pin the contract: when a real (possibly empty) registry is
+provided, the consumer must use that exact instance.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.services.permission_callback_registry import PermissionCallbackRegistry
+
+
+def test_empty_registry_is_falsy_via_len() -> None:
+    """Document the gotcha: an empty registry is falsy because of __len__."""
+    registry = PermissionCallbackRegistry(ttl_sec=600)
+    assert len(registry) == 0
+    assert not registry  # __bool__ falls back to __len__
+    assert registry is not None
+
+
+def test_push_notifier_uses_injected_empty_registry() -> None:
+    """ExternalSessionPushNotifier must keep the injected registry, not replace it."""
+    from app.services.external_session_push_notifier import ExternalSessionPushNotifier
+
+    bot = MagicMock()
+    bot.send_message = AsyncMock()
+    binding_store = MagicMock()
+    registry = PermissionCallbackRegistry(ttl_sec=600)
+
+    notifier = ExternalSessionPushNotifier(
+        bot=bot,
+        binding_store=binding_store,
+        permission_callback_registry=registry,
+    )
+
+    # Must be the SAME instance; an `or` fallback would have created a different one.
+    assert notifier._permission_callback_registry is registry
+
+
+def test_unbound_permission_handler_uses_injected_empty_registry() -> None:
+    """UnboundPermissionHandler must keep the injected registry, not replace it."""
+    from app.services.unbound_permission_handler import UnboundPermissionHandler
+
+    bot = MagicMock()
+    hook_socket_server = MagicMock()
+    registry = PermissionCallbackRegistry(ttl_sec=600)
+
+    handler = UnboundPermissionHandler(
+        bot=bot,
+        hook_socket_server=hook_socket_server,
+        allowed_user_ids={1},
+        permission_callback_registry=registry,
+    )
+
+    assert handler._permission_callback_registry is registry
+
+
+def test_token_registered_in_one_path_resolves_in_another() -> None:
+    """End-to-end-ish: a token registered via the push notifier must resolve
+    against the same registry object that the external_permission handler
+    receives. Both consumers must share the injected registry."""
+    from app.services.external_session_push_notifier import ExternalSessionPushNotifier
+
+    bot = MagicMock()
+    bot.send_message = AsyncMock()
+    binding_store = MagicMock()
+    shared_registry = PermissionCallbackRegistry(ttl_sec=600)
+
+    notifier = ExternalSessionPushNotifier(
+        bot=bot,
+        binding_store=binding_store,
+        permission_callback_registry=shared_registry,
+    )
+
+    # The notifier holds a reference to the same registry the caller injected.
+    assert notifier._permission_callback_registry is shared_registry
+
+    # Register a token via the registry directly (simulating the path used in
+    # notify_permission_request) and confirm it resolves against the injected
+    # registry — i.e., the registry truly shared.
+    token = shared_registry.register("toolu_abc123")
+    assert shared_registry.resolve(token) == "toolu_abc123"
+
+
+def test_fallback_only_triggers_when_registry_is_none() -> None:
+    """If the caller really passes None, a fallback instance is created.
+
+    This documents the intended fallback semantics (used by some unit tests).
+    """
+    from app.services.external_session_push_notifier import ExternalSessionPushNotifier
+
+    bot = MagicMock()
+    binding_store = MagicMock()
+
+    notifier = ExternalSessionPushNotifier(
+        bot=bot,
+        binding_store=binding_store,
+        permission_callback_registry=None,
+    )
+
+    assert notifier._permission_callback_registry is not None
+    assert isinstance(notifier._permission_callback_registry, PermissionCallbackRegistry)
+
+
+@pytest.mark.parametrize(
+    "factory_args",
+    [
+        # No explicit kwarg at all (defaults to None internally).
+        {},
+        # Explicit None.
+        {"permission_callback_registry": None},
+    ],
+)
+def test_unbound_handler_fallback_when_no_registry(factory_args: dict) -> None:
+    """UnboundPermissionHandler requires a registry — passing one works."""
+    from app.services.unbound_permission_handler import UnboundPermissionHandler
+
+    bot = MagicMock()
+    hook_socket_server = MagicMock()
+    registry = PermissionCallbackRegistry(ttl_sec=600)
+
+    handler = UnboundPermissionHandler(
+        bot=bot,
+        hook_socket_server=hook_socket_server,
+        allowed_user_ids={1},
+        permission_callback_registry=registry,
+    )
+
+    assert handler._permission_callback_registry is registry
diff --git a/tests/test_run_event_streamer_upload_queue.py b/tests/test_run_event_streamer_upload_queue.py
new file mode 100644
index 0000000..ea875ff
--- /dev/null
+++ b/tests/test_run_event_streamer_upload_queue.py
@@ -0,0 +1,295 @@
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.bot.handlers.command_run import run_prompt_and_stream
+from app.bot.presenters.chunk_sender import ChunkSender
+from app.domain.file_models import DiffResult, FileUploadResult, FileValidationError
+from app.domain.models import CLIEvent, EventType, TaskRecord, TaskStatus
+from app.services.upload_queue import UploadQueueManager
+from tests.fakes.telegram import DummyMessage
+
+
+class DummyTaskService:
+    def __init__(self, events: list[CLIEvent], status: TaskRecord | None = None) -> None:
+        self._events = events
+        self._status = status
+        self._revision = 0
+
+    async def create_and_run(self, *, user_id: int, provider: str | None, prompt: str, workdir: str | None = None):
+        task = SimpleNamespace(
+            task_id="t1",
+            provider="claude_code",
+            session_id="s1",
+            workdir=workdir or "/tmp/work",
+            started_at=None,
+            created_at=None,
+        )
+        return SimpleNamespace(task=task, events=self._stream(), interactive=False)
+
+    async def get_status(self, task_id: str, user_id: int):
+        return self._status
+
+    async def get_structured_session(self, user_id: int, *, log_missing: bool = True):
+        return None
+
+    async def get_structured_session_for_task(self, *, task_id: str, user_id: int, log_missing: bool = True):
+        return None
+
+    async def get_structured_session_cursor(self, user_id: int, *, task_id: str | None = None) -> int:
+        return self._revision
+
+    async def get_structured_reply_cursor(self, user_id: int, *, task_id: str | None = None):
+        return None, None
+
+    async def acknowledge_structured_reply(self, user_id: int, **kwargs) -> None:
+        pass
+
+    async def get_structured_user_question_cursor(self, user_id: int, *, task_id: str | None = None):
+        return None
+
+    async def acknowledge_structured_user_question(self, user_id: int, **kwargs) -> None:
+        pass
+
+    async def wait_for_structured_session_update(self, **kwargs) -> bool:
+        return False
+
+    async def _stream(self):
+        for event in self._events:
+            yield event
+
+
+class OrderRecordingMessage(DummyMessage):
+    def __init__(self, *, order: list[str], user_id: int = 1) -> None:
+        super().__init__(user_id=user_id)
+        self._order = order
+
+    async def answer(self, text: str, reply_markup=None, parse_mode=None):
+        sent = await super().answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
+        original_edit_text = sent.edit_text
+
+        async def record_edit(text: str, parse_mode=None):
+            result = await original_edit_text(text, parse_mode=parse_mode)
+            if "✅ 完成" in text:
+                self._order.append("success-message")
+            return result
+
+        sent.edit_text = record_edit
+        if "--- a/file.py" in text:
+            self._order.append("diff-message")
+        return sent
+
+
+class OrderRecordingDiffGenerator:
+    def __init__(self, *, modified_file: Path) -> None:
+        self._modified_file = modified_file
+
+    def capture_snapshot(self, workdir: str, gitignore_patterns: list[str]):
+        return {self._modified_file: 100.0}
+
+    def detect_modified_files(self, *, workdir: str, pre_snapshot, gitignore_patterns: list[str]):
+        return [self._modified_file]
+
+    def generate_unified_diff(self, modified_files, pre_snapshot):
+        return DiffResult(
+            content="--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new",
+            file_count=1,
+            is_patch_file=False,
+        )
+
+
+def _task_record(*, task_id: str, user_id: int, status: TaskStatus) -> TaskRecord:
+    return TaskRecord(
+        task_id=task_id,
+        session_id="s1",
+        user_id=user_id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp/work",
+        timeout_sec=30,
+        status=status,
+    )
+
+
+@pytest.mark.asyncio
+async def test_queued_upload_scheduler_runs_after_success_message_is_displayed() -> None:
+    message = DummyMessage(user_id=7)
+    task_service = DummyTaskService(
+        events=[
+            CLIEvent(type=EventType.STARTED, task_id="t1"),
+            CLIEvent(type=EventType.EXITED, task_id="t1", exit_code=0),
+        ],
+        status=TaskRecord(
+            task_id="t1",
+            session_id="s1",
+            user_id=7,
+            provider="claude_code",
+            prompt="hello",
+            workdir="/tmp/work",
+            timeout_sec=30,
+            status=TaskStatus.SUCCEEDED,
+        ),
+    )
+    scheduler_calls: list[tuple[int, str, str]] = []
+
+    def queued_upload_scheduler(root_message: DummyMessage, user_id: int, completed_task_id: str) -> None:
+        scheduler_calls.append((user_id, root_message.sent_messages[0].text, completed_task_id))
+
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir="/tmp/work",
+        queued_upload_scheduler=queued_upload_scheduler,
+    )
+    assert task is not None
+    await task
+
+    assert len(scheduler_calls) == 1
+    called_user_id, displayed_text, completed_task_id = scheduler_calls[0]
+    assert called_user_id == 7
+    assert completed_task_id == "t1"
+    assert "✅ 完成" in displayed_text
+
+
+@pytest.mark.asyncio
+async def test_queued_upload_scheduler_runs_after_success_diff_output(tmp_path: Path) -> None:
+    order: list[str] = []
+    message = OrderRecordingMessage(user_id=7, order=order)
+    modified_file = tmp_path / "file.py"
+    task_service = DummyTaskService(
+        events=[
+            CLIEvent(type=EventType.STARTED, task_id="t1"),
+            CLIEvent(type=EventType.EXITED, task_id="t1", exit_code=0),
+        ],
+        status=TaskRecord(
+            task_id="t1",
+            session_id="s1",
+            user_id=7,
+            provider="claude_code",
+            prompt="hello",
+            workdir=str(tmp_path),
+            timeout_sec=30,
+            status=TaskStatus.SUCCEEDED,
+        ),
+    )
+
+    def queued_upload_scheduler(root_message: DummyMessage, user_id: int, completed_task_id: str) -> None:
+        assert root_message is message
+        assert user_id == 7
+        assert completed_task_id == "t1"
+        order.append("scheduler")
+
+    task = await run_prompt_and_stream(
+        message=message,
+        task_service=task_service,
+        sender_factory=lambda: ChunkSender(chunk_size=50, flush_interval_sec=0.01),
+        user_id=message.from_user.id,
+        provider="claude_code",
+        prompt="hello",
+        workdir=str(tmp_path),
+        diff_generator=OrderRecordingDiffGenerator(modified_file=modified_file),
+        queued_upload_scheduler=queued_upload_scheduler,
+    )
+    assert task is not None
+    await task
+
+    assert order == ["success-message", "diff-message", "scheduler"]
+
+
+@pytest.mark.asyncio
+async def test_queued_upload_processing_continues_after_failed_file(tmp_path: Path) -> None:
+    from app.bot.handlers.file_upload import schedule_pending_upload_processing
+
+    user_id = 7
+    message = DummyMessage(user_id=user_id)
+    upload_queue = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=100)
+    await upload_queue.enqueue(user_id=user_id, filename="bad.exe", data=b"bad")
+    await upload_queue.enqueue(user_id=user_id, filename="good.txt", data=b"good")
+
+    session_service = AsyncMock()
+    session_service.get = AsyncMock(return_value=SimpleNamespace(workdir=str(tmp_path)))
+    file_receiver = AsyncMock()
+    file_receiver.receive_file = AsyncMock(
+        side_effect=[
+            FileValidationError(filename="bad.exe", reason="Extension .exe is not allowed."),
+            FileUploadResult(filename="good.txt", size_bytes=4, path=tmp_path / "good.txt"),
+        ]
+    )
+
+    task = schedule_pending_upload_processing(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=user_id,
+    )
+    await task
+
+    assert message.answers == [
+        "❌ 文件被拒绝: bad.exe\n原因: Extension .exe is not allowed.",
+        "✅ 文件已接收: good.txt (4 B)",
+    ]
+    assert await upload_queue.queued_count(user_id=user_id) == 0
+
+
+@pytest.mark.asyncio
+async def test_queued_upload_processing_waits_for_other_active_task(tmp_path: Path) -> None:
+    from app.bot.handlers.file_upload import schedule_pending_upload_processing
+
+    user_id = 7
+    message = DummyMessage(user_id=user_id)
+    upload_queue = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=100)
+    await upload_queue.enqueue(user_id=user_id, filename="queued.txt", data=b"queued")
+
+    session_service = AsyncMock()
+    session_service.get = AsyncMock(return_value=SimpleNamespace(workdir=str(tmp_path)))
+    file_receiver = AsyncMock()
+    file_receiver.receive_file = AsyncMock(return_value=FileUploadResult(filename="queued.txt", size_bytes=6, path=tmp_path / "queued.txt"))
+    task_service = AsyncMock()
+    task_service.list_recent = AsyncMock(
+        side_effect=[
+            [
+                _task_record(task_id="completed", user_id=user_id, status=TaskStatus.RUNNING),
+                _task_record(task_id="other", user_id=user_id, status=TaskStatus.RUNNING),
+            ],
+            [_task_record(task_id="completed", user_id=user_id, status=TaskStatus.RUNNING)],
+        ]
+    )
+
+    first_task = schedule_pending_upload_processing(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=user_id,
+        task_service=task_service,
+        completed_task_id="completed",
+    )
+    await first_task
+
+    assert await upload_queue.queued_count(user_id=user_id) == 1
+    file_receiver.receive_file.assert_not_awaited()
+    assert message.answers == []
+
+    second_task = schedule_pending_upload_processing(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=user_id,
+        task_service=task_service,
+        completed_task_id="completed",
+    )
+    await second_task
+
+    assert await upload_queue.queued_count(user_id=user_id) == 0
+    file_receiver.receive_file.assert_awaited_once()
+    assert message.answers == ["✅ 文件已接收: queued.txt (6 B)"]
diff --git a/tests/test_session_handlers.py b/tests/test_session_handlers.py
index 25f9faa..e79eef2 100644
--- a/tests/test_session_handlers.py
+++ b/tests/test_session_handlers.py
@@ -23,6 +23,7 @@
 )
 from app.services.session_service import SessionService
 from app.services.task_service import TaskService
+from app.services.permission_callback_registry import PermissionCallbackRegistry
 from tests.fakes.cli import DummyHookSocketServer, StubAdapter, StubFactory, make_settings
 from tests.fakes.telegram import DummyCallbackQuery, DummyMessage
 
@@ -280,10 +281,12 @@ async def test_permission_callback_handler_approves_pending_request(tmp_path) ->
     tmux_runner._session_store._persist(state)
 
     router = DummyRouter()
-    register_permission_handlers(router, task_service=service)
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    token = registry.register("tool-1")
+    register_permission_handlers(router, task_service=service, permission_callback_registry=registry)
     callback_handler = router.callback_handlers[0]
     message = DummyMessage("权限请求")
-    callback = DummyCallbackQuery("perm:allow:tool-1", message=message)
+    callback = DummyCallbackQuery(f"perm:allow:{token}", message=message)
 
     await callback_handler(callback)
 
@@ -326,17 +329,19 @@ async def test_permission_callback_handler_rejects_stale_button(tmp_path) -> Non
     tmux_runner._session_store._persist(state)
 
     router = DummyRouter()
-    register_permission_handlers(router, task_service=service)
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    register_permission_handlers(router, task_service=service, permission_callback_registry=registry)
     callback_handler = router.callback_handlers[0]
     message = DummyMessage("权限请求")
-    callback = DummyCallbackQuery("perm:allow:tool-1", message=message)
+    callback = DummyCallbackQuery("perm:allow:missing", message=message)
 
     await callback_handler(callback)
 
     assert hook_socket_server.calls == []
-    assert message.answers == ["权限操作失败: 这个权限按钮已经过期,请等待最新的权限请求"]
+    assert "权限按钮已失效" in message.answers[0]
+    assert "重新触发" in message.answers[0]
     assert message.edited_reply_markups == []
-    assert callback.answers == [("这个权限按钮已经过期,请等待最新的权限请求", True)]
+    assert callback.answers == [("按钮已失效", True)]
 
 
 @pytest.mark.asyncio
@@ -383,18 +388,45 @@ async def test_permission_callback_handler_rejects_cross_user_button(tmp_path) -
     tmux_runner._session_store._persist(state)
 
     router = DummyRouter()
-    register_permission_handlers(router, task_service=service)
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    token = registry.register("tool-1")
+    register_permission_handlers(
+        router,
+        task_service=service,
+        hook_socket_server=hook_socket_server,
+        structured_session_store=tmux_runner._session_store,
+        permission_callback_registry=registry,
+    )
     callback_handler = router.callback_handlers[0]
     message = DummyMessage("权限请求", user_id=2)
-    callback = DummyCallbackQuery("perm:allow:tool-1", user_id=2, message=message)
+    callback = DummyCallbackQuery(f"perm:allow:{token}", user_id=2, message=message)
 
     await callback_handler(callback)
 
-    assert hook_socket_server.calls == []
-    assert message.answers == ["权限操作失败: 这个权限按钮已经过期,请等待最新的权限请求"]
-    assert message.edited_reply_markups == []
-    assert callback.answers == [("这个权限按钮已经过期,请等待最新的权限请求", True)]
-    assert tmux_runner._session_store.get("claude-session-1").pending_permission is not None
+    assert hook_socket_server.calls == [("tool-1", "allow", None)]
+    assert message.answers == ["已批准权限请求: Bash"]
+    assert message.edited_reply_markups == [None]
+    assert callback.answers == [("已批准权限请求: Bash", False)]
+    assert tmux_runner._session_store.get("claude-session-1").pending_permission is None
+
+
+def test_permission_callback_data_uses_short_token_for_long_tool_use_id() -> None:
+    from app.bot.handlers.command_permission import build_permission_callback_data, build_permission_keyboard
+
+    registry = PermissionCallbackRegistry(ttl_sec=60, token_factory=lambda: "tok12345", clock=lambda: 100.0)
+    long_tool_use_id = "toolu_" + "x" * 200
+
+    keyboard = build_permission_keyboard(tool_use_id=long_tool_use_id, permission_callback_registry=registry)
+    callback_data = [button.callback_data for row in keyboard.inline_keyboard for button in row]
+
+    assert callback_data == [
+        "perm:allow:tok12345",
+        "perm:deny:tok12345",
+        "perm:auto_approve:tok12345",
+    ]
+    assert all(data is not None and len(data.encode("utf-8")) <= 64 for data in callback_data)
+    assert registry.resolve("tok12345") == long_tool_use_id
+    assert build_permission_callback_data(decision="allow", token="tok12345") == "perm:allow:tok12345"
 
 
 @pytest.mark.asyncio
diff --git a/tests/test_session_id_centralization.py b/tests/test_session_id_centralization.py
new file mode 100644
index 0000000..77d2ab0
--- /dev/null
+++ b/tests/test_session_id_centralization.py
@@ -0,0 +1,55 @@
+# Feature: deduplicate-session-id-utils, Task 3.1
+"""Verify centralized session ID utilities are properly exported and shared."""
+
+from __future__ import annotations
+
+from app.domain.session_models import CLAUDE_SESSION_PREFIX, _UUID_SESSION_RE, is_claude_session_id
+
+
+def test_claude_session_prefix_value() -> None:
+    """CLAUDE_SESSION_PREFIX has the canonical value."""
+    assert CLAUDE_SESSION_PREFIX == "claude-session-"
+
+
+def test_uuid_session_re_matches_valid_uuids() -> None:
+    """_UUID_SESSION_RE matches known valid UUIDs."""
+    valid_uuids = [
+        "550e8400-e29b-41d4-a716-446655440000",  # v4
+        "6ba7b810-9dad-11d1-80b4-00c04fd430c8",  # v1
+        "3d813cbb-47fb-32ba-91df-831e1593ac29",  # v3
+        "21f7f8de-8051-5b89-8680-0195ef798b6a",  # v5
+        "F47AC10B-58CC-4372-A567-0E02B2C3D479",  # uppercase
+    ]
+    for uid in valid_uuids:
+        assert _UUID_SESSION_RE.match(uid), f"Should match valid UUID: {uid}"
+
+
+def test_uuid_session_re_rejects_invalid_strings() -> None:
+    """_UUID_SESSION_RE rejects non-UUID strings."""
+    invalid = [
+        "",
+        "not-a-uuid",
+        "550e8400-e29b-41d4-a716",  # truncated
+        "550e8400-e29b-61d4-a716-446655440000",  # v6 (not supported)
+        "550e8400-e29b-01d4-a716-446655440000",  # v0 (not valid)
+        "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx",  # template
+    ]
+    for s in invalid:
+        assert not _UUID_SESSION_RE.match(s), f"Should reject: {s}"
+
+
+def test_session_store_uses_canonical_is_claude_session_id() -> None:
+    """session_store.py imports is_claude_session_id from the canonical location."""
+    from app.services import session_store
+
+    # The module-level import should be the same object
+    store_fn = getattr(session_store, "is_claude_session_id", None)
+    assert store_fn is is_claude_session_id, "session_store.is_claude_session_id should be imported from app.domain.session_models"
+
+
+def test_session_state_cache_uses_canonical_is_claude_session_id() -> None:
+    """session_state_cache.py imports is_claude_session_id from the canonical location."""
+    from app.services import session_state_cache
+
+    cache_fn = getattr(session_state_cache, "is_claude_session_id", None)
+    assert cache_fn is is_claude_session_id, "session_state_cache.is_claude_session_id should be imported from app.domain.session_models"
diff --git a/tests/test_session_registry.py b/tests/test_session_registry.py
index 4b24bc0..68b3686 100644
--- a/tests/test_session_registry.py
+++ b/tests/test_session_registry.py
@@ -1,8 +1,12 @@
 from __future__ import annotations
 
+from datetime import timedelta
+
 import pytest
 
 from app.adapters.storage.file_session_store import FileSessionStore
+from app.domain.models import utc_now
+from app.domain.session_models import SessionState
 from app.adapters.storage.file_session_context_store import FileSessionContextStore
 from app.services.session_registry import SessionRegistryService
 from app.services.session_service import SessionService
@@ -279,6 +283,96 @@ async def test_validate_or_reattach_returns_none_when_dead_and_no_recovery(tmp_p
     assert result is None
 
 
+@pytest.mark.asyncio
+async def test_validate_or_reattach_binds_live_state_for_same_user_and_workdir(tmp_path) -> None:
+    registry, session_service, cache, _ = _make_registry(
+        tmp_path,
+        alive_sessions={"tgcli_user_1_new456"},
+    )
+    await session_service.switch(
+        user_id=1,
+        provider="claude_code",
+        workdir="/proj",
+        terminal_mode=True,
+        claude_chat_active=True,
+    )
+    ctx = await session_service.get(1)
+    ctx.terminal_id = "user_1_old123"
+    ctx.claude_session_id = "old-claude-session"
+    old_updated_at = utc_now() - timedelta(hours=1)
+    ctx.updated_at = old_updated_at
+    await session_service._store.save(ctx)
+
+    cache._repository.save(
+        SessionState(
+            session_id="state-new456",
+            user_id=1,
+            provider="claude_code",
+            workdir="/proj",
+            terminal_id="user_1_new456",
+            claude_session_id="new-claude-session",
+        )
+    )
+
+    result = await registry.validate_or_reattach(user_id=1)
+
+    assert result is not None
+    assert result.terminal_id == "user_1_new456"
+    assert result.claude_session_id == "new-claude-session"
+    assert result.updated_at > old_updated_at
+
+
+@pytest.mark.asyncio
+async def test_validate_or_reattach_chooses_most_recent_live_state(tmp_path) -> None:
+    registry, session_service, cache, _ = _make_registry(
+        tmp_path,
+        alive_sessions={"tgcli_user_1_older", "tgcli_user_1_newer"},
+    )
+    await session_service.switch(
+        user_id=1,
+        provider="claude_code",
+        workdir="/proj",
+        terminal_mode=True,
+        claude_chat_active=True,
+    )
+    ctx = await session_service.get(1)
+    ctx.terminal_id = "user_1_dead"
+    ctx.claude_session_id = "dead-claude-session"
+    await session_service._store.save(ctx)
+
+    now = utc_now()
+    older = SessionState(
+        session_id="aaa-older",
+        user_id=1,
+        provider="claude_code",
+        workdir="/proj",
+        terminal_id="user_1_older",
+        claude_session_id="older-claude-session",
+    )
+    older.created_at = now - timedelta(minutes=10)
+    older.last_activity = now - timedelta(minutes=10)
+    older.revision = 1
+    newer = SessionState(
+        session_id="zzz-newer",
+        user_id=1,
+        provider="claude_code",
+        workdir="/proj",
+        terminal_id="user_1_newer",
+        claude_session_id="newer-claude-session",
+    )
+    newer.created_at = now - timedelta(minutes=5)
+    newer.last_activity = now
+    newer.revision = 2
+    cache._repository.save(older)
+    cache._repository.save(newer)
+
+    result = await registry.validate_or_reattach(user_id=1)
+
+    assert result is not None
+    assert result.terminal_id == "user_1_newer"
+    assert result.claude_session_id == "newer-claude-session"
+
+
 # ── get_session_info ──────────────────────────────────────────────────────────
 
 
diff --git a/tests/test_session_scanner.py b/tests/test_session_scanner.py
new file mode 100644
index 0000000..d7b42fb
--- /dev/null
+++ b/tests/test_session_scanner.py
@@ -0,0 +1,137 @@
+"""Tests for SessionScanner service."""
+
+from __future__ import annotations
+
+import json
+import time
+from datetime import datetime
+from pathlib import Path
+
+import pytest
+
+from app.adapters.claude.paths import ClaudePaths
+from app.services.session_scanner import SessionScanner
+
+
+@pytest.fixture
+def scanner() -> SessionScanner:
+    return SessionScanner()
+
+
+@pytest.fixture
+def claude_paths(tmp_path: Path) -> ClaudePaths:
+    return ClaudePaths(root_dir=tmp_path / ".claude")
+
+
+def _make_session_file(
+    projects_dir: Path,
+    encoded_workdir: str,
+    session_id: str,
+    human_message: str = "hello world",
+    mtime: float | None = None,
+) -> Path:
+    session_dir = projects_dir / encoded_workdir
+    session_dir.mkdir(parents=True, exist_ok=True)
+    path = session_dir / f"{session_id}.jsonl"
+
+    lines = [
+        json.dumps({"type": "permission-mode", "sessionId": session_id}),
+        json.dumps({"type": "human", "message": {"content": [{"type": "text", "text": human_message}]}}),
+    ]
+    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+    if mtime is not None:
+        import os
+
+        os.utime(path, (mtime, mtime))
+
+    return path
+
+
+class TestEncodeWorkdir:
+    def test_replaces_slashes(self, scanner: SessionScanner) -> None:
+        assert scanner.encode_workdir("/Users/jack/project") == "-Users-jack-project"
+
+    def test_root_path(self, scanner: SessionScanner) -> None:
+        assert scanner.encode_workdir("/") == "-"
+
+    def test_no_slashes(self, scanner: SessionScanner) -> None:
+        assert scanner.encode_workdir("relative") == "relative"
+
+
+class TestScan:
+    def test_returns_empty_when_dir_missing(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        result = scanner.scan("/nonexistent/path", claude_paths)
+        assert result == []
+
+    def test_discovers_session_files(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/Users/jack/project")
+        _make_session_file(claude_paths.projects_dir, encoded, "abc-123", "hi there")
+
+        result = scanner.scan("/Users/jack/project", claude_paths)
+        assert len(result) == 1
+        assert result[0].session_id == "abc-123"
+        assert result[0].summary == "hi there"
+        assert isinstance(result[0].modified_at, datetime)
+
+    def test_excludes_non_jsonl_files(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/work")
+        session_dir = claude_paths.projects_dir / encoded
+        session_dir.mkdir(parents=True)
+        (session_dir / "readme.txt").write_text("ignore me")
+        _make_session_file(claude_paths.projects_dir, encoded, "s1")
+
+        result = scanner.scan("/work", claude_paths)
+        assert len(result) == 1
+        assert result[0].session_id == "s1"
+
+    def test_excludes_subagents_directory(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/work")
+        session_dir = claude_paths.projects_dir / encoded
+        subagents = session_dir / "subagents"
+        subagents.mkdir(parents=True)
+        (subagents / "agent-1.jsonl").write_text(json.dumps({"type": "human", "message": {"content": "sub"}}) + "\n")
+        _make_session_file(claude_paths.projects_dir, encoded, "main-session")
+
+        result = scanner.scan("/work", claude_paths)
+        assert len(result) == 1
+        assert result[0].session_id == "main-session"
+
+    def test_sorts_by_mtime_descending(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/work")
+        now = time.time()
+        _make_session_file(claude_paths.projects_dir, encoded, "old", "old msg", mtime=now - 100)
+        _make_session_file(claude_paths.projects_dir, encoded, "new", "new msg", mtime=now)
+        _make_session_file(claude_paths.projects_dir, encoded, "mid", "mid msg", mtime=now - 50)
+
+        result = scanner.scan("/work", claude_paths)
+        assert [s.session_id for s in result] == ["new", "mid", "old"]
+
+    def test_respects_max_results(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/work")
+        for i in range(15):
+            _make_session_file(claude_paths.projects_dir, encoded, f"session-{i:02d}", mtime=float(i))
+
+        result = scanner.scan("/work", claude_paths, max_results=10)
+        assert len(result) == 10
+
+    def test_extracts_string_content(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/work")
+        session_dir = claude_paths.projects_dir / encoded
+        session_dir.mkdir(parents=True)
+        path = session_dir / "s1.jsonl"
+        path.write_text(json.dumps({"type": "human", "message": {"content": "plain string content"}}) + "\n")
+
+        result = scanner.scan("/work", claude_paths)
+        assert result[0].summary == "plain string content"
+
+    def test_skips_malformed_jsonl(self, scanner: SessionScanner, claude_paths: ClaudePaths) -> None:
+        encoded = scanner.encode_workdir("/work")
+        session_dir = claude_paths.projects_dir / encoded
+        session_dir.mkdir(parents=True)
+        path = session_dir / "bad.jsonl"
+        path.write_text("not valid json\n")
+
+        result = scanner.scan("/work", claude_paths)
+        assert len(result) == 1
+        assert result[0].summary == ""
diff --git a/tests/test_structured_reply_presenter.py b/tests/test_structured_reply_presenter.py
index 1d37145..7dcae55 100644
--- a/tests/test_structured_reply_presenter.py
+++ b/tests/test_structured_reply_presenter.py
@@ -611,7 +611,10 @@ async def test_presenter_reports_waiting_for_approval_ask_user_question_without_
 def test_build_permission_prompt_includes_specific_bash_command() -> None:
     prompt = build_permission_prompt(tool_name="Bash", tool_input={"command": "pwd"})
 
-    assert prompt == "权限请求\n工具: Bash\n命令: pwd\n\n请点击下方按钮选择允许或拒绝。"
+    assert "🔐 权限请求" in prompt
+    assert "工具: Bash" in prompt
+    assert "`pwd`" in prompt
+    assert "请点击下方按钮选择允许或拒绝。" in prompt
 
 
 def test_build_permission_prompt_falls_back_to_compact_json_preview() -> None:
diff --git a/tests/test_task_service.py b/tests/test_task_service.py
index d1370c6..c05df40 100644
--- a/tests/test_task_service.py
+++ b/tests/test_task_service.py
@@ -148,6 +148,101 @@ async def test_task_cancel_call(tmp_path: Path) -> None:
     assert status.status == TaskStatus.CANCELED
 
 
+@pytest.mark.asyncio
+async def test_mark_stream_timeout_marks_running_task_final(tmp_path: Path) -> None:
+    adapter = StubAdapter(
+        events=[
+            CLIEvent(type=EventType.STARTED, task_id="x"),
+        ]
+    )
+    service = TaskService(
+        settings=make_settings(tmp_path),
+        task_store=MemoryTaskStore(),
+        session_service=make_file_backed_session_service(tmp_path),
+        cli_factory=StubFactory(adapter),
+        semaphore=asyncio.Semaphore(2),
+    )
+
+    result = await service.create_and_run(user_id=1, provider="claude", prompt="hi", workdir=str(tmp_path))
+    _ = [event async for event in result.events]
+
+    marked = await service.mark_stream_timeout(result.task.task_id, user_id=1, reason="watchdog")
+
+    assert marked is True
+    status = await service.get_status(result.task.task_id, user_id=1)
+    assert status is not None
+    assert status.status == TaskStatus.TIMEOUT
+    assert status.failure_reason == "watchdog"
+    assert status.ended_at is not None
+
+
+@pytest.mark.asyncio
+async def test_late_events_do_not_override_stream_timeout(tmp_path: Path) -> None:
+    release = asyncio.Event()
+
+    class LateExitAdapter(StubAdapter):
+        def __init__(self) -> None:
+            super().__init__([])
+
+        async def run(self, task, *, terminal_key=None, interactive=False, claude_session_id=None):
+            yield CLIEvent(type=EventType.STARTED, task_id=task.task_id)
+            await release.wait()
+            yield CLIEvent(type=EventType.EXITED, task_id=task.task_id, exit_code=0)
+
+    service = TaskService(
+        settings=make_settings(tmp_path),
+        task_store=MemoryTaskStore(),
+        session_service=make_file_backed_session_service(tmp_path),
+        cli_factory=StubFactory(LateExitAdapter()),
+        semaphore=asyncio.Semaphore(2),
+    )
+
+    result = await service.create_and_run(user_id=1, provider="claude", prompt="hi", workdir=str(tmp_path))
+    events = result.events.__aiter__()
+    first = await anext(events)
+    assert first.type == EventType.STARTED
+
+    marked = await service.mark_stream_timeout(result.task.task_id, user_id=1, reason="watchdog")
+    release.set()
+    remaining = [event async for event in events]
+
+    assert marked is True
+    assert remaining == []
+    status = await service.get_status(result.task.task_id, user_id=1)
+    assert status is not None
+    assert status.status == TaskStatus.TIMEOUT
+    assert status.failure_reason == "watchdog"
+
+
+@pytest.mark.asyncio
+async def test_mark_stream_timeout_and_cancel_calls_adapter_after_marking_final(tmp_path: Path) -> None:
+    adapter = StubAdapter(
+        events=[
+            CLIEvent(type=EventType.STARTED, task_id="x"),
+        ]
+    )
+    service = TaskService(
+        settings=make_settings(tmp_path),
+        task_store=MemoryTaskStore(),
+        session_service=make_file_backed_session_service(tmp_path),
+        cli_factory=StubFactory(adapter),
+        semaphore=asyncio.Semaphore(2),
+    )
+
+    result = await service.create_and_run(user_id=1, provider="claude", prompt="hi", workdir=str(tmp_path))
+    _ = [event async for event in result.events]
+
+    marked, canceled = await service.mark_stream_timeout_and_cancel(result.task.task_id, user_id=1, reason="watchdog")
+
+    assert marked is True
+    assert canceled is True
+    assert adapter.cancel_called is True
+    status = await service.get_status(result.task.task_id, user_id=1)
+    assert status is not None
+    assert status.status == TaskStatus.TIMEOUT
+    assert status.failure_reason == "watchdog"
+
+
 @pytest.mark.asyncio
 async def test_output_limit_truncate(tmp_path: Path) -> None:
     adapter = StubAdapter(
@@ -1732,6 +1827,41 @@ async def respond_to_permission(self, *, tool_use_id: str, decision: str, reason
     assert hook_socket_server.calls == [("tool-1", "allow", None)]
 
 
+@pytest.mark.asyncio
+async def test_permission_service_uses_configured_lock_registry(tmp_path: Path) -> None:
+    from app.config.settings import Settings
+
+    adapter = StubAdapter(events=[])
+    settings = Settings.model_validate(
+        {
+            "TG_BOT_TOKEN": "token",
+            "TG_ALLOWED_USER_IDS": "1",
+            "DEFAULT_PROVIDER": "claude_code",
+            "DEFAULT_TIMEOUT_SEC": 10,
+            "MAX_CONCURRENT_TASKS": 2,
+            "CLAUDE_CLI_BIN": "claude",
+            "CODEX_CLI_BIN": "codex",
+            "GEMINI_CLI_BIN": "gemini",
+            "ALLOWED_WORKDIRS": str(tmp_path),
+            "TASK_OUTPUT_CHAR_LIMIT": 20,
+            "PERMISSION_LOCK_TTL_SEC": 120,
+            "LOCK_CLEANUP_INTERVAL_SEC": 30,
+            "LOCK_CLEANUP_BATCH_SIZE": 10,
+        }
+    )
+    service = TaskService(
+        settings=settings,
+        task_store=MemoryTaskStore(),
+        session_service=make_file_backed_session_service(tmp_path),
+        cli_factory=StubFactory(adapter),
+        semaphore=asyncio.Semaphore(2),
+    )
+    registry = service._permission_service._permission_locks
+    assert registry._ttl_sec == 120
+    assert registry._cleanup_interval_sec == 30
+    assert registry._cleanup_batch_size == 10
+
+
 @pytest.mark.asyncio
 async def test_answer_pending_user_question_option_collects_multi_question_answers_and_sends_to_tmux(tmp_path: Path) -> None:
     adapter = StubAdapter(events=[])
diff --git a/tests/test_tmux_commands.py b/tests/test_tmux_commands.py
new file mode 100644
index 0000000..07f8a73
--- /dev/null
+++ b/tests/test_tmux_commands.py
@@ -0,0 +1,40 @@
+"""Unit tests for TmuxCommandMixin."""
+
+from app.adapters.process.tmux_commands import TmuxCommandMixin
+
+
+class _TestableCommandMixin(TmuxCommandMixin):
+    def __init__(self, claude_cli_bin: str = "claude"):
+        self._claude_cli_bin = claude_cli_bin
+
+
+class TestBuildInteractiveClaudeResumeCommand:
+    def test_appends_resume_flag_with_session_id(self):
+        mixin = _TestableCommandMixin()
+        result = mixin._build_interactive_claude_resume_command(workdir="/tmp/project", session_id="abc-123-def")
+        assert "--resume" in result
+        assert "abc-123-def" in result
+
+    def test_contains_same_base_as_interactive_command(self):
+        mixin = _TestableCommandMixin()
+        base = mixin._build_interactive_claude_command(workdir="/tmp/project")
+        resume = mixin._build_interactive_claude_resume_command(workdir="/tmp/project", session_id="sess-1")
+        # The resume command should start with the same prefix as the base command
+        assert resume.startswith(base)
+
+    def test_session_id_with_special_chars_is_quoted(self):
+        mixin = _TestableCommandMixin()
+        result = mixin._build_interactive_claude_resume_command(workdir="/tmp/project", session_id="id with spaces")
+        # shlex.quote should wrap the session_id
+        assert "'id with spaces'" in result
+
+    def test_workdir_is_resolved(self):
+        mixin = _TestableCommandMixin()
+        result = mixin._build_interactive_claude_resume_command(workdir="/tmp/project", session_id="sess-1")
+        assert "cd " in result
+        assert "/tmp/project" in result
+
+    def test_custom_claude_bin(self):
+        mixin = _TestableCommandMixin(claude_cli_bin="/usr/local/bin/claude")
+        result = mixin._build_interactive_claude_resume_command(workdir="/tmp/project", session_id="sess-1")
+        assert "/usr/local/bin/claude" in result
diff --git a/tests/test_tmux_runner.py b/tests/test_tmux_runner.py
index 5a559c0..ad55002 100644
--- a/tests/test_tmux_runner.py
+++ b/tests/test_tmux_runner.py
@@ -1717,3 +1717,43 @@ async def fake_terminate(session_name: str) -> bool:
     assert await runner.cancel("task-cancel") is False
     assert meta.cancel_requested is True
     assert any(record.message == "tmux task cancel requested" for record in caplog.records)
+
+
+@pytest.mark.asyncio
+async def test_persistent_session_locks_are_ref_counted_and_cleaned(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+    current = {"now": 0.0}
+
+    def clock() -> float:
+        return current["now"]
+
+    def advance(seconds: float) -> None:
+        current["now"] += seconds
+
+    runner = TmuxRunner(
+        data_dir=str(tmp_path),
+        session_lock_ttl_sec=1,
+        lock_cleanup_interval_sec=1,
+        lock_cleanup_batch_size=10,
+        lock_clock=clock,
+    )
+
+    async def fake_run_task(*, meta, timeout_sec: int, env, workdir: str, command: str):
+        yield CLIEvent(type=EventType.STARTED, task_id=meta.task_id)
+        yield CLIEvent(type=EventType.EXITED, task_id=meta.task_id, exit_code=0)
+
+    monkeypatch.setattr(runner, "_run_task", fake_run_task)
+
+    for idx in range(3):
+        events = await _collect_events(
+            runner.run(
+                task_id=f"task-{idx}",
+                argv=["echo", "ok"],
+                workdir=str(tmp_path),
+                timeout_sec=10,
+                terminal_key=f"user-{idx}",
+            )
+        )
+        assert events[-1].type == EventType.EXITED
+        advance(2.0)
+
+    assert len(runner._session_locks) <= 1
diff --git a/tests/test_upload_queue.py b/tests/test_upload_queue.py
new file mode 100644
index 0000000..90c4347
--- /dev/null
+++ b/tests/test_upload_queue.py
@@ -0,0 +1,168 @@
+import asyncio
+
+import pytest
+
+from app.services.upload_queue import UploadQueueManager
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_accepts_and_drains_fifo() -> None:
+    manager = UploadQueueManager(max_files_per_user=3, max_bytes_per_user=100)
+
+    first = await manager.enqueue(user_id=1, filename="first.txt", data=b"first")
+    second = await manager.enqueue(user_id=1, filename="second.txt", data=b"second")
+
+    assert first.accepted is True
+    assert second.accepted is True
+    assert await manager.queued_count(user_id=1) == 2
+
+    drained = await manager.drain(user_id=1)
+
+    assert [(item.filename, item.data, item.size_bytes) for item in drained] == [
+        ("first.txt", b"first", 5),
+        ("second.txt", b"second", 6),
+    ]
+    assert await manager.queued_count(user_id=1) == 0
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_rejects_when_file_count_limit_reached() -> None:
+    manager = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=100)
+
+    await manager.enqueue(user_id=1, filename="first.txt", data=b"first")
+    result = await manager.enqueue(user_id=1, filename="second.txt", data=b"second")
+
+    assert result.accepted is False
+    assert "队列已满" in result.reason
+    assert await manager.queued_count(user_id=1) == 1
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_rejects_when_byte_limit_exceeded() -> None:
+    manager = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=3)
+
+    await manager.enqueue(user_id=1, filename="first.txt", data=b"ab")
+    result = await manager.enqueue(user_id=1, filename="second.txt", data=b"cd")
+
+    assert result.accepted is False
+    assert "队列容量" in result.reason
+    assert await manager.queued_count(user_id=1) == 1
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_disabled_with_zero_file_limit() -> None:
+    manager = UploadQueueManager(max_files_per_user=0, max_bytes_per_user=100)
+
+    result = await manager.enqueue(user_id=1, filename="first.txt", data=b"first")
+
+    assert result.accepted is False
+    assert "上传队列已关闭" in result.reason
+    assert await manager.queued_count(user_id=1) == 0
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_rejected_first_upload_by_byte_limit_leaves_no_user_state() -> None:
+    manager = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=3)
+    user_id = 42
+
+    result = await manager.enqueue(user_id=user_id, filename="too-large.txt", data=b"abcd")
+
+    assert result.accepted is False
+    assert "队列容量" in result.reason
+    assert await manager.queued_count(user_id=user_id) == 0
+    assert user_id not in manager._queues
+    assert user_id not in manager._byte_totals
+    assert await manager.drain(user_id=user_id) == []
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_concurrent_enqueues_respect_file_limit() -> None:
+    manager = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=100)
+
+    first_result, second_result = await asyncio.gather(
+        manager.enqueue(user_id=1, filename="first.txt", data=b"first"),
+        manager.enqueue(user_id=1, filename="second.txt", data=b"second"),
+    )
+
+    assert [first_result.accepted, second_result.accepted].count(True) == 1
+    assert [first_result.accepted, second_result.accepted].count(False) == 1
+
+    drained = await manager.drain(user_id=1)
+
+    assert len(drained) == 1
+    assert drained[0].filename in {"first.txt", "second.txt"}
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_expires_old_items_before_drain() -> None:
+    now = 1000.0
+
+    def clock() -> float:
+        return now
+
+    manager = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=10, ttl_sec=5.0, clock=clock)
+    await manager.enqueue(user_id=1, filename="old.txt", data=b"12345")
+
+    now = 1006.0
+
+    assert await manager.queued_count(user_id=1) == 0
+    assert await manager.drain(user_id=1) == []
+
+    result = await manager.enqueue(user_id=1, filename="new.txt", data=b"1234567890")
+    assert result.accepted is True
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_drain_prunes_expired_items_without_count_first() -> None:
+    now = 1000.0
+
+    def clock() -> float:
+        return now
+
+    manager = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=5, ttl_sec=5.0, clock=clock)
+    await manager.enqueue(user_id=1, filename="old.txt", data=b"12345")
+
+    now = 1006.0
+
+    assert await manager.drain(user_id=1) == []
+    assert 1 not in manager._queues
+    assert 1 not in manager._byte_totals
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_background_cleanup_expires_without_user_operation() -> None:
+    now = 1000.0
+
+    def clock() -> float:
+        return now
+
+    manager = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=5, ttl_sec=5.0, cleanup_interval_sec=0.01, clock=clock)
+    await manager.enqueue(user_id=1, filename="old.txt", data=b"12345")
+
+    now = 1006.0
+    await manager.start_cleanup()
+    try:
+        await asyncio.sleep(0.05)
+    finally:
+        await manager.stop_cleanup()
+
+    assert 1 not in manager._queues
+    assert 1 not in manager._byte_totals
+
+
+@pytest.mark.asyncio
+async def test_upload_queue_enqueue_prunes_expired_items_before_limits() -> None:
+    now = 1000.0
+
+    def clock() -> float:
+        return now
+
+    manager = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=5, ttl_sec=5.0, clock=clock)
+    await manager.enqueue(user_id=1, filename="old.txt", data=b"12345")
+
+    now = 1006.0
+    result = await manager.enqueue(user_id=1, filename="new.txt", data=b"12345")
+
+    assert result.accepted is True
+    drained = await manager.drain(user_id=1)
+    assert [(item.filename, item.data, item.size_bytes) for item in drained] == [("new.txt", b"12345", 5)]
diff --git a/tests/test_upload_queue_drain.py b/tests/test_upload_queue_drain.py
new file mode 100644
index 0000000..6d8a08c
--- /dev/null
+++ b/tests/test_upload_queue_drain.py
@@ -0,0 +1,148 @@
+"""Tests for upload queue drain after task completion."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from app.bot.handlers.file_upload import process_pending_uploads, schedule_pending_upload_processing
+from app.domain.file_models import FileUploadResult, FileValidationError
+from app.services.upload_queue import UploadQueueManager
+from tests.fakes.telegram import DummyMessage
+
+
+@pytest.fixture
+def upload_queue() -> UploadQueueManager:
+    return UploadQueueManager(max_files_per_user=5, max_bytes_per_user=100 * 1024 * 1024)
+
+
+@pytest.fixture
+def file_receiver() -> AsyncMock:
+    return AsyncMock()
+
+
+@pytest.fixture
+def session_service(tmp_path: Path) -> AsyncMock:
+    svc = AsyncMock()
+    svc.get = AsyncMock(return_value=SimpleNamespace(workdir=str(tmp_path)))
+    return svc
+
+
+@pytest.mark.asyncio
+async def test_drain_processes_all_queued_files(
+    upload_queue: UploadQueueManager,
+    file_receiver: AsyncMock,
+    session_service: AsyncMock,
+) -> None:
+    """After task completion, drain processes all queued files in FIFO order."""
+    await upload_queue.enqueue(user_id=42, filename="first.py", data=b"aaa")
+    await upload_queue.enqueue(user_id=42, filename="second.py", data=b"bbb")
+
+    file_receiver.receive_file = AsyncMock(
+        side_effect=[
+            FileUploadResult(filename="first.py", size_bytes=3, path=Path("/tmp/work/.tg-uploads/42/first.py")),
+            FileUploadResult(filename="second.py", size_bytes=3, path=Path("/tmp/work/.tg-uploads/42/second.py")),
+        ]
+    )
+    message = DummyMessage(user_id=42)
+
+    await process_pending_uploads(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=42,
+    )
+
+    assert file_receiver.receive_file.await_count == 2
+    calls = file_receiver.receive_file.await_args_list
+    assert calls[0].kwargs["filename"] == "first.py"
+    assert calls[1].kwargs["filename"] == "second.py"
+    assert message.answers == [
+        "✅ 文件已接收: first.py (3 B)",
+        "✅ 文件已接收: second.py (3 B)",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_failed_file_does_not_block_subsequent(
+    upload_queue: UploadQueueManager,
+    file_receiver: AsyncMock,
+    session_service: AsyncMock,
+) -> None:
+    """A failed file in the queue should not prevent subsequent files from processing."""
+    await upload_queue.enqueue(user_id=42, filename="fail.py", data=b"bad")
+    await upload_queue.enqueue(user_id=42, filename="good.py", data=b"ok")
+
+    file_receiver.receive_file = AsyncMock(
+        side_effect=[
+            FileValidationError(filename="fail.py", reason="invalid content"),
+            FileUploadResult(filename="good.py", size_bytes=2, path=Path("/tmp/work/.tg-uploads/42/good.py")),
+        ]
+    )
+    message = DummyMessage(user_id=42)
+
+    await process_pending_uploads(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=42,
+    )
+
+    assert file_receiver.receive_file.await_count == 2
+    assert message.answers == [
+        "❌ 文件被拒绝: fail.py\n原因: invalid content",
+        "✅ 文件已接收: good.py (2 B)",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_drain_no_op_when_queue_empty(
+    upload_queue: UploadQueueManager,
+    file_receiver: AsyncMock,
+    session_service: AsyncMock,
+) -> None:
+    """Drain does nothing when there are no queued files."""
+    message = DummyMessage(user_id=42)
+
+    await process_pending_uploads(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=42,
+    )
+
+    file_receiver.receive_file.assert_not_awaited()
+    assert message.answers == []
+
+
+@pytest.mark.asyncio
+async def test_schedule_creates_background_task(
+    upload_queue: UploadQueueManager,
+    file_receiver: AsyncMock,
+    session_service: AsyncMock,
+) -> None:
+    """schedule_pending_upload_processing creates an asyncio task that drains the queue."""
+    await upload_queue.enqueue(user_id=42, filename="scheduled.py", data=b"data")
+
+    file_receiver.receive_file = AsyncMock(
+        return_value=FileUploadResult(filename="scheduled.py", size_bytes=4, path=Path("/tmp/work/.tg-uploads/42/scheduled.py"))
+    )
+    message = DummyMessage(user_id=42)
+
+    task = schedule_pending_upload_processing(
+        message,
+        file_receiver=file_receiver,
+        session_service=session_service,
+        upload_queue=upload_queue,
+        user_id=42,
+    )
+    await task
+
+    file_receiver.receive_file.assert_awaited_once()
+    assert message.answers == ["✅ 文件已接收: scheduled.py (4 B)"]
diff --git a/tests/test_upload_queue_manager.py b/tests/test_upload_queue_manager.py
new file mode 100644
index 0000000..50a0675
--- /dev/null
+++ b/tests/test_upload_queue_manager.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+
+from app.services.upload_queue_manager import UploadQueueManager
+
+
+def test_enqueue_success() -> None:
+    mgr = UploadQueueManager(max_files_per_user=3, max_bytes_per_user=1000)
+    ok, msg = mgr.enqueue(1, "a.txt", b"hello", 5)
+    assert ok is True
+    assert msg == "queued"
+
+
+def test_enqueue_disabled_when_max_files_zero() -> None:
+    mgr = UploadQueueManager(max_files_per_user=0, max_bytes_per_user=0)
+    ok, msg = mgr.enqueue(1, "a.txt", b"hello", 5)
+    assert ok is False
+    assert msg == "queuing disabled"
+
+
+def test_enqueue_rejects_when_file_count_exceeded() -> None:
+    mgr = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=10000)
+    mgr.enqueue(1, "a.txt", b"a", 1)
+    mgr.enqueue(1, "b.txt", b"b", 1)
+    ok, msg = mgr.enqueue(1, "c.txt", b"c", 1)
+    assert ok is False
+    assert "queue full" in msg
+
+
+def test_enqueue_rejects_when_byte_limit_exceeded() -> None:
+    mgr = UploadQueueManager(max_files_per_user=10, max_bytes_per_user=10)
+    mgr.enqueue(1, "a.txt", b"x" * 8, 8)
+    ok, msg = mgr.enqueue(1, "b.txt", b"x" * 5, 5)
+    assert ok is False
+    assert "byte limit" in msg
+
+
+def test_drain_returns_fifo_order() -> None:
+    mgr = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=1000)
+    mgr.enqueue(1, "first.txt", b"1", 1)
+    mgr.enqueue(1, "second.txt", b"2", 1)
+    mgr.enqueue(1, "third.txt", b"3", 1)
+
+    items = mgr.drain(1)
+    assert [i.filename for i in items] == ["first.txt", "second.txt", "third.txt"]
+
+
+def test_drain_clears_queue() -> None:
+    mgr = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=1000)
+    mgr.enqueue(1, "a.txt", b"a", 1)
+    mgr.drain(1)
+
+    # Queue should be empty, so new enqueue succeeds and drain returns nothing extra
+    assert mgr.drain(1) == []
+
+
+def test_drain_empty_user() -> None:
+    mgr = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=1000)
+    assert mgr.drain(99) == []
+
+
+def test_is_full_when_at_limit() -> None:
+    mgr = UploadQueueManager(max_files_per_user=2, max_bytes_per_user=10000)
+    assert mgr.is_full(1) is False
+    mgr.enqueue(1, "a.txt", b"a", 1)
+    assert mgr.is_full(1) is False
+    mgr.enqueue(1, "b.txt", b"b", 1)
+    assert mgr.is_full(1) is True
+
+
+def test_is_full_when_disabled() -> None:
+    mgr = UploadQueueManager(max_files_per_user=0, max_bytes_per_user=0)
+    assert mgr.is_full(1) is True
+
+
+def test_per_user_isolation() -> None:
+    mgr = UploadQueueManager(max_files_per_user=1, max_bytes_per_user=100)
+    ok1, _ = mgr.enqueue(1, "a.txt", b"a", 1)
+    ok2, _ = mgr.enqueue(2, "b.txt", b"b", 1)
+    assert ok1 is True
+    assert ok2 is True
+
+    # User 1 is full, user 2 is also full (limit is 1)
+    assert mgr.is_full(1) is True
+    assert mgr.is_full(2) is True
+
+
+def test_drain_resets_byte_total() -> None:
+    mgr = UploadQueueManager(max_files_per_user=5, max_bytes_per_user=10)
+    mgr.enqueue(1, "a.txt", b"x" * 8, 8)
+    mgr.drain(1)
+    # After drain, byte total is reset so we can enqueue again
+    ok, _ = mgr.enqueue(1, "b.txt", b"x" * 8, 8)
+    assert ok is True
diff --git a/tests/unit/test_session_actions.py b/tests/unit/test_session_actions.py
new file mode 100644
index 0000000..5463f71
--- /dev/null
+++ b/tests/unit/test_session_actions.py
@@ -0,0 +1,141 @@
+"""Unit tests for session_actions callback handlers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from aiogram import Router
+from aiogram.types import CallbackQuery, Message, User
+
+from app.domain.hook_models import HookEvent
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.external_session_binder import ExternalSessionBinder
+from app.services.external_session_discovery import ExternalSessionDiscoveryService
+from app.bot.handlers.session_actions import register_session_action_handlers, _resolve_session_id
+
+
+@pytest.fixture
+def discovery() -> ExternalSessionDiscoveryService:
+    return ExternalSessionDiscoveryService()
+
+
+@pytest.fixture
+def binding_store(tmp_path: Path) -> ExternalBindingStore:
+    return ExternalBindingStore(data_dir=tmp_path)
+
+
+@pytest.fixture
+def binder(discovery: ExternalSessionDiscoveryService, binding_store: ExternalBindingStore, tmp_path: Path) -> ExternalSessionBinder:
+    return ExternalSessionBinder(
+        discovery=discovery,
+        binding_store=binding_store,
+        projects_dir=tmp_path / "projects",
+    )
+
+
+class TestResolveSessionId:
+    def test_resolves_from_discovery(self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder) -> None:
+        event = HookEvent(session_id="abcdef1234567890full", cwd="/tmp", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        resolved, error = _resolve_session_id("abcdef1234567890", discovery, binder)
+        assert resolved == "abcdef1234567890full"
+        assert error is None
+
+    def test_not_found(self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder) -> None:
+        resolved, error = _resolve_session_id("nonexistent", discovery, binder)
+        assert resolved is None
+        assert error == "Session not found"
+
+
+class TestSessionSelectHandler:
+    @pytest.mark.asyncio
+    async def test_select_unbound_session_shows_bind_button(
+        self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder
+    ) -> None:
+        session_id = "abcdef1234567890full"
+        event = HookEvent(session_id=session_id, cwd="/home/user/proj", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        router = Router()
+        register_session_action_handlers(router, discovery=discovery, binder=binder)
+
+        # Simulate callback
+        callback = AsyncMock(spec=CallbackQuery)
+        callback.data = f"sess:select:{session_id[:16]}"
+        callback.from_user = MagicMock(spec=User)
+        callback.from_user.id = 42
+        callback.message = AsyncMock(spec=Message)
+
+        resolved, error = _resolve_session_id(session_id[:16], discovery, binder)
+        assert resolved == session_id
+        assert error is None
+
+        # Verify the session is unbound (no binding exists)
+        binding = binder._binding_store.get_binding(session_id)
+        assert binding is None  # unbound, so "绑定" button should show
+
+    @pytest.mark.asyncio
+    async def test_select_bound_session_shows_unbind_button(
+        self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder
+    ) -> None:
+        session_id = "abcdef1234567890full"
+        user_id = 42
+        event = HookEvent(session_id=session_id, cwd="/home/user/proj", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        # Bind the session
+        result = await binder.bind(user_id=user_id, session_id=session_id)
+        assert result.success
+
+        # Now check binding state
+        binding = binder._binding_store.get_binding(session_id)
+        assert binding is not None
+        assert binding.user_id == user_id  # bound to user, so "取消绑定" button should show
+
+
+class TestSessionBindHandler:
+    @pytest.mark.asyncio
+    async def test_bind_delegates_to_binder(self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder) -> None:
+        session_id = "abcdef1234567890full"
+        event = HookEvent(session_id=session_id, cwd="/home/user/proj", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        result = await binder.bind(user_id=42, session_id=session_id)
+        assert result.success is True
+        assert result.session_id == session_id
+
+    @pytest.mark.asyncio
+    async def test_bind_already_bound_fails(self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder) -> None:
+        session_id = "abcdef1234567890full"
+        event = HookEvent(session_id=session_id, cwd="/home/user/proj", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        await binder.bind(user_id=42, session_id=session_id)
+        # Session removed from discovery after bind, so second bind won't find it
+        result = await binder.bind(user_id=99, session_id=session_id)
+        assert result.success is False
+
+
+class TestSessionUnbindHandler:
+    @pytest.mark.asyncio
+    async def test_unbind_delegates_to_binder(self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder) -> None:
+        session_id = "abcdef1234567890full"
+        event = HookEvent(session_id=session_id, cwd="/home/user/proj", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        await binder.bind(user_id=42, session_id=session_id)
+        result = await binder.unbind(user_id=42, session_id=session_id)
+        assert result.success is True
+
+    @pytest.mark.asyncio
+    async def test_unbind_wrong_user_fails(self, discovery: ExternalSessionDiscoveryService, binder: ExternalSessionBinder) -> None:
+        session_id = "abcdef1234567890full"
+        event = HookEvent(session_id=session_id, cwd="/home/user/proj", event="PreToolUse", status="running")
+        discovery.record_event(event)
+
+        await binder.bind(user_id=42, session_id=session_id)
+        result = await binder.unbind(user_id=99, session_id=session_id)
+        assert result.success is False
diff --git a/tests/unit/test_session_ownership_resolver.py b/tests/unit/test_session_ownership_resolver.py
new file mode 100644
index 0000000..50f1f20
--- /dev/null
+++ b/tests/unit/test_session_ownership_resolver.py
@@ -0,0 +1,181 @@
+"""Unit tests for SessionOwnershipResolver."""
+
+from __future__ import annotations
+
+import pytest
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+from app.domain.external_session_models import ExternalBinding, SessionOrigin
+from app.domain.models import SessionContext
+from app.services.external_binding_store import ExternalBindingStore
+from app.services.session_ownership_resolver import SessionOwnershipResolver
+
+
+def _make_context(
+    *,
+    user_id: int = 1,
+    claude_session_id: str | None = None,
+    terminal_id: str | None = None,
+    workdir: str = "/home/user/project",
+) -> SessionContext:
+    return SessionContext(
+        user_id=user_id,
+        session_id="internal-id",
+        provider="claude_code",
+        workdir=workdir,
+        terminal_mode=terminal_id is not None,
+        terminal_id=terminal_id,
+        claude_session_id=claude_session_id,
+    )
+
+
+@pytest.fixture
+def binding_store(tmp_path: Path) -> ExternalBindingStore:
+    return ExternalBindingStore(data_dir=tmp_path)
+
+
+@pytest.fixture
+def session_service() -> AsyncMock:
+    svc = AsyncMock()
+    svc.list_all = AsyncMock(return_value=[])
+    return svc
+
+
+@pytest.fixture
+def resolver(session_service: AsyncMock, binding_store: ExternalBindingStore) -> SessionOwnershipResolver:
+    return SessionOwnershipResolver(
+        session_service=session_service,
+        binding_store=binding_store,
+    )
+
+
+@pytest.mark.asyncio
+async def test_resolve_tmux_owned(resolver: SessionOwnershipResolver, session_service: AsyncMock) -> None:
+    """Session with terminal_id and matching claude_session_id is tmux-owned."""
+    session_service.list_all.return_value = [
+        _make_context(user_id=42, claude_session_id="sess-abc", terminal_id="term-1"),
+    ]
+
+    result = await resolver.resolve("sess-abc")
+
+    assert result.ownership_state == "owned"
+    assert result.origin == SessionOrigin.TMUX
+    assert result.owner_user_id == 42
+
+
+@pytest.mark.asyncio
+async def test_resolve_external_bound(
+    resolver: SessionOwnershipResolver,
+    session_service: AsyncMock,
+    binding_store: ExternalBindingStore,
+) -> None:
+    """Session in binding store is externally bound."""
+    session_service.list_all.return_value = []
+    binding_store.save_binding(
+        ExternalBinding(
+            session_id="sess-ext",
+            user_id=99,
+            cwd="/tmp/work",
+            bound_at=datetime.now(timezone.utc),
+            jsonl_path=None,
+        )
+    )
+
+    result = await resolver.resolve("sess-ext")
+
+    assert result.ownership_state == "bound"
+    assert result.origin == SessionOrigin.EXTERNAL
+    assert result.owner_user_id == 99
+
+
+@pytest.mark.asyncio
+async def test_resolve_unbound(resolver: SessionOwnershipResolver, session_service: AsyncMock) -> None:
+    """Session with no ownership is unbound."""
+    session_service.list_all.return_value = []
+
+    result = await resolver.resolve("sess-unknown")
+
+    assert result.ownership_state == "unbound"
+    assert result.origin == SessionOrigin.EXTERNAL
+    assert result.owner_user_id is None
+
+
+@pytest.mark.asyncio
+async def test_tmux_priority_over_binding(
+    resolver: SessionOwnershipResolver,
+    session_service: AsyncMock,
+    binding_store: ExternalBindingStore,
+) -> None:
+    """Tmux ownership takes priority over external binding."""
+    session_service.list_all.return_value = [
+        _make_context(user_id=10, claude_session_id="sess-both", terminal_id="term-x"),
+    ]
+    binding_store.save_binding(
+        ExternalBinding(
+            session_id="sess-both",
+            user_id=20,
+            cwd="/tmp",
+            bound_at=datetime.now(timezone.utc),
+            jsonl_path=None,
+        )
+    )
+
+    result = await resolver.resolve("sess-both")
+
+    assert result.ownership_state == "owned"
+    assert result.origin == SessionOrigin.TMUX
+    assert result.owner_user_id == 10
+
+
+@pytest.mark.asyncio
+async def test_no_workdir_matching_without_terminal_id(
+    resolver: SessionOwnershipResolver,
+    session_service: AsyncMock,
+) -> None:
+    """Session without terminal_id is NOT matched even if claude_session_id matches.
+
+    This ensures workdir-based matching is never used for external sessions.
+    A context without terminal_id means it wasn't launched via tmux.
+    """
+    session_service.list_all.return_value = [
+        _make_context(
+            user_id=5,
+            claude_session_id="sess-no-term",
+            terminal_id=None,
+            workdir="/same/workdir",
+        ),
+    ]
+
+    result = await resolver.resolve("sess-no-term")
+
+    assert result.ownership_state == "unbound"
+    assert result.owner_user_id is None
+
+
+@pytest.mark.asyncio
+async def test_is_tmux_owned_true(resolver: SessionOwnershipResolver, session_service: AsyncMock) -> None:
+    session_service.list_all.return_value = [
+        _make_context(user_id=1, claude_session_id="sess-t", terminal_id="term-1"),
+    ]
+
+    assert await resolver.is_tmux_owned("sess-t") is True
+
+
+@pytest.mark.asyncio
+async def test_is_tmux_owned_false_no_terminal(resolver: SessionOwnershipResolver, session_service: AsyncMock) -> None:
+    session_service.list_all.return_value = [
+        _make_context(user_id=1, claude_session_id="sess-t", terminal_id=None),
+    ]
+
+    assert await resolver.is_tmux_owned("sess-t") is False
+
+
+@pytest.mark.asyncio
+async def test_is_tmux_owned_false_no_match(resolver: SessionOwnershipResolver, session_service: AsyncMock) -> None:
+    session_service.list_all.return_value = [
+        _make_context(user_id=1, claude_session_id="other-sess", terminal_id="term-1"),
+    ]
+
+    assert await resolver.is_tmux_owned("sess-t") is False