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:
Yuyao Huang (Sam) 2026-03-28 07:44:44 +08:00
commit 0eb29f2dcc
16 changed files with 981 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@ -0,0 +1,72 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# Environment variables and secrets
.env
.env.*
*.env
keyring.yaml
secrets.yaml
secrets.json
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.project
.pydevproject
.settings/
# Claude settings
.claude/
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# MyPy
.mypy_cache/
.dmypy.json
dmypy.json
# Ruff
.ruff_cache/

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# PhoneWork
Feishu bot integration with Claude Code CLI.
## Architecture
- **Agent CLI**: Claude Code (print mode)
- **Chat Server**: FastAPI
- **Client**: Feishu bot API (long-connection)
## Setup
### 1. Feishu App
Create app at https://open.feishu.cn:
- Enable **Bot** capability
- Enable **long-connection event subscription** (no public URL needed)
- Get `FEISHU_APP_ID` and `FEISHU_APP_SECRET`
### 2. LLM Endpoint
Configure OpenAI-compatible endpoint:
- `OPENAI_BASE_URL`
- `OPENAI_API_KEY`
- `OPENAI_MODEL`
### 3. Claude Code CLI
- Install and authenticate `claude` command
- Ensure available in PATH
### 4. Configuration
```bash
cp keyring.example.yaml keyring.yaml
# Edit keyring.yaml with your credentials
```
### 5. Run
```bash
pip install -r requirements.txt
python main.py
```
## Requirements
| Item | Notes |
|---|---|
| Python 3.11+ | Required |
| Feishu App | Bot + long-connection enabled |
| OpenAI-compatible LLM | API endpoint and key |
| Claude Code CLI | Installed + authenticated |

1
agent/__init__.py Normal file
View File

@ -0,0 +1 @@
# empty init

128
agent/manager.py Normal file
View File

@ -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()

86
agent/pty_process.py Normal file
View File

@ -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

1
bot/__init__.py Normal file
View File

@ -0,0 +1 @@
# empty init

79
bot/feishu.py Normal file
View File

@ -0,0 +1,79 @@
"""Feishu API client: send messages back to users."""
from __future__ import annotations
import logging
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
)
from config import FEISHU_APP_ID, FEISHU_APP_SECRET
logger = logging.getLogger(__name__)
# Max Feishu text message length
MAX_TEXT_LEN = 4000
def _make_client() -> lark.Client:
return (
lark.Client.builder()
.app_id(FEISHU_APP_ID)
.app_secret(FEISHU_APP_SECRET)
.log_level(lark.LogLevel.WARNING)
.build()
)
_client = _make_client()
def _truncate(text: str) -> str:
if len(text) <= MAX_TEXT_LEN:
return text
return text[: MAX_TEXT_LEN - 20] + "\n...[truncated]"
async def send_text(receive_id: str, receive_id_type: str, text: str) -> None:
"""
Send a plain-text message to a Feishu chat or user.
Args:
receive_id: chat_id or open_id depending on receive_id_type.
receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id".
text: message content.
"""
import json as _json
content = _json.dumps({"text": _truncate(text)}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type("text")
.content(content)
.build()
)
.build()
)
import asyncio
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: _client.im.v1.message.create(request),
)
if not response.success():
logger.error(
"Feishu send_text failed: code=%s msg=%s",
response.code,
response.msg,
)
else:
logger.debug("Sent message to %s (%s)", receive_id, receive_id_type)

130
bot/handler.py Normal file
View File

