The file /root/projects/qq-shell/docs/superpowers/plans/2026-04-07-butler-shell-implementation.md has been updated successfully.

git add src/butler/security/sanitizer.py tests/test_sanitizer.py
git commit -m "feat: add sensitive information sanitizer"
```

---

## Chunk 3: TmuxWrapper 核心类

### Task 3.1: TmuxWrapper 基础功能

**Files:**
- Create: `src/butler/session/wrapper.py`
- Create: `tests/test_wrapper.py`
- Create: `tests/conftest.py`

- [ ] **Step 1: 创建 pytest fixtures**

```python
# tests/conftest.py
"""Pytest 全局 fixtures。"""

import pytest
from unittest.mock import Mock, patch
from butler.session.wrapper import TmuxWrapper

@pytest.fixture
def mock_subprocess():
    """Mock subprocess.run 用于测试 TmuxWrapper。"""
    with patch("subprocess.run") as mock_run:
        yield mock_run

@pytest.fixture
def tmux_wrapper():
    """创建 TmuxWrapper 实例。"""
    return TmuxWrapper(session_prefix="test_")

@pytest.fixture
def sample_session_state():
    """创建示例会话状态。"""
    from butler.session.state import SessionState, SessionMode
    return SessionState(
        session_id="test_user123",
        user_id="user123",
        pwd="/home/user",
        env={"HOME": "/home/user"},
    )
```

- [ ] **Step 2: 编写 TmuxWrapper 基础测试**

```python
# tests/test_wrapper.py
"""TmuxWrapper 单元测试。"""

import pytest
from unittest.mock import Mock, patch
from butler.session.wrapper import TmuxWrapper, PtyState, SessionMode

class TestTmuxWrapperBasic:
    """TmuxWrapper 基础功能测试。"""

    def test_create_session(self, mock_subprocess, tmux_wrapper):
        """测试创建 tmux session。"""
        mock_subprocess.return_value = Mock(returncode=0)

        session_name = tmux_wrapper.create_session("user123")

        assert session_name == "test_user123"
        mock_subprocess.assert_called_once()
        args = mock_subprocess.call_args[0][0]
        assert "tmux" in args
        assert "new-session" in args
        assert "test_user123" in args

    def test_session_exists_true(self, mock_subprocess, tmux_wrapper):
        """测试 session 存在检测 - 存在。"""
        mock_subprocess.return_value = Mock(returncode=0)

        result = tmux_wrapper.session_exists("test_user123")

        assert result is True

    def test_session_exists_false(self, mock_subprocess, tmux_wrapper):
        """测试 session 存在检测 - 不存在。"""
        mock_subprocess.return_value = Mock(returncode=1)

        result = tmux_wrapper.session_exists("test_user123")

        assert result is False

    def test_kill_session(self, mock_subprocess, tmux_wrapper):
        """测试销毁 session。"""
        tmux_wrapper.kill_session("test_user123")

        args = mock_subprocess.call_args[0][0]
        assert "kill-session" in args
        assert "test_user123" in args

    def test_send_keys_normal(self, mock_subprocess, tmux_wrapper):
        """测试发送普通命令。"""
        tmux_wrapper.send_keys("test_session", "ls -la", enter=True)

        # 应该调用两次：send-keys -l 和 Enter
        assert mock_subprocess.call_count == 2

    def test_send_keys_no_enter(self, mock_subprocess, tmux_wrapper):
        """测试发送不含 Enter 的命令。"""
        tmux_wrapper.send_keys("test_session", "ls -la", enter=False)

        # 应该只调用一次
        assert mock_subprocess.call_count == 1

    def test_send_keys_special_key(self, mock_subprocess, tmux_wrapper):
        """测试发送特殊键。"""
        tmux_wrapper.send_keys("test_session", "C-c")

        args = mock_subprocess.call_args[0][0]
        assert "C-c" in args

    def test_send_raw(self, mock_subprocess, tmux_wrapper):
        """测试发送原始数据。"""
        tmux_wrapper.send_raw("test_session", "raw data")

        args = mock_subprocess.call_args[0][0]
        assert "-l" in args
        assert "raw data" in args

    def test_capture_pane(self, mock_subprocess, tmux_wrapper):
        """测试抓取终端内容。"""
        mock_subprocess.return_value = Mock(stdout="line1\nline2\nline3", returncode=0)

        result = tmux_wrapper.capture_pane("test_session")

        assert "line1" in result
        args = mock_subprocess.call_args[0][0]
        assert "capture-pane" in args
```

- [ ] **Step 3: 运行测试确认失败**

```bash
pytest tests/test_wrapper.py -v
```

Expected: FAIL (模块不存在)

- [ ] **Step 4: 实现 TmuxWrapper 基础类**

```python
# src/butler/session/wrapper.py
"""Tmux 会话封装器。"""

import subprocess
from pathlib import Path
from typing import Final, Optional

from butler.session.state import PtyState, SessionMode

class TmuxWrapper:
    """tmux 会话封装器，处理底层 PTY 交互。"""

    # 交互模式进程名单
    INTERACTIVE_PROCESSES: Final[frozenset[str]] = frozenset([
        "vim", "nvim", "vi",
        "top", "htop", "btop",
        "less", "more", "man",
        "claude", "aider",
        "python", "node", "irb", "ipython",
    ])

    # 快捷键映射
    SPECIAL_KEYS: Final[dict[str, str]] = {
        "C-c": "C-c", "C-d": "C-d", "C-z": "C-z",
        "C-a": "C-a", "C-e": "C-e", "C-u": "C-u",
        "C-k": "C-k", "C-w": "C-w", "C-r": "C-r",
        "Esc": "Escape", "Tab": "Tab",
        "Up": "Up", "Down": "Down", "Left": "Left", "Right": "Right",
        "Enter": "Enter", "Space": "Space",
    }

    def __init__(self, session_prefix: str = "butler_"):
        """
        初始化 TmuxWrapper。

        Args:
            session_prefix: tmux session 名称前缀
        """
        self.session_prefix = session_prefix

    # ========== 会话生命周期 ==========

    def create_session(self, user_id: str) -> str:
        """
        为用户创建独立 tmux session。

        Args:
            user_id: 用户 ID

        Returns:
            创建的 session 名称
        """
        session_name = f"{self.session_prefix}{user_id}"
        subprocess.run(
            ["tmux", "new-session", "-d", "-s", session_name],
            check=True,
            capture_output=True,
        )
        return session_name

    def session_exists(self, session_name: str) -> bool:
        """
        检查 session 是否存在。

        Args:
            session_name: tmux session 名称

        Returns:
            存在返回 True
        """
        result = subprocess.run(
            ["tmux", "has-session", "-t", session_name],
            capture_output=True,
        )
        return result.returncode == 0

    def kill_session(self, session_name: str) -> None:
        """
        销毁 session。

        Args:
            session_name: tmux session 名称
        """
        subprocess.run(
            ["tmux", "kill-session", "-t", session_name],
            capture_output=True,
        )

    def get_or_create_session(self, user_id: str) -> str:
        """
        获取或创建用户 session。

        Args:
            user_id: 用户 ID

        Returns:
            session 名称
        """
        session_name = f"{self.session_prefix}{user_id}"
        if not self.session_exists(session_name):
            return self.create_session(user_id)
        return session_name

    # ========== PTY 交互 ==========

    def send_keys(
        self,
        session_name: str,
        keys: str,
        enter: bool = True,
    ) -> None:
        """
        向 session 发送按键。

        Args:
            session_name: tmux session 名称
            keys: 要发送的内容（支持快捷键如 "C-c"）
            enter: 是否在末尾追加 Enter
        """
        # 检查是否是特殊键
        if keys in self.SPECIAL_KEYS:
            tmux_key = self.SPECIAL_KEYS[keys]
            subprocess.run(
                ["tmux", "send-keys", "-t", session_name, tmux_key],
                capture_output=True,
            )
        else:
            # 字面模式发送，避免 tmux 解释特殊字符
            subprocess.run(
                ["tmux", "send-keys", "-l", "-t", session_name, keys],
                capture_output=True,
            )
            if enter:
                subprocess.run(
                    ["tmux", "send-keys", "-t", session_name, "Enter"],
                    capture_output=True,
                )

    def send_raw(self, session_name: str, data: str) -> None:
        """
        发送原始数据（不含 Enter），用于交互模式精确控制。

        Args:
            session_name: tmux session 名称
            data: 原始数据
        """
        subprocess.run(
            ["tmux", "send-keys", "-l", "-t", session_name, data],
            capture_output=True,
        )

    def capture_pane(
        self,
        session_name: str,
        as_ansi: bool = False,
        start_line: int = -100,
    ) -> str:
        """
        抓取终端屏幕内容。

        Args:
            session_name: tmux session 名称
            as_ansi: 是否保留 ANSI 转义码
            start_line: 起始行（负数表示从底部计算）

        Returns:
            终端屏幕文本
        """
        cmd = [
            "tmux", "capture-pane",
            "-t", session_name,
            "-p",
            "-S", str(start_line),
        ]
        if as_ansi:
            cmd.append("-e")
        result = subprocess.run(cmd, capture_output=True, text=True)
        return result.stdout

    # ========== 模式检测 ==========

    def get_active_process(self, session_name: str) -> Optional[str]:
        """
        获取 tmux 窗口中的活跃进程名。

        Args:
            session_name: tmux session 名称

        Returns:
            进程名或 None
        """
        # 获取 pane 的 PID
        result = subprocess.run(
            ["tmux", "list-panes", "-t", session_name, "-F", "#{pane_pid}"],
            capture_output=True,
            text=True,
        )
        pane_pids = result.stdout.strip().split("\n")
        if not pane_pids or not pane_pids[0]:
            return None

        pane_pid = pane_pids[0]

        # 获取该 PID 的子进程
        try:
            ps_result = subprocess.run(
                ["ps", "--ppid", pane_pid, "-o", "comm=", "--no-headers"],
                capture_output=True,
                text=True,
            )
            processes = ps_result.stdout.strip().split("\n")

            # 返回最上层的非 shell 进程
            for proc in processes:
                proc = proc.strip()
                if proc and proc not in ("bash", "zsh", "sh", "fish"):
                    return proc
        except Exception:
            pass

        return None

    def get_pty_state(self, session_name: str) -> PtyState:
        """
        检测 PTY 是否在等待输入。

        通过 /proc/<pid>/stat 的状态字段判断。

        Args:
            session_name: tmux session 名称

        Returns:
            PTY 状态
        """
        # 获取 pane PID
        result = subprocess.run(
            ["tmux", "list-panes", "-t", session_name, "-F", "#{pane_pid}"],
            capture_output=True,
            text=True,
        )
        pane_pids = result.stdout.strip().split("\n")
        if not pane_pids or not pane_pids[0]:
            return PtyState.UNKNOWN

        pane_pid = pane_pids[0]

        # 读取进程状态
        try:
            stat_path = Path(f"/proc/{pane_pid}/stat")
            if not stat_path.exists():
                return PtyState.UNKNOWN

            stat_content = stat_path.read_text()
            parts = stat_content.split()
            if len(parts) < 3:
                return PtyState.UNKNOWN

            state = parts[2]

            # 检查是否有活跃子进程
            children_path = Path(f"/proc/{pane_pid}/task/{pane_pid}/children")
            if children_path.exists():
                children = children_path.read_text().strip().split()
                for child_pid in children:
                    if child_pid:
                        child_stat = Path(f"/proc/{child_pid}/stat")
                        if child_stat.exists():
                            child_state = child_stat.read_text().split()[2]
                            if child_state == "R":
                                return PtyState.RUNNING

            if state in ("S", "D"):
                return PtyState.IDLE
            elif state == "T":
                return PtyState.BLOCKED
            else:
                return PtyState.RUNNING

        except (FileNotFoundError, PermissionError, ProcessLookupError):
            return PtyState.UNKNOWN

    def detect_mode(self, session_name: str) -> SessionMode:
        """
        混合模式检测：进程名单匹配 + PTY 状态检测。

        Args:
            session_name: tmux session 名称

        Returns:
            检测到的会话模式
        """
        active_proc = self.get_active_process(session_name)

        # 进程名单匹配
        if active_proc and active_proc in self.INTERACTIVE_PROCESSES:
            return SessionMode.INTERACTIVE

        return SessionMode.NATURAL_LANGUAGE
