PhoneWork/agent/manager.py
Yuyao Huang (Sam) 0eb29f2dcc feat: 初始化项目基础结构
添加项目基础文件和目录结构,包括:
- 初始化空包目录(bot/agent/orchestrator)
- 配置文件(config.py)和示例(keyring.example.yaml)
- 依赖文件(requirements.txt)
- 主程序入口(main.py)
- 调试脚本(debug_test.py)
- 文档说明(README.md)
- Git忽略文件(.gitignore)
- 核心功能模块(pty_process/manager/handler/feishu等)
2026-03-28 07:44:44 +08:00

129 lines
4.2 KiB
Python

"""Session registry: tracks active project sessions by conversation ID."""
from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from agent.pty_process import run_claude
logger = logging.getLogger(__name__)
IDLE_TIMEOUT = 30 * 60 # 30 minutes in seconds
@dataclass
class Session:
conv_id: str
cwd: str
# Stable UUID passed to `claude --session-id` so CC owns the history
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
last_activity: float = field(default_factory=lambda: asyncio.get_event_loop().time())
# True after the first message has been sent (so we know to use --resume)
started: bool = False
def touch(self) -> None:
self.last_activity = asyncio.get_event_loop().time()
class SessionManager:
"""Registry of active Claude Code project sessions."""
def __init__(self) -> None:
self._sessions: Dict[str, Session] = {}
self._lock = asyncio.Lock()
self._reaper_task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def start(self) -> None:
loop = asyncio.get_event_loop()
self._reaper_task = loop.create_task(self._reaper_loop())
async def stop(self) -> None:
if self._reaper_task:
self._reaper_task.cancel()
async with self._lock:
self._sessions.clear()
async def create(self, conv_id: str, working_dir: str) -> Session:
"""Register a new session for the given working directory."""
async with self._lock:
session = Session(conv_id=conv_id, cwd=working_dir)
self._sessions[conv_id] = session
logger.info(
"Created session %s (cc_session_id=%s) in %s",
conv_id, session.cc_session_id, working_dir,
)
return session
async def send(self, conv_id: str, message: str) -> str:
"""
Run claude -p with the message in the session's directory.
- First message: uses --session-id <uuid> to establish the CC session.
- Subsequent messages: uses --resume <uuid> so CC has full history.
"""
async with self._lock:
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
session.touch()
cwd = session.cwd
cc_session_id = session.cc_session_id
first_message = not session.started
if first_message:
session.started = True
output = await run_claude(
message,
cwd=cwd,
cc_session_id=cc_session_id,
resume=not first_message,
)
return output
async def close(self, conv_id: str) -> bool:
"""Remove a session. Returns True if it existed."""
async with self._lock:
if conv_id not in self._sessions:
return False
del self._sessions[conv_id]
logger.info("Closed session %s", conv_id)
return True
def list_sessions(self) -> list[dict]:
return [
{"conv_id": s.conv_id, "cwd": s.cwd, "cc_session_id": s.cc_session_id}
for s in self._sessions.values()
]
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _reaper_loop(self) -> None:
while True:
await asyncio.sleep(60)
await self._reap_idle()
async def _reap_idle(self) -> None:
now = asyncio.get_event_loop().time()
async with self._lock:
to_close = [
cid for cid, s in self._sessions.items()
if (now - s.last_activity) > IDLE_TIMEOUT
]
for cid in to_close:
del self._sessions[cid]
logger.info("Reaped idle session %s", cid)
# Module-level singleton
manager = SessionManager()