Compare commits

..

2 Commits

Author SHA1 Message Date
Yuyao Huang (Sam)
1b2bb8cdc2 refactor: rename pty_process to cc_runner
The module has long since moved away from PTY technology, using
claude -p with --output-format stream-json and --resume instead.
Rename to cc_runner to accurately reflect what it does.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:55:19 +08:00
Yuyao Huang (Sam)
a278ece348 feat: 添加可配置的命令前缀以避免与Claude Code冲突
引入可配置的COMMAND_PREFIX参数,默认设置为"//"以避免与Claude Code的"/"命令冲突
修改相关文件以支持新的命令前缀,包括配置解析、路由逻辑和命令处理
更新帮助文档和提示信息以反映新的命令前缀
2026-03-30 00:11:41 +08:00
10 changed files with 91 additions and 62 deletions

View File

@ -55,7 +55,7 @@ PhoneWork uses a **Router + Host Client** architecture that supports both single
| `orchestrator/agent.py` | LangChain agent with per-user history + direct/smart mode + direct Q&A | | `orchestrator/agent.py` | LangChain agent with per-user history + direct/smart mode + direct Q&A |
| `orchestrator/tools.py` | Tools: session management, shell, file ops, web search, scheduler, task status | | `orchestrator/tools.py` | Tools: session management, shell, file ops, web search, scheduler, task status |
| `agent/manager.py` | Session registry with persistence, idle timeout, and auto-background tasks | | `agent/manager.py` | Session registry with persistence, idle timeout, and auto-background tasks |
| `agent/pty_process.py` | Runs `claude -p` headlessly, manages session continuity via `--resume` | | `agent/cc_runner.py` | Runs `claude -p` headlessly, manages session continuity via `--resume` |
| `agent/task_runner.py` | Background task runner with Feishu notifications | | `agent/task_runner.py` | Background task runner with Feishu notifications |
| `agent/scheduler.py` | Reminder scheduler with persistence | | `agent/scheduler.py` | Reminder scheduler with persistence |
| `agent/audit.py` | Audit log of all interactions | | `agent/audit.py` | Audit log of all interactions |

View File

@ -315,7 +315,7 @@ SERVES_USERS:
**What the host client runs:** **What the host client runs:**
- Full `orchestrator/agent.py` (mailboy LLM, tool loop, per-user history, active session) - Full `orchestrator/agent.py` (mailboy LLM, tool loop, per-user history, active session)
- Full `orchestrator/tools.py` (CC, shell, file ops, web, scheduler — all local) - Full `orchestrator/tools.py` (CC, shell, file ops, web, scheduler — all local)
- `agent/manager.py`, `agent/pty_process.py`, `agent/task_runner.py` — unchanged - `agent/manager.py`, `agent/cc_runner.py`, `agent/task_runner.py` — unchanged
Task completion flow: Task completion flow:
- Background task finishes → host client pushes `TaskComplete` to router - Background task finishes → host client pushes `TaskComplete` to router
@ -495,7 +495,7 @@ PhoneWork/
├── agent/ # Part of host client (local execution) ├── agent/ # Part of host client (local execution)
│ ├── manager.py # Session registry │ ├── manager.py # Session registry
│ ├── pty_process.py # Claude Code runner │ ├── cc_runner.py # Claude Code runner
│ ├── task_runner.py # Background tasks │ ├── task_runner.py # Background tasks
│ ├── scheduler.py # Reminders │ ├── scheduler.py # Reminders
│ └── audit.py # Audit log │ └── audit.py # Audit log

View File