@ -0,0 +1,130 @@
"""Feishu event handler using lark-oapi long-connection (WebSocket) mode."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
from bot.feishu import send_text
from config import FEISHU_APP_ID, FEISHU_APP_SECRET
from orchestrator.agent import agent
logger = logging.getLogger(__name__)
# Keep a reference to the running event loop so sync callbacks can schedule coroutines
_main_loop: asyncio.AbstractEventLoop | None = None
def _handle_message(data: P2ImMessageReceiveV1) -> None:
"""
Synchronous callback invoked by the lark-oapi SDK on every incoming message.
We schedule async work onto the main event loop.
"""
try:
message = data.event.message
sender = data.event.sender
# Log raw event for debugging
logger.info(
"RAW event: type=%r content=%r chat_type=%r",
getattr(message, "message_type", None),
getattr(message, "content", None),
getattr(message, "chat_type", None),
)
# Only handle text messages
if message.message_type != "text":
logger.info("Skipping non-text message_type=%r", message.message_type)
return
# Extract fields
chat_id: str = message.chat_id
raw_content: str = message.content or "{}"
content_obj = json.loads(raw_content)
text: str = content_obj.get("text", "").strip()
# Strip @mentions (Feishu injects "@bot_name " at start of group messages)
import re
text = re.sub(r"@\S+\s*", "", text).strip()
open_id: str = ""
if sender and sender.sender_id:
open_id = sender.sender_id.open_id or ""
if not text:
logger.info("Empty text after stripping, ignoring")
return
logger.info("Received message from %s in %s: %r", open_id, chat_id, text[:80])
# Use open_id as user identifier for per-user history in the orchestrator
user_id = open_id or chat_id
if _main_loop is None:
logger.error("Main event loop not set; cannot process message")
return
# Schedule async processing; fire-and-forget
asyncio.run_coroutine_threadsafe(
_process_message(user_id, chat_id, text),
_main_loop,
)
except Exception:
logger.exception("Error in _handle_message")
async def _process_message(user_id: str, chat_id: str, text: str) -> None:
"""Run the orchestration agent and send the reply back to Feishu."""
try:
reply = await agent.run(user_id, text)
if reply:
await send_text(chat_id, "chat_id", reply)
except Exception:
logger.exception("Error processing message for user %s", user_id)
def _handle_any(data: lark.CustomizedEvent) -> None:
"""Catch-all handler to log any event the SDK receives."""
logger.info("RAW CustomizedEvent: %s", lark.JSON.marshal(data)[:500])
def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks."""
handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any)
.build()
)
return handler
def start_websocket_client(loop: asyncio.AbstractEventLoop) -> None:
"""
Start the lark-oapi WebSocket long-connection client in a background thread.
Must be called after the asyncio event loop is running.
"""
global _main_loop
_main_loop = loop
event_handler = build_event_handler()
ws_client = lark.ws.Client(
FEISHU_APP_ID,
FEISHU_APP_SECRET,
event_handler=event_handler,
log_level=lark.LogLevel.INFO,
)
def _run() -> None:
logger.info("Starting Feishu long-connection client...")
ws_client.start() # blocks until disconnected
thread = threading.Thread(target=_run, daemon=True, name="feishu-ws")
thread.start()
logger.info("Feishu WebSocket thread started")

19
config.py Normal file
View File

@ -0,0 +1,19 @@
import yaml
from pathlib import Path
_CONFIG_PATH = Path(__file__).parent / "keyring.yaml"
def _load() -> dict:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
_cfg = _load()
FEISHU_APP_ID: str = _cfg["FEISHU_APP_ID"]
FEISHU_APP_SECRET: str = _cfg["FEISHU_APP_SECRET"]
OPENAI_BASE_URL: str = _cfg["OPENAI_BASE_URL"]
OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"]
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()

31
debug_test.py Normal file
View File

@ -0,0 +1,31 @@
"""Quick diagnostic script — run interactively to trace what's failing."""
import asyncio
import logging
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
async def main():
# Step 1: Can the agent call tools at all?
print("\n=== Step 1: Agent tool-calling test ===")
from orchestrator.agent import agent
reply = await agent.run(
"test_user",
"请在 C:\\Users\\hyuyao\\Documents\\PhoneWork 目录开一个新的 Claude Code 会话conv_id 随便取。"
)
print("Agent reply:", reply)
# Step 2: Check manager sessions
print("\n=== Step 2: Active sessions ===")
from agent.manager import manager
print(manager.list_sessions())
# Step 3: If session exists, try sending a message
sessions = manager.list_sessions()
if sessions:
cid = sessions[0]["conv_id"]
print(f"\n=== Step 3: Send 'hello' to session {cid} ===")
out = await manager.send(cid, "hello")
print("Output:", out[:500])
else:
print("No sessions — tool was not called or failed.")
asyncio.run(main())

6
keyring.example.yaml Normal file
View File

@ -0,0 +1,6 @@
FEISHU_APP_ID: your_feishu_app_id
FEISHU_APP_SECRET: your_feishu_app_secret
OPENAI_BASE_URL: https://api.openai.com/v1/
OPENAI_API_KEY: your_openai_api_key
OPENAI_MODEL: gpt-4
WORKING_DIR: "/path/to/working/directory"

59
main.py Normal file
View File