```

- [ ] **Step 5: 运行测试确认通过**

```bash
pytest tests/test_wrapper.py -v
```

Expected: PASS

- [ ] **Step 6: 提交 TmuxWrapper 基础类**

```bash
git add src/butler/session/wrapper.py tests/test_wrapper.py tests/conftest.py
git commit -m "feat: add TmuxWrapper core class"
```

---

### Task 3.2: TmuxWrapper 模式检测测试

**Files:**
- Modify: `tests/test_wrapper.py`

- [ ] **Step 1: 添加模式检测测试**

```python
# 追加到 tests/test_wrapper.py

class TestTmuxWrapperModeDetection:
    """TmuxWrapper 模式检测测试。"""

    def test_get_active_process_vim(self, mock_subprocess, tmux_wrapper):
        """测试检测 vim 进程。"""
        # Mock list-panes 返回 PID
        mock_subprocess.side_effect = [
            Mock(stdout="12345\n", returncode=0),  # list-panes
            Mock(stdout="vim\nbash\n", returncode=0),  # ps
        ]

        proc = tmux_wrapper.get_active_process("test_session")
        assert proc == "vim"

    def test_get_active_process_top(self, mock_subprocess, tmux_wrapper):
        """测试检测 top 进程。"""
        mock_subprocess.side_effect = [
            Mock(stdout="12345\n", returncode=0),
            Mock(stdout="top\nbash\n", returncode=0),
        ]

        proc = tmux_wrapper.get_active_process("test_session")
        assert proc == "top"

    def test_get_active_process_shell_only(self, mock_subprocess, tmux_wrapper):
        """测试只有 shell 时返回 None。"""
        mock_subprocess.side_effect = [
            Mock(stdout="12345\n", returncode=0),
            Mock(stdout="bash\n", returncode=0),
        ]

        proc = tmux_wrapper.get_active_process("test_session")
        assert proc is None

    def test_detect_mode_vim(self, mock_subprocess, tmux_wrapper):
        """测试 vim 触发交互模式。"""
        mock_subprocess.side_effect = [
            Mock(stdout="12345\n", returncode=0),
            Mock(stdout="vim\nbash\n", returncode=0),
        ]

        mode = tmux_wrapper.detect_mode("test_session")
        assert mode == SessionMode.INTERACTIVE

    def test_detect_mode_normal(self, mock_subprocess, tmux_wrapper):
        """测试普通命令保持自然语言模式。"""
        mock_subprocess.side_effect = [
            Mock(stdout="12345\n", returncode=0),
            Mock(stdout="bash\n", returncode=0),
        ]

        mode = tmux_wrapper.detect_mode("test_session")
        assert mode == SessionMode.NATURAL_LANGUAGE
```

- [ ] **Step 2: 运行测试确认通过**

```bash
pytest tests/test_wrapper.py::TestTmuxWrapperModeDetection -v
```

Expected: PASS

- [ ] **Step 3: 提交模式检测测试**

```bash
git add tests/test_wrapper.py
git commit -m "test: add mode detection tests for TmuxWrapper"
```

---

## Chunk 4: Skill 扩展系统

### Task 4.1: Skill 基类

**Files:**
- Create: `src/butler/skills/__init__.py`
- Create: `src/butler/skills/base.py`
- Create: `tests/test_skills.py`

- [ ] **Step 1: 编写 Skill 基类测试**

```python
# tests/test_skills.py
"""Skill 系统测试。"""

import pytest
from butler.skills.base import SkillBase, SkillContext, SkillResult, SkillRegistry

class MockSkill(SkillBase):
    """测试用 Skill。"""

    name = "mock"
    description = "Mock skill for testing"
    triggers = ["/mock", "/test"]

    async def execute(self, ctx: SkillContext) -> SkillResult:
        return SkillResult(success=True, output=f"Executed with args: {ctx.args}")

class TestSkillBase:
    """Skill 基类测试。"""

    def test_skill_matches_trigger(self):
        """测试触发器匹配。"""
        skill = MockSkill()
        assert skill.matches("/mock arg1") is True
        assert skill.matches("/test") is True
        assert skill.matches("/other") is False

    def test_skill_context_creation(self):
        """测试上下文创建。"""
        ctx = SkillContext(
            session_name="test_session",
            user_id="user123",
            session_state={},
            args=["arg1", "arg2"],
        )
        assert ctx.session_name == "test_session"
        assert ctx.args == ["arg1", "arg2"]

    def test_skill_result_creation(self):
        """测试结果创建。"""
        result = SkillResult(success=True, output="Done", metadata={"key": "value"})
        assert result.success is True
        assert result.output == "Done"

class TestSkillRegistry:
    """Skill 注册表测试。"""

    def test_register_skill(self):
        """测试注册 Skill。"""
        registry = SkillRegistry()
        skill = MockSkill()

        registry.register(skill)

        assert "mock" in registry._skills

    def test_find_matching_skill(self):
        """测试查找匹配的 Skill。"""
        registry = SkillRegistry()
        registry.register(MockSkill())

        found = registry.find_matching("/mock arg1")
        assert found is not None
        assert found.name == "mock"

    def test_find_no_matching_skill(self):
        """测试无匹配时返回 None。"""
        registry = SkillRegistry()
        registry.register(MockSkill())

        found = registry.find_matching("/unknown")
        assert found is None
```

- [ ] **Step 2: 运行测试确认失败**

```bash
pytest tests/test_skills.py -v
```

Expected: FAIL

- [ ] **Step 3: 实现 Skill 基类**

```python
# src/butler/skills/__init__.py
"""Skill 扩展系统。"""

from butler.skills.base import SkillBase, SkillContext, SkillResult, SkillRegistry

__all__ = ["SkillBase", "SkillContext", "SkillResult", "SkillRegistry"]
```

```python
# src/butler/skills/base.py
"""Skill 基类和注册表。"""

from abc import ABC, abstractmethod
from typing import Any, Optional

from pydantic import BaseModel, Field

class SkillContext(BaseModel):
    """Skill 执行上下文。"""

    session_name: str = Field(..., description="会话名称")
    user_id: str = Field(..., description="用户 ID")
    session_state: dict[str, Any] = Field(default_factory=dict, description="会话状态")
    args: list[str] = Field(default_factory=list, description="参数列表")

class SkillResult(BaseModel):
    """Skill 执行结果。"""

    success: bool = Field(..., description="是否成功")
    output: str = Field(..., description="输出内容")
    metadata: dict[str, Any] = Field(default_factory=dict, description="额外元数据")

class SkillBase(ABC):
    """Skill 基类。"""

    name: str
    description: str
    triggers: list[str] = []

    @abstractmethod
    async def execute(self, ctx: SkillContext) -> SkillResult:
        """
        执行 Skill。

        Args:
            ctx: 执行上下文

        Returns:
            执行结果
        """
        pass

    def matches(self, content: str) -> bool:
        """
        检查是否匹配触发条件。

        Args:
            content: 用户输入内容

        Returns:
            是否匹配
        """
        content_stripped = content.strip()
        for trigger in self.triggers:
            if content_stripped.startswith(trigger):
                return True
        return False

    def parse_args(self, content: str) -> list[str]:
        """
        从内容中解析参数。

        Args:
            content: 用户输入内容

        Returns:
            参数列表
        """
        content_stripped = content.strip()
        for trigger in self.triggers:
            if content_stripped.startswith(trigger):
                args_str = content_stripped[len(trigger):].strip()
                if args_str:
                    return args_str.split()
                return []
        return []

class SkillRegistry:
    """Skill 注册表。"""

    def __init__(self) -> None:
        self._skills: dict[str, SkillBase] = {}

    def register(self, skill: SkillBase) -> None:
        """
        注册 Skill。

        Args:
            skill: Skill 实例
        """
        self._skills[skill.name] = skill

    def get(self, name: str) -> Optional[SkillBase]:
        """
        获取指定名称的 Skill。

        Args:
            name: Skill 名称

        Returns:
            Skill 实例或 None
        """
        return self._skills.get(name)

    def find_matching(self, content: str) -> Optional[SkillBase]:
        """
        查找匹配内容的 Skill。

        Args:
            content: 用户输入内容

        Returns:
            匹配的 Skill 或 None
        """
        for skill in self._skills.values():
            if skill.matches(content):
                return skill
        return None

    def list_skills(self) -> list[str]:
        """
        列出所有注册的 Skill 名称。

        Returns:
            Skill 名称列表
        """
        return list(self._skills.keys())
```

- [ ] **Step 4: 运行测试确认通过**

```bash
pytest tests/test_skills.py -v
```

Expected: PASS

- [ ] **Step 5: 提交 Skill 基类**

```bash
git add src/butler/skills/ tests/test_skills.py
git commit -m "feat: add Skill extension system base"
```

---

### Task 4.2: LogSkill 实现

**Files:**
- Create: `src/butler/skills/log_skill.py`
- Create: `data/.gitkeep`

- [ ] **Step 1: 编写 LogSkill 测试**

追加到 `tests/test_skills.py`:

```python
import tempfile
import pytest
from pathlib import Path
from butler.skills.log_skill import LogSkill

class TestLogSkill:
    """LogSkill 测试。"""

    def test_log_skill_triggers(self):
        """测试触发器。"""
        skill = LogSkill(db_path=":memory:")
        assert skill.matches("/log") is True
        assert skill.matches("/history") is True
        assert skill.matches("/log recent") is True

    @pytest.mark.asyncio
    async def test_log_command(self):
        """测试记录命令。"""
        skill = LogSkill(db_path=":memory:")
        skill.log_command("user123", "test_session", "ls -la", "output")

        ctx = SkillContext(
            session_name="test_session",
            user_id="user123",
            session_state={},
            args=["recent", "1"],
        )
        result = await skill.execute(ctx)
        assert result.success is True
        assert "ls -la" in result.output

    @pytest.mark.asyncio
    async def test_search_commands(self):
        """测试搜索命令。"""
        skill = LogSkill(db_path=":memory:")
        skill.log_command("user123", "test_session", "vim file.txt", "")
        skill.log_command("user123", "test_session", "cat other.txt", "")

        ctx = SkillContext(
            session_name="test_session",
            user_id="user123",
            session_state={},
            args=["search", "vim"],
        )
        result = await skill.execute(ctx)
        assert result.success is True
        assert "vim" in result.output
```

- [ ] **Step 2: 运行测试确认失败**

```bash
pytest tests/test_skills.py::TestLogSkill -v
```

Expected: FAIL

- [ ] **Step 3: 实现 LogSkill**

```python
# src/butler/skills/log_skill.py
"""日志记录 Skill。"""

import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional

from butler.skills.base import SkillBase, SkillContext, SkillResult

class LogSkill(SkillBase):
    """记录和检索 Shell 交互日志。"""

    name = "log"
    description = "记录和检索 Shell 交互日志"
    triggers = ["/log", "/history"]

    def __init__(self, db_path: str = "data/logs.db"):
        """
        初始化 LogSkill。

        Args:
            db_path: SQLite 数据库路径
        """
        self.db_path = Path(db_path)
        if self.db_path != ":memory:":
            self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()

    def _init_db(self) -> None:
        """初始化数据库。"""
        conn = sqlite3.connect(self.db_path)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS shell_logs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id TEXT NOT NULL,
                session_name TEXT NOT NULL,
                command TEXT NOT NULL,
                output TEXT,
                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_user ON shell_logs(user_id)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON shell_logs(timestamp)")
        conn.commit()
        conn.close()

    def log_command(
        self,
        user_id: str,
        session_name: str,
        command: str,
        output: str = "",
    ) -> None:
        """
        记录命令执行。

        Args:
            user_id: 用户 ID
            session_name: 会话名称
            command: 命令
            output: 输出
        """
        conn = sqlite3.connect(self.db_path)
        conn.execute(
            "INSERT INTO shell_logs (user_id, session_name, command, output) VALUES (?, ?, ?, ?)",
            (user_id, session_name, command, output),
        )
        conn.commit()
        conn.close()

    async def execute(self, ctx: SkillContext) -> SkillResult:
        """
        执行日志查询。

        Args:
            ctx: 执行上下文

        Returns:
            查询结果
        """
        args = ctx.args

        if not args or args[0] == "recent":
            limit = int(args[1]) if len(args) > 1 else 10
            return self._query_recent(ctx.user_id, limit)

        elif args[0] == "search":
            keyword = " ".join(args[1:]) if len(args) > 1 else ""
            return self._search_commands(ctx.user_id, keyword)

        return SkillResult(
            success=False,
            output="用法: /log [recent N|search 关键词]",
        )

    def _query_recent(self, user_id: str, limit: int) -> SkillResult:
        """查询最近记录。"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.execute(
            """
            SELECT command, timestamp FROM shell_logs
            WHERE user_id = ?
            ORDER BY timestamp DESC
            LIMIT ?
            """,
            (user_id, limit),
        )
        rows = cursor.fetchall()
        conn.close()

        output = "\n".join(
            f"[{row[1]}] {row[0]}"
            for row in rows
        )
        return SkillResult(
            success=True,
            output=output or "无日志记录",
        )

    def _search_commands(self, user_id: str, keyword: str) -> SkillResult:
        """搜索命令。"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.execute(
            """
            SELECT command, timestamp FROM shell_logs
            WHERE user_id = ? AND command LIKE ?
            ORDER BY timestamp DESC
            LIMIT 20
            """,
            (user_id, f"%{keyword}%"),
        )
        rows = cursor.fetchall()
        conn.close()

        output = "\n".join(
            f"[{row[1]}] {row[0]}"
            for row in rows
        )
        return SkillResult(
            success=True,
            output=output or "无匹配记录",
        )