@ -10,7 +10,7 @@ from dataclasses import dataclass, field, asdict
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from agent.pty_process import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES from agent.cc_runner import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES
from agent.audit import log_interaction from agent.audit import log_interaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -12,7 +12,7 @@ from typing import Optional, Tuple
from agent.manager import manager from agent.manager import manager
from agent.scheduler import scheduler from agent.scheduler import scheduler
from agent.task_runner import task_runner from agent.task_runner import task_runner
from agent.pty_process import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE from agent.cc_runner import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from orchestrator.agent import agent from orchestrator.agent import agent
from orchestrator.tools import set_current_user, get_current_chat from orchestrator.tools import set_current_user, get_current_chat
@ -44,14 +44,18 @@ def _perm_label(mode: str) -> str:
def parse_command(text: str) -> Optional[Tuple[str, str]]: def parse_command(text: str) -> Optional[Tuple[str, str]]:
""" """
Parse a slash command from text. Parse a bot command from text.
Returns (command, args) or None if not a command. Returns (command, args) or None if not a command.
Commands must start with COMMAND_PREFIX (default "//").
""" """
from config import COMMAND_PREFIX
text = text.strip() text = text.strip()
if not text.startswith("/"): if not text.startswith(COMMAND_PREFIX):
return None return None
parts = text.split(None, 1) # Strip the prefix, then split into command word and args
cmd = parts[0].lower() body = text[len(COMMAND_PREFIX):]
parts = body.split(None, 1)
cmd = COMMAND_PREFIX + parts[0].lower()
args = parts[1] if len(parts) > 1 else "" args = parts[1] if len(parts) > 1 else ""
return (cmd, args) return (cmd, args)
@ -68,38 +72,39 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
logger.info("Command: %s args=%r user=...%s", cmd, args[:50], user_id[-8:]) logger.info("Command: %s args=%r user=...%s", cmd, args[:50], user_id[-8:])
# In ROUTER_MODE, only handle router-specific commands locally. # In ROUTER_MODE, only handle router-specific commands locally.
# Session commands (/status, /new, /close, etc.) fall through to node forwarding. # Session commands (//status, //new, //close, etc.) fall through to node forwarding.
from config import ROUTER_MODE from config import ROUTER_MODE, COMMAND_PREFIX
if ROUTER_MODE and cmd not in ("/nodes", "/node", "/help", "/h", "/?"): P = COMMAND_PREFIX
if ROUTER_MODE and cmd not in (P+"nodes", P+"node", P+"help", P+"h", P+"?"):
return None return None
set_current_user(user_id) set_current_user(user_id)
if cmd in ("/new", "/n"): if cmd in (P+"new", P+"n"):
return await _cmd_new(user_id, args) return await _cmd_new(user_id, args)
elif cmd in ("/status", "/list", "/ls", "/l"): elif cmd in (P+"status", P+"list", P+"ls", P+"l"):
return await _cmd_status(user_id) return await _cmd_status(user_id)
elif cmd in ("/close", "/c"): elif cmd in (P+"close", P+"c"):
return await _cmd_close(user_id, args) return await _cmd_close(user_id, args)
elif cmd in ("/switch", "/s"): elif cmd in (P+"switch", P+"s"):
return await _cmd_switch(user_id, args) return await _cmd_switch(user_id, args)
elif cmd == "/retry": elif cmd == P+"retry":
return await _cmd_retry(user_id) return await _cmd_retry(user_id)
elif cmd in ("/help", "/h", "/?"): elif cmd in (P+"help", P+"h", P+"?"):
return _cmd_help() return _cmd_help()
elif cmd == "/direct": elif cmd == P+"direct":
return _cmd_direct(user_id) return _cmd_direct(user_id)
elif cmd == "/smart": elif cmd == P+"smart":
return _cmd_smart(user_id) return _cmd_smart(user_id)
elif cmd == "/tasks": elif cmd == P+"tasks":
return _cmd_tasks() return _cmd_tasks()
elif cmd == "/shell": elif cmd == P+"shell":
return await _cmd_shell(args) return await _cmd_shell(args)
elif cmd == "/remind": elif cmd == P+"remind":
return await _cmd_remind(args) return await _cmd_remind(args)
elif cmd == "/perm": elif cmd == P+"perm":
return await _cmd_perm(user_id, args) return await _cmd_perm(user_id, args)
elif cmd in ("/nodes", "/node"): elif cmd in (P+"nodes", P+"node"):
return await _cmd_nodes(user_id, args) return await _cmd_nodes(user_id, args)
else: else:
return None return None
@ -431,23 +436,24 @@ async def _cmd_nodes(user_id: str, args: str) -> str:
def _cmd_help() -> str: def _cmd_help() -> str:
"""Show help.""" """Show help."""
return """**Commands:** from config import COMMAND_PREFIX as P
/new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session return f"""**Commands:** (prefix: `{P}`)
/status - Show sessions and current mode {P}new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session
/close [n] - Close session (active or by number) {P}status - Show sessions and current mode
/switch <n> - Switch to session by number {P}close [n] - Close session (active or by number)
/perm <mode> [conv_id] - Set permission mode (bypass/accept/plan) {P}switch <n> - Switch to session by number
/direct - Direct mode: messages Claude Code (no LLM overhead) {P}perm <mode> [conv_id] - Set permission mode (bypass/accept/plan)
/smart - Smart mode: messages LLM routing (default) {P}direct - Direct mode: messages Claude Code (no LLM overhead)
/shell <cmd> - Run shell command (bypasses LLM) {P}smart - Smart mode: messages LLM routing (default)
/remind <time> <msg> - Set reminder (e.g. /remind 10m check build) {P}shell <cmd> - Run shell command (bypasses LLM)
/tasks - List background tasks {P}remind <time> <msg> - Set reminder (e.g. {P}remind 10m check build)
/nodes - List connected host nodes {P}tasks - List background tasks
/node <name> - Switch active node {P}nodes - List connected host nodes
/retry - Retry last message {P}node <name> - Switch active node
/help - Show this help {P}retry - Retry last message
{P}help - Show this help
**Permission modes** (used by /perm and /new --perm): **Permission modes** (used by {P}perm and {P}new --perm):
bypass 跳过所有权限确认CC 自动执行一切操作默认 bypass 跳过所有权限确认CC 自动执行一切操作默认
适合受信任的沙盒环境自动化任务 适合受信任的沙盒环境自动化任务
accept 自动接受文件编辑 shell 命令仍需手动确认 accept 自动接受文件编辑 shell 命令仍需手动确认