@ -0,0 +1,59 @@
"""PhoneWork entry point: FastAPI app + Feishu long-connection client."""
from __future__ import annotations
import asyncio
import logging
import uvicorn
from fastapi import FastAPI
from agent.manager import manager
from bot.handler import start_websocket_client
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(title="PhoneWork", version="0.1.0")
@app.get("/health")
async def health() -> dict:
sessions = manager.list_sessions()
return {"status": "ok", "active_sessions": len(sessions)}
@app.get("/sessions")
async def list_sessions() -> list:
return manager.list_sessions()
@app.on_event("startup")
async def startup_event() -> None:
# Start the session manager's idle-reaper
await manager.start()
# Start the Feishu WebSocket client in a background thread,
# passing the running event loop so async work can be scheduled
loop = asyncio.get_event_loop()
start_websocket_client(loop)
logger.info("PhoneWork started")
@app.on_event("shutdown")
async def shutdown_event() -> None:
await manager.stop()
logger.info("PhoneWork shut down")
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=False,
log_level="info",
)

1
orchestrator/__init__.py Normal file
View File

@ -0,0 +1 @@
# empty init

150
orchestrator/agent.py Normal file
View File

@ -0,0 +1,150 @@
"""LangChain orchestration agent backed by ZhipuAI (OpenAI-compatible API).
Uses LangChain 1.x tool-calling pattern: bind_tools + manual agentic loop.
"""
from __future__ import annotations
import json
import logging
from collections import defaultdict
from typing import Dict, List, Optional
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from langchain_openai import ChatOpenAI
from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR
from orchestrator.tools import TOOLS
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_TEMPLATE = """You are PhoneWork, an AI assistant that helps users control Claude Code \
from their phone via Feishu (飞书).
You manage Claude Code sessions. Each session has a conv_id and runs in a project directory.
Base working directory: {working_dir}
Users refer to projects by subfolder name (e.g. "todo_app") or relative path. \
Pass these names directly to `create_conversation` the tool resolves them automatically.
{active_session_line}
Your responsibilities:
1. NEW session: call `create_conversation` with the project name/path. \
If the user's message also contains a task, pass it as `initial_message` too.
2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above.
3. List sessions: call `list_conversations`.
4. Close session: call `close_conversation`.
Guidelines:
- Relay Claude Code's output verbatim.
- If no active session and the user sends a task without naming a directory, ask them which project.
- Keep your own words brief let Claude Code's output speak.
- Reply in the same language the user uses (Chinese or English).
"""
MAX_ITERATIONS = 10
_TOOL_MAP = {t.name: t for t in TOOLS}
class OrchestrationAgent:
"""Per-user agent with conversation history and active session tracking."""
def __init__(self) -> None:
llm = ChatOpenAI(
base_url=OPENAI_BASE_URL,
api_key=OPENAI_API_KEY,
model=OPENAI_MODEL,
temperature=0.0,
)
self._llm_with_tools = llm.bind_tools(TOOLS)
# user_id -> list[BaseMessage]
self._history: Dict[str, List[BaseMessage]] = defaultdict(list)
# user_id -> most recently active conv_id
self._active_conv: Dict[str, Optional[str]] = defaultdict(lambda: None)
def _build_system_prompt(self, user_id: str) -> str:
conv_id = self._active_conv[user_id]
if conv_id:
active_line = f"ACTIVE SESSION: conv_id={conv_id!r} ← use this for all follow-up messages"
else:
active_line = "ACTIVE SESSION: none"
return SYSTEM_PROMPT_TEMPLATE.format(
working_dir=WORKING_DIR,
active_session_line=active_line,
)
async def run(self, user_id: str, text: str) -> str:
"""Process a user message and return the agent's reply."""
messages: List[BaseMessage] = (
[SystemMessage(content=self._build_system_prompt(user_id))]
+ self._history[user_id]
+ [HumanMessage(content=text)]
)
reply = ""
try:
for _ in range(MAX_ITERATIONS):
ai_msg: AIMessage = await self._llm_with_tools.ainvoke(messages)
messages.append(ai_msg)
if not ai_msg.tool_calls:
reply = ai_msg.content or ""
break
for tc in ai_msg.tool_calls:
tool_name = tc["name"]
tool_args = tc["args"]
tool_id = tc["id"]
tool_obj = _TOOL_MAP.get(tool_name)
if tool_obj is None:
result = f"Unknown tool: {tool_name}"
else:
try:
result = await tool_obj.arun(tool_args)
except Exception as exc:
result = f"Tool error: {exc}"
# If a session was just created, record it as the active session
if tool_name == "create_conversation":
try:
data = json.loads(result)
if "conv_id" in data:
self._active_conv[user_id] = data["conv_id"]
logger.info(
"Active session for %s set to %s",
user_id, data["conv_id"],
)
except Exception:
pass
messages.append(
ToolMessage(content=str(result), tool_call_id=tool_id)
)
else:
reply = "[Max iterations reached]"
except Exception as exc:
logger.exception("Agent error for user %s", user_id)
reply = f"[Error] {exc}"
# Update history
self._history[user_id].append(HumanMessage(content=text))
self._history[user_id].append(AIMessage(content=reply))
if len(self._history[user_id]) > 40:
self._history[user_id] = self._history[user_id][-40:]
return reply
# Module-level singleton
agent = OrchestrationAgent()