```

- [ ] **Step 4: 创建 data 目录占位**

```bash
mkdir -p data && touch data/.gitkeep
```

- [ ] **Step 5: 运行测试确认通过**

```bash
pytest tests/test_skills.py::TestLogSkill -v
```

Expected: PASS

- [ ] **Step 6: 提交 LogSkill**

```bash
git add src/butler/skills/log_skill.py data/.gitkeep tests/test_skills.py
git commit -m "feat: add LogSkill for shell interaction logging"
```

---

### Task 4.3: SystemSkill 实现

**Files:**
- Create: `src/butler/skills/system_skill.py`

- [ ] **Step 1: 编写 SystemSkill 测试**

追加到 `tests/test_skills.py`:

```python
from butler.skills.system_skill import SystemSkill

class TestSystemSkill:
    """SystemSkill 测试。"""

    def test_system_skill_triggers(self):
        """测试触发器。"""
        skill = SystemSkill()
        assert skill.matches("/sys") is True
        assert skill.matches("/system") is True
        assert skill.matches("/load") is True

    @pytest.mark.asyncio
    async def test_system_load(self):
        """测试获取系统负载。"""
        skill = SystemSkill()
        ctx = SkillContext(
            session_name="test",
            user_id="user123",
            session_state={},
            args=["load"],
        )
        result = await skill.execute(ctx)
        assert result.success is True
        # uptime 输出应该包含 load average
        assert "load" in result.output.lower() or "up" in result.output.lower()

    @pytest.mark.asyncio
    async def test_system_disk(self):
        """测试获取磁盘信息。"""
        skill = SystemSkill()
        ctx = SkillContext(
            session_name="test",
            user_id="user123",
            session_state={},
            args=["disk"],
        )
        result = await skill.execute(ctx)
        assert result.success is True

    @pytest.mark.asyncio
    async def test_system_mem(self):
        """测试获取内存信息。"""
        skill = SystemSkill()
        ctx = SkillContext(
            session_name="test",
            user_id="user123",
            session_state={},
            args=["mem"],
        )
        result = await skill.execute(ctx)
        assert result.success is True

    @pytest.mark.asyncio
    async def test_system_help(self):
        """测试帮助信息。"""
        skill = SystemSkill()
        ctx = SkillContext(
            session_name="test",
            user_id="user123",
            session_state={},
            args=[],
        )
        result = await skill.execute(ctx)
        assert result.success is False
        assert "用法" in result.output
```

- [ ] **Step 2: 运行测试确认失败**

```bash
pytest tests/test_skills.py::TestSystemSkill -v
```

Expected: FAIL

- [ ] **Step 3: 实现 SystemSkill**

```python
# src/butler/skills/system_skill.py
"""系统信息 Skill。"""

import subprocess

from butler.skills.base import SkillBase, SkillContext, SkillResult