View File

@ -25,6 +25,10 @@ METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "")
ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False) ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False)
ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "") ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "")
# Command prefix — the leader string that identifies bot commands.
# Default is "//" to avoid conflicts with Claude Code's own "/" commands.
COMMAND_PREFIX: str = _cfg.get("COMMAND_PREFIX", "//")
# Server configuration # Server configuration
PORT: int = _cfg.get("PORT", 8000) PORT: int = _cfg.get("PORT", 8000)

View File

@ -20,6 +20,10 @@ WORKING_DIR: C:/Users/yourname/projects
# Optional: 秘塔AI Search API key for web search # Optional: 秘塔AI Search API key for web search
METASO_API_KEY: your_metaso_api_key METASO_API_KEY: your_metaso_api_key
# Bot command prefix (default: "//")
# "//" avoids conflicts with Claude Code's own "/" commands.
# COMMAND_PREFIX: "//"
# Which Feishu users this node serves # Which Feishu users this node serves
# List of open_ids from Feishu # List of open_ids from Feishu
SERVES_USERS: SERVES_USERS:

View File

@ -21,7 +21,7 @@ from langchain_core.messages import (
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from agent.manager import manager from agent.manager import manager
from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR from config import OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, WORKING_DIR, COMMAND_PREFIX as _P
from orchestrator.tools import TOOLS, set_current_user from orchestrator.tools import TOOLS, set_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,27 +39,29 @@ Pass these names directly to `create_conversation` — the tool resolves them au
{active_session_line} {active_session_line}
Bot command prefix: {prefix}
## Tools — two distinct categories ## Tools — two distinct categories
### Bot control commands (use `run_command`) ### Bot control commands (use `run_command`)
`run_command` executes PhoneWork slash commands. Use it when the user asks to: `run_command` executes PhoneWork commands. Use it when the user asks to:
- Change permission mode: "切换到只读模式" run_command("/perm plan") - Change permission mode: "切换到只读模式" run_command("{prefix}perm plan")
- Close/switch sessions: "关掉第一个" run_command("/close 1") - Close/switch sessions: "关掉第一个" run_command("{prefix}close 1")
- Change routing mode: "切换到直连模式" run_command("/direct") - Change routing mode: "切换到直连模式" run_command("{prefix}direct")
- Set a reminder: "10分钟后提醒我" run_command("/remind 10m 提醒我") - Set a reminder: "10分钟后提醒我" run_command("{prefix}remind 10m 提醒我")
- Check status: "看看现在有哪些 session" run_command("/status") - Check status: "看看现在有哪些 session" run_command("{prefix}status")
- Any other /command the user would type manually - Any other command the user would type manually
Available bot commands (pass verbatim to run_command): Available bot commands (pass verbatim to run_command):
/new <dir> [msg] [--perm bypass|accept|plan] create session {prefix}new <dir> [msg] [--perm bypass|accept|plan] create session
/close [n|conv_id] close session {prefix}close [n|conv_id] close session
/switch <n> switch active session {prefix}switch <n> switch active session
/perm <mode> [conv_id] permission mode: bypass (default), accept, plan {prefix}perm <mode> [conv_id] permission mode: bypass (default), accept, plan
/direct direct mode (bypass LLM for CC messages) {prefix}direct direct mode (bypass LLM for CC messages)
/smart smart mode (LLM routing, default) {prefix}smart smart mode (LLM routing, default)
/status list sessions {prefix}status list sessions
/remind <Ns|Nm|Nh> <msg> one-shot reminder {prefix}remind <Ns|Nm|Nh> <msg> one-shot reminder
/tasks list background tasks {prefix}tasks list background tasks
### Host shell commands (use `run_shell`) ### Host shell commands (use `run_shell`)
`run_shell` executes shell commands on the host machine (git, ls, cat, pip, etc.). `run_shell` executes shell commands on the host machine (git, ls, cat, pip, etc.).
@ -69,7 +71,7 @@ NEVER use `run_shell` for bot control. NEVER use `run_command` for shell command
1. NEW session: call `create_conversation` with the project name/path. \ 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. 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. 2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above.
3. BOT CONTROL: call `run_command` with the appropriate slash command. 3. BOT CONTROL: call `run_command` with the appropriate command.
4. GENERAL QUESTIONS: answer directly do NOT create a session for simple Q&A. 4. GENERAL QUESTIONS: answer directly do NOT create a session for simple Q&A.
5. WEB / SEARCH: use `web` at most twice, then synthesize and reply. 5. WEB / SEARCH: use `web` at most twice, then synthesize and reply.
6. BACKGROUND TASKS: when a task starts, reply immediately do NOT poll `task_status`. 6. BACKGROUND TASKS: when a task starts, reply immediately do NOT poll `task_status`.
@ -119,6 +121,7 @@ class OrchestrationAgent:
working_dir=WORKING_DIR, working_dir=WORKING_DIR,
active_session_line=active_line, active_session_line=active_line,
today=today, today=today,
prefix=_P,
) )
def get_active_conv(self, user_id: str) -> Optional[str]: def get_active_conv(self, user_id: str) -> Optional[str]:
@ -143,6 +146,16 @@ class OrchestrationAgent:
logger.info(">>> user=...%s conv=%s msg=%r", short_uid, active_conv, text[:80]) logger.info(">>> user=...%s conv=%s msg=%r", short_uid, active_conv, text[:80])
logger.debug(" history_len=%d", len(self._history[user_id])) logger.debug(" history_len=%d", len(self._history[user_id]))
# Always handle bot commands first — even in passthrough mode.
# Bot commands must never reach Claude Code.
from config import COMMAND_PREFIX
if text.strip().startswith(COMMAND_PREFIX):
from bot.commands import handle_command
result = await handle_command(user_id, text)
if result is not None:
return result
logger.debug(" unknown command, falling through to LLM")
# Passthrough mode: if enabled and active session, bypass LLM # Passthrough mode: if enabled and active session, bypass LLM
if self._passthrough[user_id] and active_conv: if self._passthrough[user_id] and active_conv:
try: try:

