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等)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# empty init
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Headless Claude Code runner using `claude -p` (print/non-interactive mode)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
MAX_OUTPUT_CHARS = 8000 # truncate very long responses before sending to Feishu
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
return ANSI_ESCAPE.sub("", text)
|
||||
|
||||
|
||||
async def run_claude(
|
||||
prompt: str,
|
||||
cwd: str,
|
||||
cc_session_id: str | None = None,
|
||||
resume: bool = False,
|
||||
timeout: float = 300.0,
|
||||
) -> str:
|
||||
"""
|
||||
Run `claude -p <prompt>` in the given directory and return the output.
|
||||
|
||||
Args:
|
||||
prompt: The message/instruction to pass to Claude Code.
|
||||
cwd: Working directory for the subprocess.
|
||||
cc_session_id: Stable UUID for the Claude Code session.
|
||||
- First call: passed as --session-id to establish the session.
|
||||
- Subsequent calls: passed as --resume so CC has full history.
|
||||
resume: If True, use --resume instead of --session-id.
|
||||
timeout: Maximum seconds to wait before giving up.
|
||||
"""
|
||||
base_args = [
|
||||
"--dangerously-skip-permissions",
|
||||
"-p", prompt,
|
||||
]
|
||||
|
||||
if cc_session_id:
|
||||
if resume:
|
||||
base_args = ["--resume", cc_session_id] + base_args
|
||||
else:
|
||||
base_args = ["--session-id", cc_session_id] + base_args
|
||||
|
||||
if sys.platform == "win32":
|
||||
# `claude` is a .cmd shim on Windows — must go through cmd /c
|
||||
args = ["cmd", "/c", "claude"] + base_args
|
||||
else:
|
||||
args = ["claude"] + base_args
|
||||
|
||||
logger.debug("Running: %s (cwd=%s)", " ".join(args[:6]), cwd)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return f"[Timed out after {timeout:.0f}s]"
|
||||
|
||||
output = stdout_bytes.decode("utf-8", errors="replace")
|
||||
output = strip_ansi(output).strip()
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
logger.warning("claude -p exited %d: %s", proc.returncode, err[:200])
|
||||
if not output:
|
||||
output = f"[Error exit {proc.returncode}] {err[:500]}"
|
||||
|
||||
if len(output) > MAX_OUTPUT_CHARS:
|
||||
output = output[:MAX_OUTPUT_CHARS] + "\n...[truncated]"
|
||||
|
||||
return output
|
||||
Reference in New Issue
Block a user