162
orchestrator/tools.py Normal file
View File

@ -0,0 +1,162 @@
"""LangChain tools that bridge the orchestration agent to Claude Code PTY sessions."""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Optional, Type
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from agent.manager import manager
from config import WORKING_DIR
def _resolve_dir(working_dir: str) -> Path:
"""
Resolve working_dir to an absolute path under WORKING_DIR.
Rules:
- Absolute paths are used as-is (but must stay within WORKING_DIR for safety).
- Relative paths / bare names are joined onto WORKING_DIR.
- The resolved directory is created if it doesn't exist.
"""
p = Path(working_dir)
if not p.is_absolute():
p = WORKING_DIR / p
p = p.resolve()
# Safety: must be inside WORKING_DIR
try:
p.relative_to(WORKING_DIR)
except ValueError:
raise ValueError(
f"Directory {p} is outside the allowed base {WORKING_DIR}. "
"Please use a subfolder name or a path inside the working directory."
)
p.mkdir(parents=True, exist_ok=True)
return p
# ---------------------------------------------------------------------------
# Input schemas
# ---------------------------------------------------------------------------
class CreateConversationInput(BaseModel):
working_dir: str = Field(
...,
description=(
"The project directory. Can be a subfolder name (e.g. 'todo_app'), "
"a relative path (e.g. 'projects/todo_app'), or a full absolute path. "
"Relative names are resolved under the configured base working directory."
),
)
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
class SendToConversationInput(BaseModel):
conv_id: str = Field(..., description="Conversation ID returned by create_conversation")
message: str = Field(..., description="Message / instruction to send to Claude Code")
class CloseConversationInput(BaseModel):
conv_id: str = Field(..., description="Conversation ID to close")
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
class CreateConversationTool(BaseTool):
name: str = "create_conversation"
description: str = (
"Spawn a new Claude Code session in the given working directory. "
"Returns a conv_id that must be used for subsequent messages. "
"Use this when the user wants to start a new task in a specific directory."
)
args_schema: Type[BaseModel] = CreateConversationInput
def _run(self, working_dir: str, initial_message: Optional[str] = None) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, working_dir: str, initial_message: Optional[str] = None) -> str:
try:
resolved = _resolve_dir(working_dir)
except ValueError as exc:
return json.dumps({"error": str(exc)})
conv_id = str(uuid.uuid4())[:8]
await manager.create(conv_id, str(resolved))
result: dict = {
"conv_id": conv_id,
"working_dir": str(resolved),
}
if initial_message:
output = await manager.send(conv_id, initial_message)
result["response"] = output
else:
result["status"] = "Session created. Send a message to start working."
return json.dumps(result, ensure_ascii=False)
class SendToConversationTool(BaseTool):
name: str = "send_to_conversation"
description: str = (
"Send a message to an existing Claude Code session and return its response. "
"Use this for follow-up messages in an ongoing session."
)
args_schema: Type[BaseModel] = SendToConversationInput
def _run(self, conv_id: str, message: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, conv_id: str, message: str) -> str:
try:
output = await manager.send(conv_id, message)
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
except KeyError:
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
class ListConversationsTool(BaseTool):
name: str = "list_conversations"
description: str = "List all currently active Claude Code sessions."
def _run(self) -> str:
raise NotImplementedError("Use async version")
async def _arun(self) -> str:
sessions = manager.list_sessions()
if not sessions:
return "No active sessions."
return json.dumps(sessions, ensure_ascii=False, indent=2)
class CloseConversationTool(BaseTool):
name: str = "close_conversation"
description: str = "Close and terminate an active Claude Code session."
args_schema: Type[BaseModel] = CloseConversationInput
def _run(self, conv_id: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, conv_id: str) -> str:
closed = await manager.close(conv_id)
if closed:
return f"Session {conv_id!r} closed."
return f"Session {conv_id!r} not found."
# Module-level tool list for easy import
TOOLS = [
CreateConversationTool(),
SendToConversationTool(),
ListConversationsTool(),
CloseConversationTool(),
]

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi>=0.111.0
uvicorn>=0.29.0
lark-oapi>=1.3.0
langchain>=0.2.0
langchain-openai>=0.1.0
langchain-community>=0.2.0
pywinpty>=2.0.0
pyyaml>=6.0.0