class SystemSkill(SkillBase):
    """获取系统负载和资源信息。"""

    name = "system"
    description = "获取系统负载和资源信息"
    triggers = ["/sys", "/system", "/load"]

    async def execute(self, ctx: SkillContext) -> SkillResult:
        """
        执行系统信息查询。

        Args:
            ctx: 执行上下文

        Returns:
            查询结果
        """
        args = ctx.args

        if not args or args[0] == "help":
            return SkillResult(
                success=False,
                output="用法: /system [load|disk|mem]",
            )

        cmd = args[0]

        if cmd == "load":
            return self._get_load()
        elif cmd == "disk":
            return self._get_disk()
        elif cmd == "mem":
            return self._get_memory()

        return SkillResult(
            success=False,
            output=f"未知命令: {cmd}\n用法: /system [load|disk|mem]",
        )

    def _get_load(self) -> SkillResult:
        """获取系统负载。"""
        try:
            result = subprocess.run(
                ["uptime"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return SkillResult(
                success=True,
                output=result.stdout.strip(),
            )
        except Exception as e:
            return SkillResult(
                success=False,
                output=f"获取系统负载失败: {e}",
            )

    def _get_disk(self) -> SkillResult:
        """获取磁盘使用情况。"""
        try:
            result = subprocess.run(
                ["df", "-h", "/"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return SkillResult(
                success=True,
                output=result.stdout.strip(),
            )
        except Exception as e:
            return SkillResult(
                success=False,
                output=f"获取磁盘信息失败: {e}",
            )

    def _get_memory(self) -> SkillResult:
        """获取内存使用情况。"""
        try:
            result = subprocess.run(
                ["free", "-h"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return SkillResult(
                success=True,
                output=result.stdout.strip(),
            )
        except Exception as e:
            return SkillResult(
                success=False,
                output=f"获取内存信息失败: {e}",
            )
```

- [ ] **Step 4: 运行测试确认通过**

```bash
pytest tests/test_skills.py::TestSystemSkill -v
```

Expected: PASS

- [ ] **Step 5: 提交 SystemSkill**

```bash
git add src/butler/skills/system_skill.py tests/test_skills.py
git commit -m "feat: add SystemSkill for system information"
```

+from butler.session.wrapper import TmuxWrapper

+    """Mock subprocess.run 用于测试 TmuxWrapper。"""

+    with patch("subprocess.run") as mock_run:

+    return TmuxWrapper(session_prefix="test_")

+    """创建示例会话状态。"""

+    from butler.session.state import SessionState, SessionMode

+from butler.session.wrapper import TmuxWrapper, PtyState, SessionMode

+    def test_create_session(self, mock_subprocess, tmux_wrapper):

+        mock_subprocess.return_value = Mock(returncode=0)

+        session_name = tmux_wrapper.create_session("user123")

+        assert session_name == "test_user123"

+        mock_subprocess.assert_called_once()

+        args = mock_subprocess.call_args[0][0]

+    def test_session_exists_true(self, mock_subprocess, tmux_wrapper):

+        result = tmux_wrapper.session_exists("test_user123")

+    def test_session_exists_false(self, mock_subprocess, tmux_wrapper):

+        mock_subprocess.return_value = Mock(returncode=1)

+    def test_kill_session(self, mock_subprocess, tmux_wrapper):

+        tmux_wrapper.kill_session("test_user123")

+    def test_send_keys_normal(self, mock_subprocess, tmux_wrapper):

+        """测试发送普通命令。"""

+        tmux_wrapper.send_keys("test_session", "ls -la", enter=True)

+        assert mock_subprocess.call_count == 2

+    def test_send_keys_no_enter(self, mock_subprocess, tmux_wrapper):

+        tmux_wrapper.send_keys("test_session", "ls -la", enter=False)

+        # 应该只调用一次

+        assert mock_subprocess.call_count == 1

+    def test_send_keys_special_key(self, mock_subprocess, tmux_wrapper):

+        """测试发送特殊键。"""

+        tmux_wrapper.send_keys("test_session", "C-c")

+    def test_send_raw(self, mock_subprocess, tmux_wrapper):

+        """测试发送原始数据。"""

+        tmux_wrapper.send_raw("test_session", "raw data")

+        args = mock_subprocess.call_args[0][0]

+    def test_capture_pane(self, mock_subprocess, tmux_wrapper):

+        """测试抓取终端内容。"""

+        mock_subprocess.return_value = Mock(stdout="line1\nline2\nline3", returncode=0)

+        result = tmux_wrapper.capture_pane("test_session")

+from butler.session.state import PtyState, SessionMode

+    # 交互模式进程名单

+    INTERACTIVE_PROCESSES: Final[frozenset[str]] = frozenset([

+        "python", "node", "irb", "ipython",

+    # 快捷键映射

+    SPECIAL_KEYS: Final[dict[str, str]] = {

+        "Up": "Up", "Down": "Down", "Left": "Left", "Right": "Right",

+        "Enter": "Enter", "Space": "Space",

+    def __init__(self, session_prefix: str = "butler_"):

+            session_prefix: tmux session 名称前缀

+        self.session_prefix = session_prefix

+    def create_session(self, user_id: str) -> str:

+        session_name = f"{self.session_prefix}{user_id}"

+            ["tmux", "new-session", "-d", "-s", session_name],

+    def session_exists(self, session_name: str) -> bool:

+            session_name: tmux session 名称

+            ["tmux", "has-session", "-t", session_name],

+    def kill_session(self, session_name: str) -> None:

+            ["tmux", "kill-session", "-t", session_name],

+    def get_or_create_session(self, user_id: str) -> str:

+        获取或创建用户 session。

+        if not self.session_exists(session_name):

+            return self.create_session(user_id)

+            keys: 要发送的内容（支持快捷键如 "C-c"）

+        # 检查是否是特殊键

+            tmux_key = self.SPECIAL_KEYS[keys]

+                ["tmux", "send-keys", "-t", session_name, tmux_key],

+            # 字面模式发送，避免 tmux 解释特殊字符

+                ["tmux", "send-keys", "-l", "-t", session_name, keys],

+                    ["tmux", "send-keys", "-t", session_name, "Enter"],

+                    capture_output=True,

+    def send_raw(self, session_name: str, data: str) -> None:

+        发送原始数据（不含 Enter），用于交互模式精确控制。

+            ["tmux", "send-keys", "-l", "-t", session_name, data],

+        抓取终端屏幕内容。

+            start_line: 起始行（负数表示从底部计算）

+            终端屏幕文本

+        result = subprocess.run(cmd, capture_output=True, text=True)

+    def get_active_process(self, session_name: str) -> Optional[str]:

+        获取 tmux 窗口中的活跃进程名。

+            ["tmux", "list-panes", "-t", session_name, "-F", "#{pane_pid}"],

+        pane_pids = result.stdout.strip().split("\n")

+        if not pane_pids or not pane_pids[0]:

+        # 获取该 PID 的子进程

+            ps_result = subprocess.run(

+                ["ps", "--ppid", pane_pid, "-o", "comm=", "--no-headers"],

+            processes = ps_result.stdout.strip().split("\n")

+            # 返回最上层的非 shell 进程

+                if proc and proc not in ("bash", "zsh", "sh", "fish"):

+    def get_pty_state(self, session_name: str) -> PtyState:

+        检测 PTY 是否在等待输入。

+            session_name: tmux session 名称

+        # 读取进程状态

+            stat_path = Path(f"/proc/{pane_pid}/stat")

+                return PtyState.UNKNOWN

+            stat_content = stat_path.read_text()

+            parts = stat_content.split()

+            # 检查是否有活跃子进程

+            children_path = Path(f"/proc/{pane_pid}/task/{pane_pid}/children")

+                children = children_path.read_text().strip().split()

+                for child_pid in children:

+                        child_stat = Path(f"/proc/{child_pid}/stat")

+                        if child_stat.exists():

+                            child_state = child_stat.read_text().split()[2]

+                            if child_state == "R":

+                                return PtyState.RUNNING

+                return PtyState.BLOCKED

+                return PtyState.RUNNING

+        except (FileNotFoundError, PermissionError, ProcessLookupError):

+    def detect_mode(self, session_name: str) -> SessionMode:

+        混合模式检测：进程名单匹配 + PTY 状态检测。

+            session_name: tmux session 名称

+            检测到的会话模式

+        active_proc = self.get_active_process(session_name)

+        # 进程名单匹配

+        if active_proc and active_proc in self.INTERACTIVE_PROCESSES:

+            return SessionMode.INTERACTIVE

+        return SessionMode.NATURAL_LANGUAGE

+git add src/butler/session/wrapper.py tests/test_wrapper.py tests/conftest.py

+git commit -m "feat: add TmuxWrapper core class"

+    def test_get_active_process_vim(self, mock_subprocess, tmux_wrapper):

+        mock_subprocess.side_effect = [

+            Mock(stdout="12345\n", returncode=0),  # list-panes

+            Mock(stdout="vim\nbash\n", returncode=0),  # ps

+        proc = tmux_wrapper.get_active_process("test_session")

+    def test_get_active_process_top(self, mock_subprocess, tmux_wrapper):

+            Mock(stdout="12345\n", returncode=0),

+            Mock(stdout="top\nbash\n", returncode=0),

+    def test_get_active_process_shell_only(self, mock_subprocess, tmux_wrapper):

+            Mock(stdout="bash\n", returncode=0),

+    def test_detect_mode_vim(self, mock_subprocess, tmux_wrapper):

+            Mock(stdout="vim\nbash\n", returncode=0),

+        mode = tmux_wrapper.detect_mode("test_session")

+        assert mode == SessionMode.INTERACTIVE

+    def test_detect_mode_normal(self, mock_subprocess, tmux_wrapper):

+        """测试普通命令保持自然语言模式。"""

+        assert mode == SessionMode.NATURAL_LANGUAGE

+git commit -m "test: add mode detection tests for TmuxWrapper"

+from butler.skills.base import SkillBase, SkillContext, SkillResult, SkillRegistry

+    description = "Mock skill for testing"

+    async def execute(self, ctx: SkillContext) -> SkillResult:

+        return SkillResult(success=True, output=f"Executed with args: {ctx.args}")

+    def test_skill_matches_trigger(self):

+        """测试触发器匹配。"""

+        assert skill.matches("/mock arg1") is True

+        assert skill.matches("/test") is True

+        assert skill.matches("/other") is False

+    def test_skill_context_creation(self):

+        """测试上下文创建。"""

+            session_name="test_session",

+        assert ctx.session_name == "test_session"

+        assert ctx.args == ["arg1", "arg2"]

+    def test_skill_result_creation(self):

+        """测试结果创建。"""

+        result = SkillResult(success=True, output="Done", metadata={"key": "value"})

+        assert "mock" in registry._skills

+    def test_find_matching_skill(self):

+        found = registry.find_matching("/mock arg1")

+    def test_find_no_matching_skill(self):

+        found = registry.find_matching("/unknown")

+__all__ = ["SkillBase", "SkillContext", "SkillResult", "SkillRegistry"]

+    session_name: str = Field(..., description="会话名称")

+    user_id: str = Field(..., description="用户 ID")

+    session_state: dict[str, Any] = Field(default_factory=dict, description="会话状态")

+    args: list[str] = Field(default_factory=list, description="参数列表")

+    success: bool = Field(..., description="是否成功")

+    output: str = Field(..., description="输出内容")

+    metadata: dict[str, Any] = Field(default_factory=dict, description="额外元数据")

+            ctx: 执行上下文

+            执行结果

+    def matches(self, content: str) -> bool:

+        检查是否匹配触发条件。

+            是否匹配

+        content_stripped = content.strip()

+            if content_stripped.startswith(trigger):

+    def parse_args(self, content: str) -> list[str]:

+        从内容中解析参数。

+            参数列表

+                args_str = content_stripped[len(trigger):].strip()

+                    return args_str.split()

+        self._skills: dict[str, SkillBase] = {}

+    def register(self, skill: SkillBase) -> None:

+        self._skills[skill.name] = skill

+    def get(self, name: str) -> Optional[SkillBase]:

+        获取指定名称的 Skill。

+    def find_matching(self, content: str) -> Optional[SkillBase]:

+        查找匹配内容的 Skill。

+        for skill in self._skills.values():

+    def list_skills(self) -> list[str]:

+        列出所有注册的 Skill 名称。

+        return list(self._skills.keys())

+git add src/butler/skills/ tests/test_skills.py

+git commit -m "feat: add Skill extension system base"

+from butler.skills.log_skill import LogSkill

+        skill = LogSkill(db_path=":memory:")

+        assert skill.matches("/log") is True

+        assert skill.matches("/history") is True

+        assert skill.matches("/log recent") is True

+        """测试记录命令。"""

+        skill.log_command("user123", "test_session", "ls -la", "output")

+            session_name="test_session",

+        result = await skill.execute(ctx)

+        assert "ls -la" in result.output

+    async def test_search_commands(self):

+        """测试搜索命令。"""

+        skill.log_command("user123", "test_session", "vim file.txt", "")

+        skill.log_command("user123", "test_session", "cat other.txt", "")

+    def __init__(self, db_path: str = "data/logs.db"):

+            self.db_path.parent.mkdir(parents=True, exist_ok=True)

+        """初始化数据库。"""

+        conn = sqlite3.connect(self.db_path)

+            CREATE TABLE IF NOT EXISTS shell_logs (

+                id INTEGER PRIMARY KEY AUTOINCREMENT,

+                session_name TEXT NOT NULL,

+                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP

+        conn.execute("CREATE INDEX IF NOT EXISTS idx_user ON shell_logs(user_id)")

+        conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON shell_logs(timestamp)")

+        记录命令执行。

+            "INSERT INTO shell_logs (user_id, session_name, command, output) VALUES (?, ?, ?, ?)",

+            (user_id, session_name, command, output),

+        执行日志查询。

+            ctx: 执行上下文

+            查询结果

+        if not args or args[0] == "recent":

+            limit = int(args[1]) if len(args) > 1 else 10

+            return self._query_recent(ctx.user_id, limit)

+            keyword = " ".join(args[1:]) if len(args) > 1 else ""

+            return self._search_commands(ctx.user_id, keyword)

+            output="用法: /log [recent N|search 关键词]",

+    def _query_recent(self, user_id: str, limit: int) -> SkillResult:

+        """查询最近记录。"""

+        conn = sqlite3.connect(self.db_path)

+            SELECT command, timestamp FROM shell_logs

+    def _search_commands(self, user_id: str, keyword: str) -> SkillResult:

+            WHERE user_id = ? AND command LIKE ?

+git add src/butler/skills/log_skill.py data/.gitkeep tests/test_skills.py

+git commit -m "feat: add LogSkill for shell interaction logging"

+from butler.skills.system_skill import SystemSkill

+    def test_system_skill_triggers(self):

+        assert skill.matches("/sys") is True

+        assert skill.matches("/system") is True

+        assert skill.matches("/load") is True

+        """测试获取系统负载。"""

+        result = await skill.execute(ctx)

+        assert "load" in result.output.lower() or "up" in result.output.lower()

+        """测试获取磁盘信息。"""

+        """测试获取内存信息。"""

+        """测试帮助信息。"""

+    """获取系统负载和资源信息。"""

+    triggers = ["/sys", "/system", "/load"]

+        执行系统信息查询。

+            ctx: 执行上下文

+            查询结果

+        if not args or args[0] == "help":

+                output="用法: /system [load|disk|mem]",

+            output=f"未知命令: {cmd}\n用法: /system [load|disk|mem]",

+    def _get_load(self) -> SkillResult:

+        """获取系统负载。"""

+                output=result.stdout.strip(),

+                output=f"获取系统负载失败: {e}",

+    def _get_disk(self) -> SkillResult:

+        """获取磁盘使用情况。"""

+                output=f"获取磁盘信息失败: {e}",

+    def _get_memory(self) -> SkillResult:

+        """获取内存使用情况。"""

+                output=f"获取内存信息失败: {e}",

+git add src/butler/skills/system_skill.py tests/test_skills.py

+git commit -m "feat: add SystemSkill for system information"


Now I need to add the remaining chunks for:
- Chunk 5: Inngest Workflows (handle_message, guardrail, compaction)
- Chunk 6: QQ Gateway (NapCat integration)
- Chunk 7: Main entry point and Docker deployment

Let me continue adding these sections.


继续添加剩余的计划内容（Inngest 工作流、Gateway、主入口）。


1650
1651	    def test_register_skill(self):
1652	        """测试注册 Skill。"""
1653	        registry = SkillRegistry()
1654	        skill = MockSkill()
1655
1656	        registry.register(skill)
1657
1658	        assert "mock" in registry._skills
1659
1660	    def test_find_matching_skill(self):
1661	        """测试查找匹配的 Skill。"""
1662	        registry = SkillRegistry()
1663	        registry.register(MockSkill())
1664
1665	        found = registry.find_matching("/mock arg1")
1666	        assert found is not None
1667	        assert found.name == "mock"
1668
1669	    def test_find_no_matching_skill(self):
1670	        """测试无匹配时返回 None。"""
1671	        registry = SkillRegistry()
1672	        registry.register(MockSkill())
1673
1674	        found = registry.find_matching("/unknown")
1675	        assert found is None
1676	```
1677
1678	- [ ] **Step 2: 运行测试确认失败**
1679
1680	```bash
1681	pytest tests/test_skills.py -v
1682	```
1683
1684	Expected: FAIL
1685
1686	- [ ] **Step 3: 实现 Skill 基类**
1687
1688	```python
1689	# src/butler/skills/__init__.py
1690	"""Skill 扩展系统。"""
1691
1692	from butler.skills.base import SkillBase, SkillContext, SkillResult, SkillRegistry
1693
1694	__all__ = ["SkillBase", "SkillContext", "SkillResult", "SkillRegistry"]
1695	```
1696
1697	```python
1698	# src/butler/skills/base.py
1699	"""Skill 基类和注册表。"""
[result-id: r7]

    def test_register_skill(self):
        """测试注册 Skill。"""
        registry = SkillRegistry()
        skill = MockSkill()

        registry.register(skill)

        assert "mock" in registry._skills

    def test_find_matching_skill(self):
        """测试查找匹配的 Skill。"""
        registry = SkillRegistry()
        registry.register(MockSkill())

        found = registry.find_matching("/mock arg1")
        assert found is not None
        assert found.name == "mock"

    def test_find_no_matching_skill(self):
        """测试无匹配时返回 None。"""
        registry = SkillRegistry()
        registry.register(MockSkill())

        found = registry.find_matching("/unknown")
        assert found is None
```

- [ ] **Step 2: 运行测试确认失败**

```bash
pytest tests/test_skills.py -v
```

Expected: FAIL

- [ ] **Step 3: 实现 Skill 基类**

```python
# src/butler/skills/__init__.py
"""Skill 扩展系统。"""

from butler.skills.base import SkillBase, SkillContext, SkillResult, SkillRegistry

__all__ = ["SkillBase", "SkillContext", "SkillResult", "SkillRegistry"]
```

```python
# src/butler/skills/base.py
"""Skill 基类和注册表。"""


让我查看文件的末尾部分，然后添加剩余的计划内容。


<system-reminder>Warning: the file exists but is shorter than the provided offset (2350). The file has 2299 lines.</system-reminder>


文件已经到末尾了。让我查看末尾的内容，然后继续添加剩余的计划内容。


2250	                ["df", "-h", "/"],
2251	                capture_output=True,
2252	                text=True,
2253	                timeout=5,
2254	            )
2255	            return SkillResult(
2256	                success=True,
2257	                output=result.stdout.strip(),
2258	            )
2259	        except Exception as e:
2260	            return SkillResult(
2261	                success=False,
2262	                output=f"获取磁盘信息失败: {e}",
2263	            )
2264
2265	    def _get_memory(self) -> SkillResult:
2266	        """获取内存使用情况。"""
2267	        try:
2268	            result = subprocess.run(
2269	                ["free", "-h"],
2270	                capture_output=True,
2271	                text=True,
2272	                timeout=5,
2273	            )
2274	            return SkillResult(
2275	                success=True,
2276	                output=result.stdout.strip(),
2277	            )
2278	        except Exception as e:
2279	            return SkillResult(
2280	                success=False,
2281	                output=f"获取内存信息失败: {e}",
2282	            )
2283	```
2284
2285	- [ ] **Step 4: 运行测试确认通过**
2286
2287	```bash
2288	pytest tests/test_skills.py::TestSystemSkill -v
2289	```
2290
2291	Expected: PASS
2292
2293	- [ ] **Step 5: 提交 SystemSkill**
2294
2295	```bash
2296	git add src/butler/skills/system_skill.py tests/test_skills.py
2297	git commit -m "feat: add SystemSkill for system information"
2298	```
2299
[result-id: r8]

                ["df", "-h", "/"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return SkillResult(
                success=True,
                output=result.stdout.strip(),
            )
        except Exception as e:
            return SkillResult(
                success=False,
                output=f"获取磁盘信息失败: {e}",
            )

    def _get_memory(self) -> SkillResult:
        """获取内存使用情况。"""
        try:
            result = subprocess.run(
                ["free", "-h"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return SkillResult(
                success=True,
                output=result.stdout.strip(),
            )
        except Exception as e:
            return SkillResult(
                success=False,
                output=f"获取内存信息失败: {e}",
            )
```

- [ ] **Step 4: 运行测试确认通过**

```bash
pytest tests/test_skills.py::TestSystemSkill -v
```

Expected: PASS

- [ ] **Step 5: 提交 SystemSkill**

```bash
git add src/butler/skills/system_skill.py tests/test_skills.py
git commit -m "feat: add SystemSkill for system information"
```


现在我需要追加剩余的计划内容：Chunk 5 (Inngest 工作流)、Chunk 6 (Gateway)、Chunk 7 (主入口和部署)。


Edit

git add src/butler/skills/system_skill.py tests/test_skills.py
git commit -m "feat: add SystemSkill for system information"
```

git add src/butler/skills/system_skill.py tests/test_skills.py
git commit -m "feat: add SystemSkill for system information"
```

---

## Chunk 5: Inngest 工作流

### Task 5.1: 工作流基础结构

**Files:**
- Create: `src/butler/workflows/__init__.py`
- Create: `src/butler/workflows/handle_message.py`

- [ ] **Step 1: 创建工作流模块初始化**

```python
# src/butler/workflows/__init__.py
"""Inngest 工作流模块。"""

import inngest

from butler.config import settings

# 创建 Inngest 客户端
inngest_client = inngest.Inngest(
    app_id="butler-shell",
    event_key=settings.inngest_event_key or "dev_key",
    signing_key=settings.inngest_signing_key or None,
)

__all__ = ["inngest_client"]
```

- [ ] **Step 2: 实现 handle_message 工作流骨架**

```python
# src/butler/workflows/handle_message.py
"""主消息处理工作流。"""

import uuid
from typing import Any

import inngest

from butler.config import settings
from butler.session import SessionMode, SessionState
from butler.session.wrapper import TmuxWrapper
from butler.security.auth import is_authorized
from butler.security.guardrail import is_dangerous_command, get_danger_reason
from butler.skills.base import SkillRegistry
from butler.skills.log_skill import LogSkill
from butler.skills.system_skill import SystemSkill
from butler.workflows import inngest_client

# 初始化组件
tmux = TmuxWrapper(session_prefix=settings.session_prefix)
skill_registry = SkillRegistry()
skill_registry.register(LogSkill())
skill_registry.register(SystemSkill())

@inngest_client.function(
    id="handle-im-message",
    name="Handle IM Message",
    trigger=inngest.TriggerEvent(event="im/message"),
)
async def handle_im_message(ctx: inngest.Context, step: inngest.Step) -> dict[str, Any]:
    """
    主消息处理流程。

    事件参数:
        user_id: QQ 用户 ID
        content: 消息内容
    """
    user_id = ctx.event.data.get("user_id", "")
    content = ctx.event.data.get("content", "")
    message_id = ctx.event.data.get("message_id", str(uuid.uuid4()))

    # Step 1: 权限检查
    if not is_authorized(user_id, settings.allowed_users):
        return {
            "status": "unauthorized",
            "message": f"用户 {user_id} 无权限使用此服务",
        }

    # Step 2: 获取或创建会话
    session_name = await step.run(
        "get-or-create-session",
        lambda: tmux.get_or_create_session(user_id),
    )

    # Step 3: 加载会话状态（简化版，实际应使用 ctx.state）
    state = SessionState(session_id=session_name, user_id=user_id)

    # Step 4: 检测当前模式
    current_mode = await step.run(
        "detect-mode",
        lambda: tmux.detect_mode(session_name),
    )

    # Step 5: 根据模式分流
    if current_mode == SessionMode.INTERACTIVE:
        # 交互模式：直接透传
        await step.run(
            "passthrough",
            lambda: tmux.send_keys(session_name, content, enter=False),
        )
        output = await step.run(
            "capture-output",
            lambda: tmux.capture_pane(session_name),
        )
        return {
            "status": "success",
            "mode": "interactive",
            "output": output[-2000:],  # 限制输出长度
        }

    # Step 6: 检查是否是 Skill
    skill = skill_registry.find_matching(content)
    if skill:
        args = skill.parse_args(content)
        from butler.skills.base import SkillContext
        skill_ctx = SkillContext(
            session_name=session_name,
            user_id=user_id,
            session_state=state.model_dump(),
            args=args,
        )
        result = await step.run(
            "execute-skill",
            lambda: skill.execute(skill_ctx),
        )
        return {
            "status": "success",
            "mode": "skill",
            "skill_name": skill.name,
            "output": result.output,
        }

    # Step 7: 危险命令检查
    if settings.guardrail_enabled and is_dangerous_command(content):
        reason = get_danger_reason(content) or "潜在危险命令"
        # 发送审批事件
        await step.send_event(
            "im/command-needs-approval",
            {
                "user_id": user_id,
                "command": content,
                "reason": reason,
                "session_name": session_name,
                "message_id": message_id,
            },
        )
        return {
            "status": "pending_approval",
            "mode": "nl",
            "reason": reason,
            "message": f"⚠ 危险命令需要确认: {reason}",
        }

    # Step 8: 正常命令执行
    await step.run(
        "execute-command",
        lambda: tmux.send_keys(session_name, content, enter=True),
    )

    # Step 9: 捕获输出
    output = await step.run(
        "capture-output",
        lambda: tmux.capture_pane(session_name),
    )

    # Step 10: 记录日志
    log_skill = skill_registry.get("log")
    if log_skill:
        log_skill.log_command(user_id, session_name, content, output[:500])

    return {
        "status": "success",
        "mode": "nl",
        "output": output[-2000:],
    }
```

- [ ] **Step 3: 提交工作流骨架**

```bash
git add src/butler/workflows/
git commit -m "feat: add handle_message workflow skeleton"
```

---

### Task 5.2: 审批工作流

**Files:**
- Create: `src/butler/workflows/guardrail.py`

- [ ] **Step 1: 实现审批工作流**

```python
# src/butler/workflows/guardrail.py
"""命令准入审批工作流。"""

import inngest

from butler.config import settings
from butler.session.wrapper import TmuxWrapper
from butler.workflows import inngest_client

tmux = TmuxWrapper(session_prefix=settings.session_prefix)

@inngest_client.function(
    id="command-guardrail",
    name="Command Guardrail",
    trigger=inngest.TriggerEvent(event="im/command-needs-approval"),
)
async def command_guardrail(ctx: inngest.Context, step: inngest.Step) -> dict:
    """
    危险命令审批流程。

    事件参数:
        user_id: 用户 ID
        command: 命令内容
        reason: 危险原因
        session_name: 会话名称
    """
    user_id = ctx.event.data.get("user_id", "")
    command = ctx.event.data.get("command", "")
    reason = ctx.event.data.get("reason", "未知原因")
    session_name = ctx.event.data.get("session_name", "")

    # Step 1: 发送确认请求（通过回调到 QQ）
    # 这里简化处理，实际需要调用 gateway 发送消息
    confirmation_message = f"""⚠ 危险命令检测

命令: `{command}`
原因: {reason}

回复 "确认" 执行，回复 "取消" 放弃
超时时间: {settings.guardrail_approval_timeout} 秒"""

    # Step 2: 等待用户回复
    try:
        approval = await step.wait_for_event(
            "wait-approval",
            event="im/approval-response",
            timeout=settings.guardrail_approval_timeout,
            filter={"user_id": user_id},
        )

        if approval.data.get("approved"):
            # 执行命令
            await step.run(
                "execute-approved",
                lambda: tmux.send_keys(session_name, command, enter=True),
            )
            output = await step.run(
                "capture-output",
                lambda: tmux.capture_pane(session_name),
            )
            return {
                "status": "approved",
                "executed": True,
                "output": output[-1000:],
            }
        else:
            return {
                "status": "rejected",
                "executed": False,
            }

    except inngest.errors.WaitForEventTimeout:
        return {
            "status": "timeout",
            "executed": False,
            "message": "审批超时，命令已取消",
        }
```

- [ ] **Step 2: 提交审批工作流**

```bash
git add src/butler/workflows/guardrail.py
git commit -m "feat: add command guardrail workflow"
```

---

## Chunk 6: QQ Gateway

### Task 6.1: Gateway 模块

**Files:**
- Create: `src/butler/gateway/__init__.py`
- Create: `src/butler/gateway/events.py`
- Create: `src/butler/gateway/napcat.py`

- [ ] **Step 1: 创建 Gateway 初始化**

```python
# src/butler/gateway/__init__.py
"""QQ Gateway 模块。"""

from butler.gateway.napcat import NapCatGateway

__all__ = ["NapCatGateway"]
```

```python
# src/butler/gateway/events.py
"""消息事件定义。"""

from butler.session.state import NapCatEvent

__all__ = ["NapCatEvent"]
```

- [ ] **Step 2: 实现 NapCat Gateway**

```python
# src/butler/gateway/napcat.py
"""NapCatQQ WebSocket 网关。"""

import asyncio
import json
from typing import Callable, Optional

import websockets
from websockets.client import WebSocketClientProtocol

from butler.session.state import NapCatEvent

class NapCatGateway:
    """NapCatQQ WebSocket 网关。"""

    def __init__(
        self,
        ws_url: str = "ws://localhost:3001",
        access_token: str = "",
    ):
        """
        初始化网关。

        Args:
            ws_url: WebSocket URL
            access_token: 访问令牌
        """
        self.ws_url = ws_url
        self.access_token = access_token
        self.message_handler: Optional[Callable[[NapCatEvent], None]] = None
        self._ws: Optional[WebSocketClientProtocol] = None
        self._running = False

    def on_message(self, handler: Callable[[NapCatEvent], None]) -> None:
        """
        设置消息处理器。

        Args:
            handler: 消息处理函数
        """
        self.message_handler = handler

    async def connect(self) -> None:
        """建立 WebSocket 连接并监听消息。"""
        headers = {}
        if self.access_token:
            headers["Authorization"] = f"Bearer {self.access_token}"

        self._running = True

        while self._running:
            try:
                async with websockets.connect(
                    self.ws_url,
                    extra_headers=headers,
                    ping_interval=30,
                    ping_timeout=10,
                ) as ws:
                    self._ws = ws
                    await self._listen(ws)
            except Exception as e:
                print(f"Gateway connection error: {e}")
                if self._running:
                    await asyncio.sleep(5)  # 重连间隔

    async def _listen(self, ws: WebSocketClientProtocol) -> None:
        """监听 WebSocket 消息。"""
        async for raw_message in ws:
            try:
                data = json.loads(raw_message)

                # 处理 NapCat 事件格式
                if isinstance(data, dict):
                    event = NapCatEvent(
                        time=data.get("time", 0),
                        self_id=data.get("self_id", 0),
                        post_type=data.get("post_type", ""),
                        message_type=data.get("message_type", ""),
                        user_id=data.get("user_id", 0),
                        message=self._extract_message(data),
                        raw_message=str(data),
                    )

                    # 只处理私聊消息
                    if event.is_private_message and self.message_handler:
                        self.message_handler(event)

            except Exception as e:
                print(f"Error processing message: {e}")

    def _extract_message(self, data: dict) -> str:
        """提取消息文本。"""
        message = data.get("message", "")
        if isinstance(message, str):
            return message
        elif isinstance(message, list):
            # 处理消息段格式
            texts = []
            for seg in message:
                if isinstance(seg, dict) and seg.get("type") == "text":
                    texts.append(seg.get("data", {}).get("text", ""))
            return "".join(texts)
        return str(message)

    async def send_private_message(self, user_id: int, message: str) -> None:
        """
        发送私聊消息。

        Args:
            user_id: 目标用户 ID
            message: 消息内容
        """
        if not self._ws:
            return

        payload = {
            "action": "send_private_msg",
            "params": {
                "user_id": user_id,
                "message": message,
            },
        }
        await self._ws.send(json.dumps(payload))

    async def stop(self) -> None:
        """停止网关。"""
        self._running = False
        if self._ws:
            await self._ws.close()
```

- [ ] **Step 3: 提交 Gateway 模块**

```bash
git add src/butler/gateway/
git commit -m "feat: add NapCatQQ WebSocket gateway"
```

---

## Chunk 7: 主入口和部署

### Task 7.1: FastAPI 主入口

**Files:**
- Create: `src/butler/main.py`

- [ ] **Step 1: 实现 FastAPI 主入口**

```python
# src/butler/main.py
"""FastAPI 主入口。"""

import asyncio
import logging
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI
from fastapi.responses import JSONResponse

from butler.config import settings
from butler.gateway.napcat import NapCatGateway
from butler.workflows import inngest_client
from butler.workflows.handle_message import handle_im_message
from butler.workflows.guardrail import command_guardrail

# 配置日志
logging.basicConfig(
    level=getattr(logging, settings.log_level),
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

# 初始化 Gateway
gateway = NapCatGateway(
    ws_url=settings.napcat_ws_url,
    access_token=settings.napcat_token,
)

async def run_gateway():
    """后台运行 Gateway。"""
    async def handle_message(event):
        """处理 QQ 消息。"""
        logger.info(f"Received message from {event.user_id}: {event.message[:50]}...")

        # 转发到 Inngest
        await inngest_client.send(
            inngest_client.Event(
                name="im/message",
                data={
                    "user_id": str(event.user_id),
                    "content": event.message,
                },
            )
        )

    gateway.on_message(handle_message)
    await gateway.connect()

@asynccontextmanager
async def lifespan(app: FastAPI):
    """应用生命周期管理。"""
    # 启动时
    logger.info("Starting Butler-Shell...")

    # 启动 Gateway 后台任务
    gateway_task = asyncio.create_task(run_gateway())

    yield

    # 关闭时
    logger.info("Shutting down Butler-Shell...")
    await gateway.stop()
    gateway_task.cancel()

# 创建 FastAPI 应用
app = FastAPI(
    title="Butler-Shell",
    description="Persistent Shell Assistant with QQ Bridge",
    version="0.1.0",
    lifespan=lifespan,
)

# 注册 Inngest 函数
inngest_client.register(handle_im_message)
inngest_client.register(command_guardrail)

# Inngest Webhook 端点
app.include_router(
    inngest_client.serve(),
    prefix="/api/inngest",
)

# 健康检查端点
@app.get("/health")
async def health():
    """服务存活检查。"""
    return {"status": "ok"}

@app.get("/ready")
async def ready():
    """服务就绪检查。"""
    # 检查 tmux 是否可用
    import subprocess
    try:
        subprocess.run(["tmux", "-V"], capture_output=True, check=True)
        tmux_ok = True
    except Exception:
        tmux_ok = False

    return {
        "status": "ready" if tmux_ok else "degraded",
        "tmux": tmux_ok,
        "gateway": gateway._ws is not None,
    }

# 用户审批端点（用于外部回调）
@app.post("/api/approval")
async def handle_approval(user_id: str, approved: bool):
    """处理用户审批响应。"""
    await inngest_client.send(
        inngest_client.Event(
            name="im/approval-response",
            data={
                "user_id": user_id,
                "approved": approved,
            },
        )
    )
    return {"status": "sent"}

def main():
    """启动服务。"""
    uvicorn.run(
        "butler.main:app",
        host="0.0.0.0",
        port=8000,
        reload=False,
    )

if __name__ == "__main__":
    main()
```

- [ ] **Step 2: 提交主入口**

```bash
git add src/butler/main.py
git commit -m "feat: add FastAPI main entry point"
```

---

### Task 7.2: Docker 部署配置

**Files:**
- Create: `docker-compose.yml`
- Create: `Dockerfile`

- [ ] **Step 1: 创建 Dockerfile**

```dockerfile
# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# 安装系统依赖
RUN apt-get update && apt-get install -y \
    tmux \
    procps \
    && rm -rf /var/lib/apt/lists/*

# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制代码
COPY src/ ./src/
COPY config.yaml .
COPY .env.example .env

# 创建数据目录
RUN mkdir -p /app/data

# 暴露端口
EXPOSE 8000

# 启动命令
CMD ["python", "-m", "butler.main"]
```

- [ ] **Step 2: 创建 docker-compose.yml**

```yaml
# docker-compose.yml
version: '3.8'

services:
  butler:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - ./data:/app/data
      - ./config.yaml:/app/config.yaml:ro
    environment:
      - BUTLER_ALLOWED_USERS=${BUTLER_ALLOWED_USERS:-}
      - NAPCAT_WS_URL=${NAPCAT_WS_URL:-ws://napcat:3001}
      - NAPCAT_TOKEN=${NAPCAT_TOKEN:-}
      - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY:-dev_key}
      - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:-}
    depends_on:
      - inngest
    restart: unless-stopped

  inngest:
    image: inngest/inngest:latest
    ports:
      - "8288:8288"
    command: start --url http://butler:8000/api/inngest
    restart: unless-stopped

  napcat:
    image: mlikiowa/napcat-docker:latest
    ports:
      - "3001:3001"
    volumes:
      - ./napcat-data:/app/napcat/config
    restart: unless-stopped
```

- [ ] **Step 3: 提交部署配置**

```bash
git add docker-compose.yml Dockerfile
git commit -m "feat: add Docker deployment configuration"
```

---

### Task 7.3: Mock 测试脚本

**Files:**
- Create: `scripts/mock_test.py`

- [ ] **Step 1: 创建 Mock 测试脚本**

```python
#!/usr/bin/env python3
"""Mock 测试脚本 - 模拟 QQ 消息输入。"""

import asyncio
import httpx
import sys

async def send_mock_message(user_id: str, content: str):
    """发送模拟消息到 Butler-Shell。"""
    async with httpx.AsyncClient() as client:
        # 直接调用 Inngest 端点模拟消息
        response = await client.post(
            "http://localhost:8000/api/inngest",
            json={
                "name": "im/message",
                "data": {
                    "user_id": user_id,
                    "content": content,
                },
            },
            headers={
                "Content-Type": "application/json",
            },
        )
        print(f"Response: {response.status_code}")
        print(f"Body: {response.text}")

async def main():
    """主函数。"""
    if len(sys.argv) < 2:
        print("Usage: python mock_test.py <command> [user_id]")
        print("\nCommands:")
        print("  normal <cmd>   - 发送普通命令")
        print("  dangerous      - 发送危险命令测试")
        print("  skill          - 发送 Skill 命令")
        return

    user_id = sys.argv[2] if len(sys.argv) > 2 else "test_user_001"
    command = sys.argv[1]

    if command == "normal":
        content = sys.argv[3] if len(sys.argv) > 3 else "ls -la"
        await send_mock_message(user_id, content)
    elif command == "dangerous":
        await send_mock_message(user_id, "rm -rf /tmp/test")
    elif command == "skill":
        await send_mock_message(user_id, "/log recent 5")
    else:
        await send_mock_message(user_id, command)

if __name__ == "__main__":
    asyncio.run(main())
```

- [ ] **Step 2: 提交 Mock 测试脚本**

```bash
mkdir -p scripts
git add scripts/mock_test.py
git commit -m "feat: add mock test script for QQ message simulation"
```

---

## 完成检查清单

实施完成后，确保以下功能正常：

- [ ] 所有测试通过: `pytest tests/ -v`
- [ ] 类型检查通过: `mypy src/`
- [ ] 代码格式正确: `ruff check src/`
- [ ] 服务可启动: `python -m butler.main`
- [ ] 健康检查正常: `curl http://localhost:8000/health`
- [ ] Mock 测试可运行: `python scripts/mock_test.py normal "ls -la"`

---

**计划完成。准备执行？**


git add src/butler/skills/system_skill.py tests/test_skills.py
git commit -m "feat: add SystemSkill for system information"
```

---

## Chunk 5: Inngest 工作流

### Task 5.1: 工作流基础结构

**Files:**
- Create: `src/butler/workflows/__init__.py`
- Create: `src/butler/workflows/handle_message.py`

- [ ] **Step 1: 创建工作流模块初始化**

```python
# src/butler/workflows/__init__.py
"""Inngest 工作流模块。"""

import inngest

from butler.config import settings

# 创建 Inngest 客户端
inngest_client = inngest.Inngest(
    app_id="butler-shell",
    event_key=settings.inngest_event_key or "dev_key",
    signing_key=settings.inngest_signing_key or None,
)

__all__ = ["inngest_client"]
```

- [ ] **Step 2: 实现 handle_message 工作流骨架**

```python
# src/butler/workflows/handle_message.py
"""主消息处理工作流。"""

import uuid
from typing import Any

import inngest

from butler.config import settings
from butler.session import SessionMode, SessionState
from butler.session.wrapper import TmuxWrapper
from butler.security.auth import is_authorized
from butler.security.guardrail import is_dangerous_command, get_danger_reason
from butler.skills.base import SkillRegistry
from butler.skills.log_skill import LogSkill
from butler.skills.system_skill import SystemSkill
from butler.workflows import inngest_client

# 初始化组件
tmux = TmuxWrapper(session_prefix=settings.session_prefix)
skill_registry = SkillRegistry()
skill_registry.register(LogSkill())
skill_registry.register(SystemSkill())

@inngest_client.function(
    id="handle-im-message",
    name="Handle IM Message",
    trigger=inngest.TriggerEvent(event="im/message"),
)
async def handle_im_message(ctx: inngest.Context, step: inngest.Step) -> dict[str, Any]:
    """
    主消息处理流程。

    事件参数:
        user_id: QQ 用户 ID
        content: 消息内容
    """
    user_id = ctx.event.data.get("user_id", "")
    content = ctx.event.data.get("content", "")
    message_id = ctx.event.data.get("message_id", str(uuid.uuid4()))

    # Step 1: 权限检查
    if not is_authorized(user_id, settings.allowed_users):
        return {
            "status": "unauthorized",
            "message": f"用户 {user_id} 无权限使用此服务",
        }

    # Step 2: 获取或创建会话
    session_name = await step.run(
        "get-or-create-session",
        lambda: tmux.get_or_create_session(user_id),
    )

    # Step 3: 加载会话状态（简化版，实际应使用 ctx.state）
    state = SessionState(session_id=session_name, user_id=user_id)

    # Step 4: 检测当前模式
    current_mode = await step.run(
        "detect-mode",
        lambda: tmux.detect_mode(session_name),
    )

    # Step 5: 根据模式分流
    if current_mode == SessionMode.INTERACTIVE:
        # 交互模式：直接透传
        await step.run(
            "passthrough",
            lambda: tmux.send_keys(session_name, content, enter=False),
        )
        output = await step.run(
            "capture-output",
            lambda: tmux.capture_pane(session_name),
        )
        return {
            "status": "success",
            "mode": "interactive",
            "output": output[-2000:],  # 限制输出长度
        }

    # Step 6: 检查是否是 Skill
    skill = skill_registry.find_matching(content)
    if skill:
        args = skill.parse_args(content)
        from butler.skills.base import SkillContext
        skill_ctx = SkillContext(
            session_name=session_name,
            user_id=user_id,
            session_state=state.model_dump(),
            args=args,
        )
        result = await step.run(
            "execute-skill",
            lambda: skill.execute(skill_ctx),
        )
        return {
            "status": "success",
            "mode": "skill",
            "skill_name": skill.name,
            "output": result.output,
        }

    # Step 7: 危险命令检查
    if settings.guardrail_enabled and is_dangerous_command(content):
        reason = get_danger_reason(content) or "潜在危险命令"
        # 发送审批事件
        await step.send_event(
            "im/command-needs-approval",
            {
                "user_id": user_id,
                "command": content,
                "reason": reason,
                "session_name": session_name,
                "message_id": message_id,
            },
        )
        return {
            "status": "pending_approval",
            "mode": "nl",
            "reason": reason,
            "message": f"⚠ 危险命令需要确认: {reason}",
        }

    # Step 8: 正常命令执行
    await step.run(
        "execute-command",
        lambda: tmux.send_keys(session_name, content, enter=True),
    )

    # Step 9: 捕获输出
    output = await step.run(
        "capture-output",
        lambda: tmux.capture_pane(session_name),
    )

    # Step 10: 记录日志
    log_skill = skill_registry.get("log")
    if log_skill:
        log_skill.log_command(user_id, session_name, content, output[:500])

    return {
        "status": "success",
        "mode": "nl",
        "output": output[-2000:],
    }
```

- [ ] **Step 3: 提交工作流骨架**

```bash
git add src/butler/workflows/
git commit -m "feat: add handle_message workflow skeleton"
```

---

### Task 5.2: 审批工作流

**Files:**
- Create: `src/butler/workflows/guardrail.py`

- [ ] **Step 1: 实现审批工作流**

```python
# src/butler/workflows/guardrail.py
"""命令准入审批工作流。"""

import inngest

from butler.config import settings
from butler.session.wrapper import TmuxWrapper
from butler.workflows import inngest_client

tmux = TmuxWrapper(session_prefix=settings.session_prefix)

@inngest_client.function(
    id="command-guardrail",
    name="Command Guardrail",
    trigger=inngest.TriggerEvent(event="im/command-needs-approval"),
)
async def command_guardrail(ctx: inngest.Context, step: inngest.Step) -> dict:
    """
    危险命令审批流程。

    事件参数:
        user_id: 用户 ID
        command: 命令内容
        reason: 危险原因
        session_name: 会话名称
    """
    user_id = ctx.event.data.get("user_id", "")
    command = ctx.event.data.get("command", "")
    reason = ctx.event.data.get("reason", "未知原因")
    session_name = ctx.event.data.get("session_name", "")

    # Step 1: 发送确认请求（通过回调到 QQ）
    # 这里简化处理，实际需要调用 gateway 发送消息
    confirmation_message = f"""⚠ 危险命令检测

命令: `{command}`
原因: {reason}

回复 "确认" 执行，回复 "取消" 放弃
超时时间: {settings.guardrail_approval_timeout} 秒"""

    # Step 2: 等待用户回复
    try:
        approval = await step.wait_for_event(
            "wait-approval",
            event="im/approval-response",
            timeout=settings.guardrail_approval_timeout,
            filter={"user_id": user_id},
        )

        if approval.data.get("approved"):
            # 执行命令
            await step.run(
                "execute-approved",
                lambda: tmux.send_keys(session_name, command, enter=True),
            )
            output = await step.run(
                "capture-output",
                lambda: tmux.capture_pane(session_name),
            )
            return {
                "status": "approved",
                "executed": True,
                "output": output[-1000:],
            }
        else:
            return {
                "status": "rejected",
                "executed": False,
            }

    except inngest.errors.WaitForEventTimeout:
        return {
            "status": "timeout",
            "executed": False,
            "message": "审批超时，命令已取消",
        }
```

- [ ] **Step 2: 提交审批工作流**

```bash
git add src/butler/workflows/guardrail.py
git commit -m "feat: add command guardrail workflow"
```

---

## Chunk 6: QQ Gateway

### Task 6.1: Gateway 模块

**Files:**
- Create: `src/butler/gateway/__init__.py`
- Create: `src/butler/gateway/events.py`
- Create: `src/butler/gateway/napcat.py`

- [ ] **Step 1: 创建 Gateway 初始化**

```python
# src/butler/gateway/__init__.py
"""QQ Gateway 模块。"""

from butler.gateway.napcat import NapCatGateway

__all__ = ["NapCatGateway"]
```

```python
# src/butler/gateway/events.py
"""消息事件定义。"""

from butler.session.state import NapCatEvent

__all__ = ["NapCatEvent"]
```

- [ ] **Step 2: 实现 NapCat Gateway**

```python
# src/butler/gateway/napcat.py
"""NapCatQQ WebSocket 网关。"""

import asyncio
import json
from typing import Callable, Optional

import websockets
from websockets.client import WebSocketClientProtocol

from butler.session.state import NapCatEvent

class NapCatGateway:
    """NapCatQQ WebSocket 网关。"""

    def __init__(
        self,
        ws_url: str = "ws://localhost:3001",
        access_token: str = "",
    ):
        """
        初始化网关。

        Args:
            ws_url: WebSocket URL
            access_token: 访问令牌
        """
        self.ws_url = ws_url
        self.access_token = access_token
        self.message_handler: Optional[Callable[[NapCatEvent], None]] = None
        self._ws: Optional[WebSocketClientProtocol] = None
        self._running = False

    def on_message(self, handler: Callable[[NapCatEvent], None]) -> None:
        """
        设置消息处理器。

        Args:
            handler: 消息处理函数
        """
        self.message_handler = handler

    async def connect(self) -> None:
        """建立 WebSocket 连接并监听消息。"""
        headers = {}
        if self.access_token:
            headers["Authorization"] = f"Bearer {self.access_token}"

        self._running = True

        while self._running:
            try:
                async with websockets.connect(
                    self.ws_url,
                    extra_headers=headers,
                    ping_interval=30,
                    ping_timeout=10,
                ) as ws:
                    self._ws = ws
                    await self._listen(ws)
            except Exception as e:
                print(f"Gateway connection error: {e}")
                if self._running:
                    await asyncio.sleep(5)  # 重连间隔

    async def _listen(self, ws: WebSocketClientProtocol) -> None:
        """监听 WebSocket 消息。"""
        async for raw_message in ws:
            try:
                data = json.loads(raw_message)

                # 处理 NapCat 事件格式
                if isinstance(data, dict):
                    event = NapCatEvent(
                        time=data.get("time", 0),
                        self_id=data.get("self_id", 0),
                        post_type=data.get("post_type", ""),
                        message_type=data.get("message_type", ""),
                        user_id=data.get("user_id", 0),
                        message=self._extract_message(data),
                        raw_message=str(data),
                    )

                    # 只处理私聊消息
                    if event.is_private_message and self.message_handler:
                        self.message_handler(event)

            except Exception as e:
                print(f"Error processing message: {e}")

    def _extract_message(self, data: dict) -> str:
        """提取消息文本。"""
        message = data.get("message", "")
        if isinstance(message, str):
            return message
        elif isinstance(message, list):
            # 处理消息段格式
            texts = []
            for seg in message:
                if isinstance(seg, dict) and seg.get("type") == "text":
                    texts.append(seg.get("data", {}).get("text", ""))
            return "".join(texts)
        return str(message)

    async def send_private_message(self, user_id: int, message: str) -> None:
        """
        发送私聊消息。

        Args:
            user_id: 目标用户 ID
            message: 消息内容
        """
        if not self._ws:
            return

        payload = {
            "action": "send_private_msg",
            "params": {
                "user_id": user_id,
                "message": message,
            },
        }
        await self._ws.send(json.dumps(payload))

    async def stop(self) -> None:
        """停止网关。"""
        self._running = False
        if self._ws:
            await self._ws.close()
```

- [ ] **Step 3: 提交 Gateway 模块**

```bash
git add src/butler/gateway/
git commit -m "feat: add NapCatQQ WebSocket gateway"
```

---

## Chunk 7: 主入口和部署

### Task 7.1: FastAPI 主入口

**Files:**
- Create: `src/butler/main.py`

- [ ] **Step 1: 实现 FastAPI 主入口**

```python
# src/butler/main.py
"""FastAPI 主入口。"""

import asyncio
import logging
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI
from fastapi.responses import JSONResponse

from butler.config import settings
from butler.gateway.napcat import NapCatGateway
from butler.workflows import inngest_client
from butler.workflows.handle_message import handle_im_message
from butler.workflows.guardrail import command_guardrail

# 配置日志
logging.basicConfig(
    level=getattr(logging, settings.log_level),
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

# 初始化 Gateway
gateway = NapCatGateway(
    ws_url=settings.napcat_ws_url,
    access_token=settings.napcat_token,
)

async def run_gateway():
    """后台运行 Gateway。"""
    async def handle_message(event):
        """处理 QQ 消息。"""
        logger.info(f"Received message from {event.user_id}: {event.message[:50]}...")

        # 转发到 Inngest
        await inngest_client.send(
            inngest_client.Event(
                name="im/message",
                data={
                    "user_id": str(event.user_id),
                    "content": event.message,
                },
            )
        )

    gateway.on_message(handle_message)
    await gateway.connect()

@asynccontextmanager
async def lifespan(app: FastAPI):
    """应用生命周期管理。"""
    # 启动时
    logger.info("Starting Butler-Shell...")

    # 启动 Gateway 后台任务
    gateway_task = asyncio.create_task(run_gateway())

    yield

    # 关闭时
    logger.info("Shutting down Butler-Shell...")
    await gateway.stop()
    gateway_task.cancel()

# 创建 FastAPI 应用
app = FastAPI(
    title="Butler-Shell",
    description="Persistent Shell Assistant with QQ Bridge",
    version="0.1.0",
    lifespan=lifespan,
)

# 注册 Inngest 函数
inngest_client.register(handle_im_message)
inngest_client.register(command_guardrail)

# Inngest Webhook 端点
app.include_router(
    inngest_client.serve(),
    prefix="/api/inngest",
)

# 健康检查端点
@app.get("/health")
async def health():
    """服务存活检查。"""
    return {"status": "ok"}

@app.get("/ready")
async def ready():
    """服务就绪检查。"""
    # 检查 tmux 是否可用
    import subprocess
    try:
        subprocess.run(["tmux", "-V"], capture_output=True, check=True)
        tmux_ok = True
    except Exception:
        tmux_ok = False

    return {
        "status": "ready" if tmux_ok else "degraded",
        "tmux": tmux_ok,
        "gateway": gateway._ws is not None,
    }

# 用户审批端点（用于外部回调）
@app.post("/api/approval")
async def handle_approval(user_id: str, approved: bool):
    """处理用户审批响应。"""
    await inngest_client.send(
        inngest_client.Event(
            name="im/approval-response",
            data={
                "user_id": user_id,
                "approved": approved,
            },
        )
    )
    return {"status": "sent"}

def main():
    """启动服务。"""
    uvicorn.run(
        "butler.main:app",
        host="0.0.0.0",
        port=8000,
        reload=False,
    )

if __name__ == "__main__":
    main()
```

- [ ] **Step 2: 提交主入口**

```bash
git add src/butler/main.py
git commit -m "feat: add FastAPI main entry point"
```

---

### Task 7.2: Docker 部署配置

**Files:**
- Create: `docker-compose.yml`
- Create: `Dockerfile`

- [ ] **Step 1: 创建 Dockerfile**

```dockerfile
# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# 安装系统依赖
RUN apt-get update && apt-get install -y \
    tmux \
    procps \
    && rm -rf /var/lib/apt/lists/*

# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制代码
COPY src/ ./src/
COPY config.yaml .
COPY .env.example .env

# 创建数据目录
RUN mkdir -p /app/data

# 暴露端口
EXPOSE 8000

# 启动命令
CMD ["python", "-m", "butler.main"]
```

- [ ] **Step 2: 创建 docker-compose.yml**

```yaml
# docker-compose.yml
version: '3.8'

services:
  butler:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - ./data:/app/data
      - ./config.yaml:/app/config.yaml:ro
    environment:
      - BUTLER_ALLOWED_USERS=${BUTLER_ALLOWED_USERS:-}
      - NAPCAT_WS_URL=${NAPCAT_WS_URL:-ws://napcat:3001}
      - NAPCAT_TOKEN=${NAPCAT_TOKEN:-}
      - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY:-dev_key}
      - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:-}
    depends_on:
      - inngest
    restart: unless-stopped

  inngest:
    image: inngest/inngest:latest
    ports:
      - "8288:8288"
    command: start --url http://butler:8000/api/inngest
    restart: unless-stopped

  napcat:
    image: mlikiowa/napcat-docker:latest
    ports:
      - "3001:3001"
    volumes:
      - ./napcat-data:/app/napcat/config
    restart: unless-stopped
```

- [ ] **Step 3: 提交部署配置**

```bash
git add docker-compose.yml Dockerfile
git commit -m "feat: add Docker deployment configuration"
```

---

### Task 7.3: Mock 测试脚本

**Files:**
- Create: `scripts/mock_test.py`

- [ ] **Step 1: 创建 Mock 测试脚本**

```python
#!/usr/bin/env python3
"""Mock 测试脚本 - 模拟 QQ 消息输入。"""

import asyncio
import httpx
import sys

async def send_mock_message(user_id: str, content: str):
    """发送模拟消息到 Butler-Shell。"""
    async with httpx.AsyncClient() as client:
        # 直接调用 Inngest 端点模拟消息
        response = await client.post(
            "http://localhost:8000/api/inngest",
            json={
                "name": "im/message",
                "data": {
                    "user_id": user_id,
                    "content": content,
                },
            },
            headers={
                "Content-Type": "application/json",
            },
        )
        print(f"Response: {response.status_code}")
        print(f"Body: {response.text}")

async def main():
    """主函数。"""
    if len(sys.argv) < 2:
        print("Usage: python mock_test.py <command> [user_id]")
        print("\nCommands:")
        print("  normal <cmd>   - 发送普通命令")
        print("  dangerous      - 发送危险命令测试")
        print("  skill          - 发送 Skill 命令")
        return

    user_id = sys.argv[2] if len(sys.argv) > 2 else "test_user_001"
    command = sys.argv[1]

    if command == "normal":
        content = sys.argv[3] if len(sys.argv) > 3 else "ls -la"
        await send_mock_message(user_id, content)
    elif command == "dangerous":
        await send_mock_message(user_id, "rm -rf /tmp/test")
    elif command == "skill":
        await send_mock_message(user_id, "/log recent 5")
    else:
        await send_mock_message(user_id, command)

if __name__ == "__main__":
    asyncio.run(main())
```

- [ ] **Step 2: 提交 Mock 测试脚本**

```bash
mkdir -p scripts
git add scripts/mock_test.py
git commit -m "feat: add mock test script for QQ message simulation"
```

---

## 完成检查清单

实施完成后，确保以下功能正常：

- [ ] 所有测试通过: `pytest tests/ -v`
- [ ] 类型检查通过: `mypy src/`
- [ ] 代码格式正确: `ruff check src/`
- [ ] 服务可启动: `python -m butler.main`
- [ ] 健康检查正常: `curl http://localhost:8000/health`
- [ ] Mock 测试可运行: `python scripts/mock_test.py normal "ls -la"`

---

**计划完成。准备执行？**

+    event_key=settings.inngest_event_key or "dev_key",

+    signing_key=settings.inngest_signing_key or None,

+- [ ] **Step 2: 实现 handle_message 工作流骨架**

+"""主消息处理工作流。"""

+from butler.session import SessionMode, SessionState

+from butler.session.wrapper import TmuxWrapper

+from butler.security.auth import is_authorized

+from butler.security.guardrail import is_dangerous_command, get_danger_reason

+from butler.skills.base import SkillRegistry

+from butler.skills.log_skill import LogSkill

+from butler.skills.system_skill import SystemSkill

+from butler.workflows import inngest_client

+    trigger=inngest.TriggerEvent(event="im/message"),

+async def handle_im_message(ctx: inngest.Context, step: inngest.Step) -> dict[str, Any]:

+    主消息处理流程。

+    user_id = ctx.event.data.get("user_id", "")

+    content = ctx.event.data.get("content", "")

+    message_id = ctx.event.data.get("message_id", str(uuid.uuid4()))

+    if not is_authorized(user_id, settings.allowed_users):

+            "message": f"用户 {user_id} 无权限使用此服务",

+        lambda: tmux.get_or_create_session(user_id),

+    # Step 3: 加载会话状态（简化版，实际应使用 ctx.state）

+    state = SessionState(session_id=session_name, user_id=user_id)

+        lambda: tmux.detect_mode(session_name),

+    if current_mode == SessionMode.INTERACTIVE:

+        # 交互模式：直接透传

+            lambda: tmux.send_keys(session_name, content, enter=False),

+            lambda: tmux.capture_pane(session_name),

+            "output": output[-2000:],  # 限制输出长度

+    skill = skill_registry.find_matching(content)

+        args = skill.parse_args(content)

+        from butler.skills.base import SkillContext

+            session_state=state.model_dump(),

+            lambda: skill.execute(skill_ctx),

+    if settings.guardrail_enabled and is_dangerous_command(content):

+        reason = get_danger_reason(content) or "潜在危险命令"

+        # 发送审批事件

+            "im/command-needs-approval",

+                "session_name": session_name,

+                "message_id": message_id,

+            "status": "pending_approval",

+            "message": f"⚠ 危险命令需要确认: {reason}",

+        lambda: tmux.send_keys(session_name, content, enter=True),

+        lambda: tmux.capture_pane(session_name),

+    log_skill = skill_registry.get("log")

+        log_skill.log_command(user_id, session_name, content, output[:500])

+git commit -m "feat: add handle_message workflow skeleton"

+"""命令准入审批工作流。"""

+from butler.session.wrapper import TmuxWrapper

+from butler.workflows import inngest_client

+    trigger=inngest.TriggerEvent(event="im/command-needs-approval"),

+async def command_guardrail(ctx: inngest.Context, step: inngest.Step) -> dict:

+    危险命令审批流程。

+    user_id = ctx.event.data.get("user_id", "")

+    command = ctx.event.data.get("command", "")

+    reason = ctx.event.data.get("reason", "未知原因")

+    session_name = ctx.event.data.get("session_name", "")

+    # Step 1: 发送确认请求（通过回调到 QQ）

+    # 这里简化处理，实际需要调用 gateway 发送消息

+    confirmation_message = f"""⚠ 危险命令检测

+回复 "确认" 执行，回复 "取消" 放弃

+        approval = await step.wait_for_event(

+            event="im/approval-response",

+            timeout=settings.guardrail_approval_timeout,

+            filter={"user_id": user_id},

+        if approval.data.get("approved"):

+            # 执行命令

+                lambda: tmux.send_keys(session_name, command, enter=True),

+                lambda: tmux.capture_pane(session_name),

+    except inngest.errors.WaitForEventTimeout:

+git commit -m "feat: add command guardrail workflow"

+from butler.gateway.napcat import NapCatGateway

+"""消息事件定义。"""

+from butler.session.state import NapCatEvent

+from websockets.client import WebSocketClientProtocol

+        ws_url: str = "ws://localhost:3001",

+        初始化网关。

+        self.access_token = access_token

+        self.message_handler: Optional[Callable[[NapCatEvent], None]] = None

+        self._ws: Optional[WebSocketClientProtocol] = None

+    def on_message(self, handler: Callable[[NapCatEvent], None]) -> None:

+        设置消息处理器。

+            headers["Authorization"] = f"Bearer {self.access_token}"

+                async with websockets.connect(

+                    extra_headers=headers,

+                    await self._listen(ws)

+                print(f"Gateway connection error: {e}")

+                    await asyncio.sleep(5)  # 重连间隔

+    async def _listen(self, ws: WebSocketClientProtocol) -> None:

+                data = json.loads(raw_message)

+                if isinstance(data, dict):

+                    event = NapCatEvent(

+                        time=data.get("time", 0),

+                        self_id=data.get("self_id", 0),

+                        post_type=data.get("post_type", ""),

+                        message_type=data.get("message_type", ""),

+                        user_id=data.get("user_id", 0),

+                        message=self._extract_message(data),

+                        raw_message=str(data),

+                    # 只处理私聊消息

+                    if event.is_private_message and self.message_handler:

+                        self.message_handler(event)

+                print(f"Error processing message: {e}")

+    def _extract_message(self, data: dict) -> str:

+        """提取消息文本。"""

+        message = data.get("message", "")

+        elif isinstance(message, list):

+            # 处理消息段格式

+                if isinstance(seg, dict) and seg.get("type") == "text":

+                    texts.append(seg.get("data", {}).get("text", ""))

+    async def send_private_message(self, user_id: int, message: str) -> None:

+        发送私聊消息。

+            "action": "send_private_msg",

+        await self._ws.send(json.dumps(payload))

+git commit -m "feat: add NapCatQQ WebSocket gateway"

+from contextlib import asynccontextmanager

+from fastapi.responses import JSONResponse

+from butler.workflows import inngest_client

+from butler.workflows.handle_message import handle_im_message

+from butler.workflows.guardrail import command_guardrail

+    level=getattr(logging, settings.log_level),

+    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",

+    access_token=settings.napcat_token,

+        logger.info(f"Received message from {event.user_id}: {event.message[:50]}...")

+                    "user_id": str(event.user_id),

+                    "content": event.message,

+    """应用生命周期管理。"""

+    logger.info("Starting Butler-Shell...")

+    gateway_task = asyncio.create_task(run_gateway())

+    logger.info("Shutting down Butler-Shell...")

+    description="Persistent Shell Assistant with QQ Bridge",

+    """服务存活检查。"""

+    """服务就绪检查。"""

+    # 检查 tmux 是否可用

+        subprocess.run(["tmux", "-V"], capture_output=True, check=True)

+        "status": "ready" if tmux_ok else "degraded",

+        "gateway": gateway._ws is not None,

+# 用户审批端点（用于外部回调）

+async def handle_approval(user_id: str, approved: bool):

+    """处理用户审批响应。"""

+            name="im/approval-response",

+git commit -m "feat: add FastAPI main entry point"

+RUN apt-get update && apt-get install -y \

+RUN pip install --no-cache-dir -r requirements.txt

+- [ ] **Step 2: 创建 docker-compose.yml**

+      - ./config.yaml:/app/config.yaml:ro

+      - BUTLER_ALLOWED_USERS=${BUTLER_ALLOWED_USERS:-}

+      - NAPCAT_WS_URL=${NAPCAT_WS_URL:-ws://napcat:3001}

+      - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY:-dev_key}

+      - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:-}

+    command: start --url http://butler:8000/api/inngest

+    image: mlikiowa/napcat-docker:latest

+      - ./napcat-data:/app/napcat/config

+git commit -m "feat: add Docker deployment configuration"

+async def send_mock_message(user_id: str, content: str):

+    async with httpx.AsyncClient() as client:

+        # 直接调用 Inngest 端点模拟消息

+            "http://localhost:8000/api/inngest",

+                    "user_id": user_id,

+                    "content": content,

+                "Content-Type": "application/json",

+        print(f"Response: {response.status_code}")

+        print(f"Body: {response.text}")

+        print("Usage: python mock_test.py <command> [user_id]")

+        print("  normal <cmd>   - 发送普通命令")

+        print("  dangerous      - 发送危险命令测试")

+        print("  skill          - 发送 Skill 命令")

+    user_id = sys.argv[2] if len(sys.argv) > 2 else "test_user_001"

+        content = sys.argv[3] if len(sys.argv) > 3 else "ls -la"

+        await send_mock_message(user_id, content)

+        await send_mock_message(user_id, "rm -rf /tmp/test")

+        await send_mock_message(user_id, "/log recent 5")

+        await send_mock_message(user_id, command)

+git commit -m "feat: add mock test script for QQ message simulation"

+实施完成后，确保以下功能正常：

+- [ ] 健康检查正常: `curl http://localhost:8000/health`

+- [ ] Mock 测试可运行: `python scripts/mock_test.py normal "ls -la"`

+**计划完成。准备执行？**


实施计划已完成。现在我需要更新任务状态，并告知用户计划已保存，准备执行。


Updated task #7 status


实施计划已经完成。现在需要退出 plan mode 并让用户审查计划。


计划已完成并保存到 `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`。

**计划概要：**

| Chunk | 任务 | 文件数 |
|-------|------|--------|
| 1 | 项目基础设施 | 5 个核心文件 |
| 2 | 安全模块 | 4 个文件 |
| 3 | TmuxWrapper 核心类 | 3 个文件 |
| 4 | Skill 扩展系统 | 4 个文件 |
| 5 | Inngest 工作流 | 3 个文件 |
| 6 | QQ Gateway | 3 个文件 |
| 7 | 主入口和部署 | 4 个文件 |

**总计：约 26 个新建文件，按 TDD 方式分步实施。**


<tool_use_error>You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation.</tool_use_error>

Error: You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation.


我需要将计划文件路径写入到系统指定的位置。用户已经完成了设计规范的审查，现在我需要将计划写入到 plan 文件中，然后调用 ExitPlanMode。

让我先检查 plan 文件的位置 - 根据系统提示，计划应该写入到 /root/.claude/plans/recursive-knitting-river.md


现在让我将计划写入到系统指定的计划文件位置。