View File

@ -76,9 +76,11 @@ async def route(user_id: str, chat_id: str, text: str) -> tuple[Optional[str], s
if len(online_nodes) == 1: if len(online_nodes) == 1:
return online_nodes[0].node_id, "Only one node available" return online_nodes[0].node_id, "Only one node available"
if text.strip().startswith("/"): from config import COMMAND_PREFIX
if text.strip().startswith(COMMAND_PREFIX):
cmd = text.strip().split()[0].lower() cmd = text.strip().split()[0].lower()
if cmd in ("/nodes", "/node", "/help", "/h", "/?"): meta_cmds = {COMMAND_PREFIX + s for s in ("nodes", "node", "help", "h", "?")}
if cmd in meta_cmds:
return "meta", "Meta command" return "meta", "Meta command"
# Session commands: forward to active node directly (no LLM call needed) # Session commands: forward to active node directly (no LLM call needed)
active = registry.get_active_node(user_id) active = registry.get_active_node(user_id)

View File

@ -51,7 +51,7 @@ def mock_run_claude():
Default return value is a short CC-style output string. Default return value is a short CC-style output string.
""" """
mock = AsyncMock(return_value="Claude Code: task complete.") mock = AsyncMock(return_value="Claude Code: task complete.")
with patch("agent.pty_process.run_claude", mock), \ with patch("agent.cc_runner.run_claude", mock), \
patch("agent.manager.run_claude", mock): patch("agent.manager.run_claude", mock):
yield mock yield mock