Write

# Butler-Shell 实施计划

> **Status:** Ready for implementation

**Goal:** 构建基于 Inngest 与 tmux 的持久化 Shell 助手，通过 QQ 接收指令并维护持久化会话

**Architecture:** 分层单体架构 - QQ Gateway → Inngest Workflows → Session Manager (TmuxWrapper) → tmux sessions

**Tech Stack:** Python 3.11+, Inngest (Python SDK), tmux, NapCatQQ (WebSocket), Pydantic, SQLite, FastAPI

**Spec:** `docs/superpowers/specs/2026-04-07-butler-shell-design.md`

**Detailed Plan:** `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`

---

## 实施阶段概要

### Phase 1: 核心会话层 (Chunk 1-3)
- 项目基础设施
- 配置管理模块
- 核心数据模型
- 安全模块（认证、危险命令检测、敏感信息过滤）
- TmuxWrapper 核心类

### Phase 2: Skill 扩展系统 (Chunk 4)
- Skill 基类和注册表
- LogSkill 实现
- SystemSkill 实现

### Phase 3: Inngest 工作流 (Chunk 5)
- handle_message 工作流
- command_guardrail 审批流程

### Phase 4: QQ Gateway (Chunk 6)
- NapCatQQ WebSocket 集成
- 消息转换

### Phase 5: 主入口和部署 (Chunk 7)
- FastAPI 主入口
- Docker 部署配置
- Mock 测试脚本

---

## 关键文件清单

```
src/butler/
├── __init__.py
├── main.py                     # FastAPI 入口
├── config.py                   # 配置管理
├── session/
│   ├── state.py               # 数据模型
│   └── wrapper.py             # TmuxWrapper
├── security/
│   ├── auth.py                # 认证授权
│   ├── guardrail.py           # 危险命令检测
│   └── sanitizer.py           # 敏感信息过滤
├── skills/
│   ├── base.py                # Skill 基类
│   ├── log_skill.py           # 日志 Skill
│   └── system_skill.py        # 系统信息 Skill
├── workflows/
│   ├── handle_message.py      # 消息处理工作流
│   └── guardrail.py           # 审批工作流
└── gateway/
    └── napcat.py              # QQ Gateway
```

---

## 验证步骤

完成所有阶段后执行：

```bash
# 1. 运行测试
pytest tests/ -v

# 2. 类型检查
mypy src/

# 3. 代码格式
ruff check src/

# 4. 启动服务
python -m butler.main

# 5. 健康检查
curl http://localhost:8000/health

# 6. Mock 测试
python scripts/mock_test.py normal "ls -la"
```


File created successfully at: /root/.claude/plans/recursive-knitting-river.md

# Butler-Shell 实施计划

> **Status:** Ready for implementation

**Goal:** 构建基于 Inngest 与 tmux 的持久化 Shell 助手，通过 QQ 接收指令并维护持久化会话

**Architecture:** 分层单体架构 - QQ Gateway → Inngest Workflows → Session Manager (TmuxWrapper) → tmux sessions

**Tech Stack:** Python 3.11+, Inngest (Python SDK), tmux, NapCatQQ (WebSocket), Pydantic, SQLite, FastAPI

**Spec:** `docs/superpowers/specs/2026-04-07-butler-shell-design.md`

**Detailed Plan:** `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`

---

## 实施阶段概要

### Phase 1: 核心会话层 (Chunk 1-3)
- 项目基础设施
- 配置管理模块
- 核心数据模型
- 安全模块（认证、危险命令检测、敏感信息过滤）
- TmuxWrapper 核心类

### Phase 2: Skill 扩展系统 (Chunk 4)
- Skill 基类和注册表
- LogSkill 实现
- SystemSkill 实现

### Phase 3: Inngest 工作流 (Chunk 5)
- handle_message 工作流
- command_guardrail 审批流程

### Phase 4: QQ Gateway (Chunk 6)
- NapCatQQ WebSocket 集成
- 消息转换

### Phase 5: 主入口和部署 (Chunk 7)
- FastAPI 主入口
- Docker 部署配置
- Mock 测试脚本

---

## 关键文件清单

```
src/butler/
├── __init__.py
├── main.py                     # FastAPI 入口
├── config.py                   # 配置管理
├── session/
│   ├── state.py               # 数据模型
│   └── wrapper.py             # TmuxWrapper
├── security/
│   ├── auth.py                # 认证授权
│   ├── guardrail.py           # 危险命令检测
│   └── sanitizer.py           # 敏感信息过滤
├── skills/
│   ├── base.py                # Skill 基类
│   ├── log_skill.py           # 日志 Skill
│   └── system_skill.py        # 系统信息 Skill
├── workflows/
│   ├── handle_message.py      # 消息处理工作流
│   └── guardrail.py           # 审批工作流
└── gateway/
    └── napcat.py              # QQ Gateway
```

---

## 验证步骤

完成所有阶段后执行：

```bash
# 1. 运行测试
pytest tests/ -v

# 2. 类型检查
mypy src/

# 3. 代码格式
ruff check src/

# 4. 启动服务
python -m butler.main

# 5. 健康检查
curl http://localhost:8000/health

# 6. Mock 测试
python scripts/mock_test.py normal "ls -la"
```


计划已经完成，我应该退出计划模式让用户审批。让我使用 ExitPlanMode 工具。


ExitPlanMode

# Butler-Shell 实施计划

> **Status:** Ready for implementation

**Goal:** 构建基于 Inngest 与 tmux 的持久化 Shell 助手，通过 QQ 接收指令并维护持久化会话

**Architecture:** 分层单体架构 - QQ Gateway → Inngest Workflows → Session Manager (TmuxWrapper) → tmux sessions

**Tech Stack:** Python 3.11+, Inngest (Python SDK), tmux, NapCatQQ (WebSocket), Pydantic, SQLite, FastAPI

**Spec:** `docs/superpowers/specs/2026-04-07-butler-shell-design.md`

**Detailed Plan:** `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`

---

## 实施阶段概要

### Phase 1: 核心会话层 (Chunk 1-3)
- 项目基础设施
- 配置管理模块
- 核心数据模型
- 安全模块（认证、危险命令检测、敏感信息过滤）
- TmuxWrapper 核心类

### Phase 2: Skill 扩展系统 (Chunk 4)
- Skill 基类和注册表
- LogSkill 实现
- SystemSkill 实现

### Phase 3: Inngest 工作流 (Chunk 5)
- handle_message 工作流
- command_guardrail 审批流程

### Phase 4: QQ Gateway (Chunk 6)
- NapCatQQ WebSocket 集成
- 消息转换

### Phase 5: 主入口和部署 (Chunk 7)
- FastAPI 主入口
- Docker 部署配置
- Mock 测试脚本

---

## 关键文件清单

```
src/butler/
├── __init__.py
├── main.py                     # FastAPI 入口
├── config.py                   # 配置管理
├── session/
│   ├── state.py               # 数据模型
│   └── wrapper.py             # TmuxWrapper
├── security/
│   ├── auth.py                # 认证授权
│   ├── guardrail.py           # 危险命令检测
│   └── sanitizer.py           # 敏感信息过滤
├── skills/
│   ├── base.py                # Skill 基类
│   ├── log_skill.py           # 日志 Skill
│   └── system_skill.py        # 系统信息 Skill
├── workflows/
│   ├── handle_message.py      # 消息处理工作流
│   └── guardrail.py           # 审批工作流
└── gateway/
    └── napcat.py              # QQ Gateway
```

---

## 验证步骤

完成所有阶段后执行：

```bash
# 1. 运行测试
pytest tests/ -v

# 2. 类型检查
mypy src/

# 3. 代码格式
ruff check src/

# 4. 启动服务
python -m butler.main

# 5. 健康检查
curl http://localhost:8000/health

# 6. Mock 测试
python scripts/mock_test.py normal "ls -la"
```


<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 mode 了。让我检查当前状态并告知用户计划已完成。


**实施计划已完成。**

计划文件已保存到：
- **详细计划:** `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`
- **概要计划:** `/root/.claude/plans/recursive-knitting-river.md`

**计划包含 7 个 Chunk，约 26 个文件：**

| 阶段 | 内容 | 关键交付物 |
|------|------|-----------|
| Chunk 1-2 | 项目基础设施 + 安全模块 | config, state, auth, guardrail, sanitizer |
| Chunk 3 | TmuxWrapper 核心类 | 会话管理、模式检测 |
| Chunk 4 | Skill 扩展系统 | 基类 + LogSkill + SystemSkill |
| Chunk 5 | Inngest 工作流 | handle_message + guardrail |
| Chunk 6 | QQ Gateway | NapCat WebSocket 客户端 |
| Chunk 7 | 主入口和部署 | FastAPI + Docker + Mock 脚本 |

**准备开始实施吗？** 使用 `/execute` 或告诉我开始执行。


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:check-console-log" "scripts/hooks/check-console-log.js" "standard,strict"

node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:session-end" "scripts/hooks/session-end.js" "minimal,standard,strict"

node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:evaluate-session" "scripts/hooks/evaluate-session.js" "minimal,standard,strict"

node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:cost-tracker" "scripts/hooks/cost-tracker.js" "minimal,standard,strict"


<command-message>everything-claude-code:multi-execute</command-message>
<command-name>/everything-claude-code:multi-execute</command-name>


# Execute - Multi-Model Collaborative Execution

Multi-model collaborative execution - Get prototype from plan → Claude refactors and implements → Multi-model audit and delivery.

---

## Core Protocols

- **Language Protocol**: Use **English** when interacting with tools/models, communicate with user in their language
- **Code Sovereignty**: External models have **zero filesystem write access**, all modifications by Claude
- **Dirty Prototype Refactoring**: Treat Codex/Gemini Unified Diff as "dirty prototype", must refactor to production-grade code
- **Stop-Loss Mechanism**: Do not proceed to next phase until current phase output is validated
- **Prerequisite**: Only execute after user explicitly replies "Y" to `/ccg:plan` output (if missing, must confirm first)

---

## Multi-Model Call Specification

**Call Syntax** (parallel: use `run_in_background: true`):

```
# Resume session call (recommended) - Implementation Prototype
Bash({
  command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
Context: <plan content + target files>
</TASK>
OUTPUT: Unified Diff Patch ONLY. Strictly prohibit any actual modifications.
EOF",
  run_in_background: true,
  timeout: 3600000,
  description: "Brief description"
})

# New session call - Implementation Prototype
Bash({
  command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}- \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Requirement: <task description>
Context: <plan content + target files>
</TASK>
OUTPUT: Unified Diff Patch ONLY. Strictly prohibit any actual modifications.
EOF",
  run_in_background: true,
  timeout: 3600000,
  description: "Brief description"
})
```

**Audit Call Syntax** (Code Review / Audit):

```
Bash({
  command: "~/.claude/bin/codeagent-wrapper {{LITE_MODE_FLAG}}--backend <codex|gemini> {{GEMINI_MODEL_FLAG}}resume <SESSION_ID> - \"$PWD\" <<'EOF'
ROLE_FILE: <role prompt path>
<TASK>
Scope: Audit the final code changes.
Inputs:
- The applied patch (git diff / final unified diff)
- The touched files (relevant excerpts if needed)
Constraints:
- Do NOT modify any files.
- Do NOT output tool commands that assume filesystem access.
</TASK>
OUTPUT:
1) A prioritized list of issues (severity, file, rationale)
2) Concrete fixes; if code changes are needed, include a Unified Diff Patch in a fenced code block.
EOF",
  run_in_background: true,
  timeout: 3600000,
  description: "Brief description"
})
```

**Model Parameter Notes**:
- `{{GEMINI_MODEL_FLAG}}`: When using `--backend gemini`, replace with `--gemini-model gemini-3-pro-preview` (note trailing space); use empty string for codex

**Role Prompts**:

| Phase | Codex | Gemini |
|-------|-------|--------|
| Implementation | `~/.claude/.ccg/prompts/codex/architect.md` | `~/.claude/.ccg/prompts/gemini/frontend.md` |
| Review | `~/.claude/.ccg/prompts/codex/reviewer.md` | `~/.claude/.ccg/prompts/gemini/reviewer.md` |

**Session Reuse**: If `/ccg:plan` provided SESSION_ID, use `resume <SESSION_ID>` to reuse context.

**Wait for Background Tasks** (max timeout 600000ms = 10 minutes):

```
TaskOutput({ task_id: "<task_id>", block: true, timeout: 600000 })
```

**IMPORTANT**:
- Must specify `timeout: 600000`, otherwise default 30 seconds will cause premature timeout
- If still incomplete after 10 minutes, continue polling with `TaskOutput`, **NEVER kill the process**
- If waiting is skipped due to timeout, **MUST call `AskUserQuestion` to ask user whether to continue waiting or kill task**

---

## Execution Workflow

**Execute Task**:

### Phase 0: Read Plan

`[Mode: Prepare]`

1. **Identify Input Type**:
   - Plan file path (e.g., `.claude/plan/xxx.md`)
   - Direct task description

2. **Read Plan Content**:
   - If plan file path provided, read and parse
   - Extract: task type, implementation steps, key files, SESSION_ID

3. **Pre-Execution Confirmation**:
   - If input is "direct task description" or plan missing `SESSION_ID` / key files: confirm with user first
   - If cannot confirm user replied "Y" to plan: must confirm again before proceeding

4. **Task Type Routing**:

   | Task Type | Detection | Route |
   |-----------|-----------|-------|
   | **Frontend** | Pages, components, UI, styles, layout | Gemini |
   | **Backend** | API, interfaces, database, logic, algorithms | Codex |
   | **Fullstack** | Contains both frontend and backend | Codex ∥ Gemini parallel |

---

### Phase 1: Quick Context Retrieval

`[Mode: Retrieval]`

**If ace-tool MCP is available**, use it for quick context retrieval:

Based on "Key Files" list in plan, call `mcp__ace-tool__search_context`:

```
mcp__ace-tool__search_context({
  query: "<semantic query based on plan content, including key files, modules, function names>",
  project_root_path: "$PWD"
})
```

**Retrieval Strategy**:
- Extract target paths from plan's "Key Files" table
- Build semantic query covering: entry files, dependency modules, related type definitions
- If results insufficient, add 1-2 recursive retrievals

**If ace-tool MCP is NOT available**, use Claude Code built-in tools as fallback:
1. **Glob**: Find target files from plan's "Key Files" table (e.g., `Glob("src/components/**/*.tsx")`)
2. **Grep**: Search for key symbols, function names, type definitions across the codebase
3. **Read**: Read the discovered files to gather complete context
4. **Task (Explore agent)**: For broader exploration, use `Task` with `subagent_type: "Explore"`

**After Retrieval**:
- Organize retrieved code snippets
- Confirm complete context for implementation
- Proceed to Phase 3

---

### Phase 3: Prototype Acquisition

`[Mode: Prototype]`

**Route Based on Task Type**:

#### Route A: Frontend/UI/Styles → Gemini

**Limit**: Context < 32k tokens

1. Call Gemini (use `~/.claude/.ccg/prompts/gemini/frontend.md`)
2. Input: Plan content + retrieved context + target files
3. OUTPUT: `Unified Diff Patch ONLY. Strictly prohibit any actual modifications.`
4. **Gemini is frontend design authority, its CSS/React/Vue prototype is the final visual baseline**
5. **WARNING**: Ignore Gemini's backend logic suggestions
6. If plan contains `GEMINI_SESSION`: prefer `resume <GEMINI_SESSION>`

#### Route B: Backend/Logic/Algorithms → Codex

1. Call Codex (use `~/.claude/.ccg/prompts/codex/architect.md`)
2. Input: Plan content + retrieved context + target files
3. OUTPUT: `Unified Diff Patch ONLY. Strictly prohibit any actual modifications.`
4. **Codex is backend logic authority, leverage its logical reasoning and debug capabilities**
5. If plan contains `CODEX_SESSION`: prefer `resume <CODEX_SESSION>`

#### Route C: Fullstack → Parallel Calls

1. **Parallel Calls** (`run_in_background: true`):
   - Gemini: Handle frontend part
   - Codex: Handle backend part
2. Wait for both models' complete results with `TaskOutput`
3. Each uses corresponding `SESSION_ID` from plan for `resume` (create new session if missing)

**Follow the `IMPORTANT` instructions in `Multi-Model Call Specification` above**

---

### Phase 4: Code Implementation

`[Mode: Implement]`

**Claude as Code Sovereign executes the following steps**:

1. **Read Diff**: Parse Unified Diff Patch returned by Codex/Gemini

2. **Mental Sandbox**:
   - Simulate applying Diff to target files
   - Check logical consistency
   - Identify potential conflicts or side effects

3. **Refactor and Clean**:
   - Refactor "dirty prototype" to **highly readable, maintainable, enterprise-grade code**
   - Remove redundant code
   - Ensure compliance with project's existing code standards
   - **Do not generate comments/docs unless necessary**, code should be self-explanatory

4. **Minimal Scope**:
   - Changes limited to requirement scope only
   - **Mandatory review** for side effects
   - Make targeted corrections

5. **Apply Changes**:
   - Use Edit/Write tools to execute actual modifications
   - **Only modify necessary code**, never affect user's other existing functionality

6. **Self-Verification** (strongly recommended):
   - Run project's existing lint / typecheck / tests (prioritize minimal related scope)
   - If failed: fix regressions first, then proceed to Phase 5

---

### Phase 5: Audit and Delivery

`[Mode: Audit]`

#### 5.1 Automatic Audit

**After changes take effect, MUST immediately parallel call** Codex and Gemini for Code Review:

1. **Codex Review** (`run_in_background: true`):
   - ROLE_FILE: `~/.claude/.ccg/prompts/codex/reviewer.md`
   - Input: Changed Diff + target files
   - Focus: Security, performance, error handling, logic correctness

2. **Gemini Review** (`run_in_background: true`):
   - ROLE_FILE: `~/.claude/.ccg/prompts/gemini/reviewer.md`
   - Input: Changed Diff + target files
   - Focus: Accessibility, design consistency, user experience

Wait for both models' complete review results with `TaskOutput`. Prefer reusing Phase 3 sessions (`resume <SESSION_ID>`) for context consistency.

#### 5.2 Integrate and Fix

1. Synthesize Codex + Gemini review feedback
2. Weigh by trust rules: Backend follows Codex, Frontend follows Gemini
3. Execute necessary fixes
4. Repeat Phase 5.1 as needed (until risk is acceptable)

#### 5.3 Delivery Confirmation

After audit passes, report to user:

```markdown
## Execution Complete

### Change Summary
| File | Operation | Description |
|------|-----------|-------------|
| path/to/file.ts | Modified | Description |

### Audit Results
- Codex: <Passed/Found N issues>
- Gemini: <Passed/Found N issues>

### Recommendations
1. [ ] <Suggested test steps>
2. [ ] <Suggested verification steps>
```

---

## Key Rules

1. **Code Sovereignty** – All file modifications by Claude, external models have zero write access
2. **Dirty Prototype Refactoring** – Codex/Gemini output treated as draft, must refactor
3. **Trust Rules** – Backend follows Codex, Frontend follows Gemini
4. **Minimal Changes** – Only modify necessary code, no side effects
5. **Mandatory Audit** – Must perform multi-model Code Review after changes

---

## Usage

```bash
# Execute plan file
/ccg:execute .claude/plan/feature-name.md

# Execute task directly (for plans already discussed in context)
/ccg:execute implement user authentication based on previous plan
```

---

## Relationship with /ccg:plan

1. `/ccg:plan` generates plan + SESSION_ID
2. User confirms with "Y"
3. `/ccg:execute` reads plan, reuses SESSION_ID, executes implementation


用户请求执行 `/everything-claude-code:multi-execute`，这是一个多模型协作执行技能。

让我分析当前情况：
1. 我已经完成了设计规范文档和实施计划的编写
2. 计划文件位于 `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`
3. 这是一个后端为主的 Python 项目

按照技能要求，我需要：
1. Phase 0: 读取计划
2. Phase 1: 快速上下文检索
3. Phase 3: 原型获取
4. Phase 4: 代码实现
5. Phase 5: 审计和交付

首先，我需要确认用户是否已经对计划回复了 "Y"。根据之前的对话，用户确认了设计规范，但没有明确确认实施计划。

让我先确认一下，然后开始执行。

由于这是一个后端项目（Python + Inngest + tmux），应该路由到 Codex。

但是，我注意到这个技能需要使用 codeagent-wrapper 工具，这可能是一个外部工具。让我检查一下这个工具是否存在。

另外，根据技能说明，我需要先确认用户是否已经回复 "Y" 到计划。如果没有，需要先确认。

让我先询问用户确认，然后开始执行。


我来执行多模型协作实施流程。

## Phase 0: 读取计划

首先确认计划和用户意图：

**计划文件:** `docs/superpowers/plans/2026-04-07-butler-shell-implementation.md`

**任务类型:** 后端 - 这是一个 Python 后端项目，使用 Inngest + tmux + FastAPI

**路由:** Codex（后端逻辑）

**需要确认:** 你是否确认开始执行此实施计划？请回复 "Y" 确认，或者告诉我需要先修改计划。

---

**计划摘要:**
- 7 个 Chunk，约 26 个文件
- 从项目基础设施开始 → 安全模块 → TmuxWrapper → Skills → 工作流 → Gateway → 部署
- 遵循 TDD 流程，每步先写测试再实现


AskUserQuestion

Choices:
开始多模型协作执行，Codex 提供原型，Claude 实施并审计
先查看或修改计划内容

Question: 是否确认开始执行 Butler-Shell 实施计划？


User has answered your questions: "是否确认开始执行 Butler-Shell 实施计划？"="Y - 开始执行". You can now continue with the user's answers in mind.

Question: 是否确认开始执行 Butler-Shell 实施计划？

Choices:
开始多模型协作执行，Codex 提供原型，Claude 实施并审计
先查看或修改计划内容


用户确认开始执行。现在按照多模型协作执行流程：

1. Phase 1: 快速上下文检索 - 项目是新项目，没有现有代码需要检索
2. Phase 3: 原型获取 - 这是后端项目，路由到 Codex
3. Phase 4: 代码实施 - Claude 执行
4. Phase 5: 审计和交付 - Codex + Gemini 审计

由于这是一个全新项目（只有 venv 和 CLAUDE.md），不需要上下文检索。

让我开始 Phase 3：调用 Codex 获取实现原型。我需要：
1. 读取详细计划
2. 构建调用 Codex 的任务
3. 后台运行 Codex

按照规范，我需要使用 `run_in_background: true` 和正确的超时设置。


用户确认开始执行。

## Phase 1: 快速上下文检索

这是一个全新项目，没有现有代码需要检索。让我确认项目当前状态。


Bash

检查项目当前状态


No files found


total 8
drwxr-xr-x  4 root root    47 Apr  7 17:21 .
drwxr-xr-x 12  501 games 4096 Apr  7 16:57 ..
-rw-r--r--  1 root root  2020 Apr  7 16:59 CLAUDE.md
drwxr-xr-x  3 root root    25 Apr  7 17:21 docs
drwxr-xr-x  5 root root    74 Apr  7 16:57 venv


项目状态确认：
- 这是一个全新项目，只有 docs 目录（包含我们创建的规范和计划）和 venv
- 没有现有代码需要检索
- 可以直接进入 Phase 3 原型获取阶段

由于这是全新项目，我将按 Chunk 顺序执行。先从 Chunk 1（项目基础设施）开始。

任务类型是后端，所以路由到 Codex。

让我启动 Codex 来获取原型实现。


项目确认是全新项目，没有现有代码。直接进入 Phase 3 原型获取。

## Phase 3: 原型获取 (Chunk 1 - 项目基础设施)

由于这是后端项目，我将调用 Codex 获取实现原型。


~/.claude/bin/codeagent-wrapper --backend codex - "$PWD" <<'EOF'
ROLE_FILE: ~/.claude/.ccg/prompts/codex/architect.md
<TASK>
Requirement: Implement Butler-Shell Phase 1 (Chunk 1-2) - Project Infrastructure and Security Modules

Context from plan file docs/superpowers/plans/2026-04-07-butler-shell-implementation.md:

## Chunk 1: 项目基础设施

### Task 1.1: 项目初始化
- Create: pyproject.toml, requirements.txt, config.yaml, .env.example, src/butler/__init__.py

### Task 1.2: 配置管理模块
- Create: src/butler/config.py, tests/test_config.py
- Settings class with pydantic-settings
- Load from YAML and env vars

### Task 1.3: 核心数据模型
- Create: src/butler/session/__init__.py, src/butler/session/state.py, tests/test_state.py
- SessionMode, PtyState, SessionState, MessageContext, NapCatEvent

## Chunk 2: 安全模块

### Task 2.1: 认证授权模块
- Create: src/butler/security/__init__.py, src/butler/security/auth.py, tests/test_auth.py
- is_authorized(), check_user_permission()

### Task 2.2: 危险命令检测
- Create: src/butler/security/guardrail.py, tests/test_guardrail.py
- DANGEROUS_PATTERNS, is_dangerous_command(), get_danger_reason()

### Task 2.3: 敏感信息过滤
- Create: src/butler/security/sanitizer.py, tests/test_sanitizer.py
- SENSITIVE_PATTERNS, sanitize_output(), contains_secrets()

Target files to create (all new):
1. pyproject.toml
2. requirements.txt
3. config.yaml
4. .env.example
5. src/butler/__init__.py
6. src/butler/config.py
7. src/butler/session/__init__.py
8. src/butler/session/state.py
9. src/butler/security/__init__.py
10. src/butler/security/auth.py
11. src/butler/security/guardrail.py
12. src/butler/security/sanitizer.py
13. tests/conftest.py
14. tests/test_config.py
15. tests/test_state.py
16. tests/test_auth.py
17. tests/test_guardrail.py
18. tests/test_sanitizer.py

Follow TDD approach: write tests first, then implementation.
</TASK>
OUTPUT: Unified Diff Patch ONLY. Strictly prohibit any actual modifications.
EOF

Codex 获取 Chunk 1-2 原型


Command running in background with ID: b0j3dbzss. Output is being written to: /tmp/claude-0/-root-projects-qq-shell/05ce9247-4475-4ac9-917c-8c6602fafbdf/tasks/b0j3dbzss.output


<task-notification>
<task-id>b0j3dbzss</task-id>
<tool-use-id>call_b021cd6fe8bf412081df7032</tool-use-id>
<output-file>/tmp/claude-0/-root-projects-qq-shell/05ce9247-4475-4ac9-917c-8c6602fafbdf/tasks/b0j3dbzss.output</output-file>
<status>failed</status>
<summary>Background command "Codex 获取 Chunk 1-2 原型" failed with exit code 127</summary>
</task-notification>


Codex 任务已在后台运行。我需要等待其完成。根据 skill 指令，我需要使用 TaskOutput 等待结果，设置 timeout: 600000 (10分钟)。


Codex 原型任务已在后台运行 (ID: b0j3dbzss)。等待结果...


<retrieval_status>success</retrieval_status>

<task_id>b0j3dbzss</task_id>

<task_type>local_bash</task_type>

<status>failed</status>

<exit_code>127</exit_code>

<output>
/bin/bash: line 59: /root/.claude/bin/codeagent-wrapper: No such file or directory
</output>
[result-id: r9]

Codex 获取 Chunk 1-2 原型

Output: /bin/bash: line 59: /root/.claude/bin/codeagent-wrapper: No such file or directory


The codeagent-wrapper script doesn't exist. The multi-model execution workflow requires this wrapper but it's not available. I should fall back to direct implementation using my own capabilities, or inform the user that the multi-model execution isn't available.

Let me check if there's an alternative approach or just proceed with direct implementation since the user has confirmed they want to execute the plan.


Codex wrapper 不可用。我将直接作为代码主权者实施计划，不依赖外部模型原型。

## Phase 4: 直接代码实施

我将按照计划逐步实施。开始 Chunk 1。


mkdir -p /root/projects/qq-shell/src/butler/session /root/projects/qq-shell/src/butler/security /root/projects/qq-shell/src/butler/workflows /root/projects/qq-shell/src/butler/gateway /root/projects/qq-shell/src/butler/skills /root/projects/qq-shell/tests /root/projects/qq-shell/data /root/projects/qq-shell/scripts

创建项目目录结构


(Bash completed with no output)


Good, directories created. Now I need to implement the files according to the plan. Let me start with Chunk 1 - Project Infrastructure.


### Task 1.1: 项目初始化


Write

[project]
name = "butler-shell"
version = "0.1.0"
description = "Persistent Shell Assistant with QQ Bridge"
requires-python = ">=3.11"
dependencies = [
    "inngest>=0.2.0",
    "pydantic>=2.0.0",
    "pydantic-settings>=2.0.0",
    "fastapi>=0.109.0",
    "uvicorn>=0.27.0",
    "websockets>=12.0",
    "aiosqlite>=0.19.0",
    "pyyaml>=6.0",
    "python-dotenv>=1.0.0",
    "httpx>=0.26.0",
    "anthropic>=0.18.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4.0",
    "pytest-asyncio>=0.23.0",
    "pytest-cov>=4.1.0",
    "ruff>=0.1.0",
    "mypy>=1.8.0",
]

[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.mypy]
python_version = "3.11"
strict = true

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]


File created successfully at: /root/projects/qq-shell/pyproject.toml

[project]
name = "butler-shell"
version = "0.1.0"
description = "Persistent Shell Assistant with QQ Bridge"
requires-python = ">=3.11"
dependencies = [
    "inngest>=0.2.0",
    "pydantic>=2.0.0",
    "pydantic-settings>=2.0.0",
    "fastapi>=0.109.0",
    "uvicorn>=0.27.0",
    "websockets>=12.0",
    "aiosqlite>=0.19.0",
    "pyyaml>=6.0",
    "python-dotenv>=1.0.0",
    "httpx>=0.26.0",
    "anthropic>=0.18.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4.0",
    "pytest-asyncio>=0.23.0",
    "pytest-cov>=4.1.0",
    "ruff>=0.1.0",
    "mypy>=1.8.0",
]

[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.mypy]
python_version = "3.11"
strict = true

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]


继续创建其他项目基础设施文件。


inngest>=0.2.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
fastapi>=0.109.0
uvicorn>=0.27.0
websockets>=12.0
aiosqlite>=0.19.0
pyyaml>=6.0
python-dotenv>=1.0.0
httpx>=0.26.0
anthropic>=0.18.0


butler:
  session:
    idle_timeout: 3600
    max_per_user: 1
    max_total: 100
    prefix: "butler_"

  guardrail:
    enabled: true
    approval_timeout: 300

  security:
    allowed_users: []

  logging:
    level: INFO
    format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"

  napcat:
    ws_url: "ws://localhost:3001"
    token: ""

  inngest:
    event_key: ""
    signing_key: ""


# Butler-Shell 环境变量配置

# NapCatQQ 配置
NAPCAT_WS_URL=ws://localhost:3001
NAPCAT_TOKEN=your_napcat_token

# Inngest 配置
INNGEST_EVENT_KEY=your_event_key
INNGEST_SIGNING_KEY=your_signing_key

# 安全配置
BUTLER_ALLOWED_USERS=12345678,87654321

# Claude API (可选，用于上下文压缩)
ANTHROPIC_API_KEY=your_claude_api_key


"""Butler-Shell: Persistent Shell Assistant with QQ Bridge."""

__version__ = "0.1.0"


File created successfully at: /root/projects/qq-shell/requirements.txt

inngest>=0.2.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
fastapi>=0.109.0
uvicorn>=0.27.0
websockets>=12.0
aiosqlite>=0.19.0
pyyaml>=6.0
python-dotenv>=1.0.0
httpx>=0.26.0
anthropic>=0.18.0


File created successfully at: /root/projects/qq-shell/config.yaml

butler:
  session:
    idle_timeout: 3600
    max_per_user: 1
    max_total: 100
    prefix: "butler_"

  guardrail:
    enabled: true
    approval_timeout: 300

  security:
    allowed_users: []

  logging:
    level: INFO
    format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"

  napcat:
    ws_url: "ws://localhost:3001"
    token: ""

  inngest:
    event_key: ""
    signing_key: ""


File created successfully at: /root/projects/qq-shell/.env.example

# Butler-Shell 环境变量配置

# NapCatQQ 配置
NAPCAT_WS_URL=ws://localhost:3001
NAPCAT_TOKEN=your_napcat_token

# Inngest 配置
INNGEST_EVENT_KEY=your_event_key
INNGEST_SIGNING_KEY=your_signing_key

# 安全配置
BUTLER_ALLOWED_USERS=12345678,87654321

# Claude API (可选，用于上下文压缩)
ANTHROPIC_API_KEY=your_claude_api_key


File created successfully at: /root/projects/qq-shell/src/butler/__init__.py

"""Butler-Shell: Persistent Shell Assistant with QQ Bridge."""

__version__ = "0.1.0"


好的，项目基础设施文件已创建。现在继续实现配置管理模块和核心数据模型。


### Task 1.2: 配置管理模块


"""配置管理模块。"""

import os
from pathlib import Path
from typing import Any

import yaml
from pydantic import Field
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    """应用配置。"""

    # 会话配置
    session_idle_timeout: int = Field(default=3600, description="会话空闲超时(秒)")
    session_max_per_user: int = Field(default=1, description="每用户最大会话数")
    session_max_total: int = Field(default=100, description="全局最大会话数")
    session_prefix: str = Field(default="butler_", description="会话名前缀")

    # 审批配置
    guardrail_enabled: bool = Field(default=True, description="是否启用命令审批")
    guardrail_approval_timeout: int = Field(default=300, description="审批超时(秒)")

    # 安全配置
    allowed_users: list[str] = Field(default_factory=list, description="允许的用户ID列表")

    # NapCat 配置
    napcat_ws_url: str = Field(default="ws://localhost:3001", description="NapCat WebSocket URL")
    napcat_token: str = Field(default="", description="NapCat 访问令牌")

    # Inngest 配置
    inngest_event_key: str = Field(default="", description="Inngest Event Key")
    inngest_signing_key: str = Field(default="", description="Inngest Signing Key")

    # 日志配置
    log_level: str = Field(default="INFO", description="日志级别")

    # Claude API
    anthropic_api_key: str = Field(default="", description="Anthropic API Key")

    model_config = {"env_prefix": "BUTLER_", "env_file": ".env", "extra": "ignore"}

    def is_user_allowed(self, user_id: str) -> bool:
        """检查用户是否在白名单中。"""
        if not self.allowed_users:
            return True
        return user_id in self.allowed_users

def load_config(config_path: Path | None = None) -> dict[str, Any]:
    """从 YAML 文件加载配置。"""
    if config_path is None:
        config_path = Path("config.yaml")

    if not config_path.exists():
        return {}

    with open(config_path) as f:
        return yaml.safe_load(f) or {}

def create_settings(config_path: Path | None = None) -> Settings:
    """创建配置实例，合并 YAML 和环境变量。"""
    config = load_config(config_path)
    butler_config = config.get("butler", {})

    flat_config: dict[str, Any] = {}

    session_cfg = butler_config.get("session", {})
    flat_config["session_idle_timeout"] = session_cfg.get("idle_timeout", 3600)
    flat_config["session_max_per_user"] = session_cfg.get("max_per_user", 1)
    flat_config["session_max_total"] = session_cfg.get("max_total", 100)
    flat_config["session_prefix"] = session_cfg.get("prefix", "butler_")

    guardrail_cfg = butler_config.get("guardrail", {})
    flat_config["guardrail_enabled"] = guardrail_cfg.get("enabled", True)
    flat_config["guardrail_approval_timeout"] = guardrail_cfg.get("approval_timeout", 300)

    security_cfg = butler_config.get("security", {})
    allowed = security_cfg.get("allowed_users", [])
    flat_config["allowed_users"] = allowed if allowed else []

    napcat_cfg = butler_config.get("napcat", {})
    flat_config["napcat_ws_url"] = napcat_cfg.get("ws_url", "ws://localhost:3001")
    flat_config["napcat_token"] = napcat_cfg.get("token", "")

    logging_cfg = butler_config.get("logging", {})
    flat_config["log_level"] = logging_cfg.get("level", "INFO")

    return Settings(**flat_config)

settings = create_settings()


File created successfully at: /root/projects/qq-shell/src/butler/config.py

"""配置管理模块。"""

import os
from pathlib import Path
from typing import Any

import yaml
from pydantic import Field
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    """应用配置。"""

    # 会话配置
    session_idle_timeout: int = Field(default=3600, description="会话空闲超时(秒)")
    session_max_per_user: int = Field(default=1, description="每用户最大会话数")
    session_max_total: int = Field(default=100, description="全局最大会话数")
    session_prefix: str = Field(default="butler_", description="会话名前缀")

    # 审批配置
    guardrail_enabled: bool = Field(default=True, description="是否启用命令审批")
    guardrail_approval_timeout: int = Field(default=300, description="审批超时(秒)")

    # 安全配置
    allowed_users: list[str] = Field(default_factory=list, description="允许的用户ID列表")

    # NapCat 配置
    napcat_ws_url: str = Field(default="ws://localhost:3001", description="NapCat WebSocket URL")
    napcat_token: str = Field(default="", description="NapCat 访问令牌")

    # Inngest 配置
    inngest_event_key: str = Field(default="", description="Inngest Event Key")
    inngest_signing_key: str = Field(default="", description="Inngest Signing Key")

    # 日志配置
    log_level: str = Field(default="INFO", description="日志级别")

    # Claude API
    anthropic_api_key: str = Field(default="", description="Anthropic API Key")

    model_config = {"env_prefix": "BUTLER_", "env_file": ".env", "extra": "ignore"}

    def is_user_allowed(self, user_id: str) -> bool:
        """检查用户是否在白名单中。"""
        if not self.allowed_users:
            return True
        return user_id in self.allowed_users

def load_config(config_path: Path | None = None) -> dict[str, Any]:
    """从 YAML 文件加载配置。"""
    if config_path is None:
        config_path = Path("config.yaml")

    if not config_path.exists():
        return {}

    with open(config_path) as f:
        return yaml.safe_load(f) or {}

def create_settings(config_path: Path | None = None) -> Settings:
    """创建配置实例，合并 YAML 和环境变量。"""
    config = load_config(config_path)
    butler_config = config.get("butler", {})

    flat_config: dict[str, Any] = {}

    session_cfg = butler_config.get("session", {})
    flat_config["session_idle_timeout"] = session_cfg.get("idle_timeout", 3600)
    flat_config["session_max_per_user"] = session_cfg.get("max_per_user", 1)
    flat_config["session_max_total"] = session_cfg.get("max_total", 100)
    flat_config["session_prefix"] = session_cfg.get("prefix", "butler_")

    guardrail_cfg = butler_config.get("guardrail", {})
    flat_config["guardrail_enabled"] = guardrail_cfg.get("enabled", True)
    flat_config["guardrail_approval_timeout"] = guardrail_cfg.get("approval_timeout", 300)

    security_cfg = butler_config.get("security", {})
    allowed = security_cfg.get("allowed_users", [])
    flat_config["allowed_users"] = allowed if allowed else []

    napcat_cfg = butler_config.get("napcat", {})
    flat_config["napcat_ws_url"] = napcat_cfg.get("ws_url", "ws://localhost:3001")
    flat_config["napcat_token"] = napcat_cfg.get("token", "")

    logging_cfg = butler_config.get("logging", {})
    flat_config["log_level"] = logging_cfg.get("level", "INFO")

    return Settings(**flat_config)

settings = create_settings()


"""Pytest 全局 fixtures。"""

import pytest
from unittest.mock import Mock, patch

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

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


"""配置模块测试。"""

import pytest
from pathlib import Path
from butler.config import Settings, load_config

def test_default_settings():
    """测试默认配置值。"""
    settings = Settings()
    assert settings.session_idle_timeout == 3600
    assert settings.session_max_per_user == 1
    assert settings.guardrail_enabled is True

def test_env_override(monkeypatch):
    """测试环境变量覆盖。"""
    monkeypatch.setenv("BUTLER_ALLOWED_USERS", "['123', '456']")
    settings = Settings()
    assert "123" in settings.allowed_users
    assert "456" in settings.allowed_users

def test_load_config_from_yaml(tmp_path):
    """测试从 YAML 加载配置。"""
    config_file = tmp_path / "config.yaml"
    config_file.write_text("""
butler:
  session:
    idle_timeout: 7200
""")
    config = load_config(config_file)
    assert config["butler"]["session"]["idle_timeout"] == 7200

def test_is_user_allowed():
    """测试用户白名单检查。"""
    settings = Settings(allowed_users=["user123", "user456"])
    assert settings.is_user_allowed("user123") is True
    assert settings.is_user_allowed("user789") is False

def test_empty_whitelist_allows_all():
    """空白名单允许所有用户。"""
    settings = Settings(allowed_users=[])
    assert settings.is_user_allowed("anyone") is True


File created successfully at: /root/projects/qq-shell/tests/conftest.py

"""Pytest 全局 fixtures。"""

import pytest
from unittest.mock import Mock, patch

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

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


File created successfully at: /root/projects/qq-shell/tests/test_config.py

"""配置模块测试。"""

import pytest
from pathlib import Path
from butler.config import Settings, load_config

def test_default_settings():
    """测试默认配置值。"""
    settings = Settings()
    assert settings.session_idle_timeout == 3600
    assert settings.session_max_per_user == 1
    assert settings.guardrail_enabled is True

def test_env_override(monkeypatch):
    """测试环境变量覆盖。"""
    monkeypatch.setenv("BUTLER_ALLOWED_USERS", "['123', '456']")
    settings = Settings()
    assert "123" in settings.allowed_users
    assert "456" in settings.allowed_users

def test_load_config_from_yaml(tmp_path):
    """测试从 YAML 加载配置。"""
    config_file = tmp_path / "config.yaml"
    config_file.write_text("""
butler:
  session:
    idle_timeout: 7200
""")
    config = load_config(config_file)
    assert config["butler"]["session"]["idle_timeout"] == 7200

def test_is_user_allowed():
    """测试用户白名单检查。"""
    settings = Settings(allowed_users=["user123", "user456"])
    assert settings.is_user_allowed("user123") is True
    assert settings.is_user_allowed("user789") is False

def test_empty_whitelist_allows_all():
    """空白名单允许所有用户。"""
    settings = Settings(allowed_users=[])
    assert settings.is_user_allowed("anyone") is True


Let me continue implementing the remaining files. I need to create:
1. Session state module
2. Security modules (auth, guardrail, sanitizer)
3. Their tests


### Task 1.3: 核心数据模型


"""会话管理模块。"""

from butler.session.state import (
    MessageContext,
    NapCatEvent,
    PtyState,
    SessionMode,
    SessionState,
)

__all__ = ["SessionMode", "PtyState", "SessionState", "MessageContext", "NapCatEvent"]


"""会话状态数据模型。"""

from datetime import datetime
from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field

class SessionMode(str, Enum):
    """会话运行模式。"""

    NATURAL_LANGUAGE = "nl"
    INTERACTIVE = "interactive"

class PtyState(str, Enum):
    """PTY 进程状态。"""

    IDLE = "idle"
    RUNNING = "running"
    BLOCKED = "blocked"
    UNKNOWN = "unknown"

class SessionState(BaseModel):
    """会话状态快照。"""

    session_id: str = Field(..., description="tmux session 名称")
    user_id: str = Field(..., description="QQ 用户 ID")
    mode: SessionMode = Field(default=SessionMode.NATURAL_LANGUAGE, description="当前模式")

    pwd: str = Field(default="", description="当前工作目录")
    env: dict[str, str] = Field(default_factory=dict, description="关键环境变量")
    last_output: str = Field(default="", description="最近终端输出摘要")

    interactive_process: Optional[str] = Field(
        default=None, description="当前交互进程名 (vim, top, etc.)"
    )

    created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
    updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
    last_active_at: datetime = Field(default_factory=datetime.utcnow, description="最后活跃时间")

    def touch(self) -> None:
        """更新活跃时间。"""
        self.updated_at = datetime.utcnow()
        self.last_active_at = datetime.utcnow()

    def idle_seconds(self) -> int:
        """计算空闲秒数。"""
        delta = datetime.utcnow() - self.last_active_at
        return int(delta.total_seconds())

class MessageContext(BaseModel):
    """消息处理上下文。"""

    message_id: str = Field(..., description="消息唯一 ID")
    user_id: str = Field(..., description="QQ 用户 ID")
    content: str = Field(..., description="消息内容")

    is_command: bool = Field(default=False, description="是否以 / 开头")
    requires_approval: bool = Field(default=False, description="危险命令标记")
    session: Optional[SessionState] = Field(default=None, description="关联的会话状态")

class NapCatEvent(BaseModel):
    """NapCatQQ 事件格式。"""

    time: int = Field(..., description="事件时间戳")
    self_id: int = Field(..., description="机器人 QQ 号")
    post_type: str = Field(..., description="事件类型")
    message_type: str = Field(default="", description="消息类型")
    user_id: int = Field(default=0, description="发送者 ID")
    message: str = Field(default="", description="消息内容")
    raw_message: str = Field(default="", description="原始消息")

    @property
    def is_private_message(self) -> bool:
        """是否是私聊消息。"""
        return self.post_type == "message" and self.message_type == "private"


"""数据模型测试。"""

import pytest
from butler.session.state import SessionMode, PtyState, SessionState, MessageContext

def test_session_mode_enum():
    """测试会话模式枚举。"""
    assert SessionMode.NATURAL_LANGUAGE.value == "nl"
    assert SessionMode.INTERACTIVE.value == "interactive"

def test_pty_state_enum():
    """测试 PTY 状态枚举。"""
    assert PtyState.IDLE.value == "idle"
    assert PtyState.RUNNING.value == "running"
    assert PtyState.BLOCKED.value == "blocked"
    assert PtyState.UNKNOWN.value == "unknown"

def test_session_state_creation():
    """测试会话状态创建。"""
    state = SessionState(
        session_id="butler_test123",
        user_id="test123",
        pwd="/home/user",
        env={"HOME": "/home/user"},
    )
    assert state.session_id == "butler_test123"
    assert state.mode == SessionMode.NATURAL_LANGUAGE
    assert state.pwd == "/home/user"
    assert state.env["HOME"] == "/home/user"

def test_session_state_default_mode():
    """测试默认模式为自然语言模式。"""
    state = SessionState(session_id="test", user_id="test")
    assert state.mode == SessionMode.NATURAL_LANGUAGE

def test_session_state_touch():
    """测试 touch 方法更新时间。"""
    state = SessionState(session_id="test", user_id="test")
    old_time = state.last_active_at
    state.touch()
    assert state.last_active_at >= old_time

def test_message_context_creation():
    """测试消息上下文创建。"""
    ctx = MessageContext(
        message_id="msg123",
        user_id="user123",
        content="ls -la",
    )
    assert ctx.message_id == "msg123"
    assert ctx.is_command is False
    assert ctx.requires_approval is False

def test_message_context_command_detection():
    """测试命令检测。"""
    ctx = MessageContext(
        message_id="msg123",
        user_id="user123",
        content="/log recent",
        is_command=True,
    )
    assert ctx.is_command is True


File created successfully at: /root/projects/qq-shell/src/butler/session/__init__.py

"""会话管理模块。"""

from butler.session.state import (
    MessageContext,
    NapCatEvent,
    PtyState,
    SessionMode,
    SessionState,
)

__all__ = ["SessionMode", "PtyState", "SessionState", "MessageContext", "NapCatEvent"]


File created successfully at: /root/projects/qq-shell/src/butler/session/state.py

"""会话状态数据模型。"""

from datetime import datetime
from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field

class SessionMode(str, Enum):
    """会话运行模式。"""

    NATURAL_LANGUAGE = "nl"
    INTERACTIVE = "interactive"

class PtyState(str, Enum):
    """PTY 进程状态。"""

    IDLE = "idle"
    RUNNING = "running"
    BLOCKED = "blocked"
    UNKNOWN = "unknown"

class SessionState(BaseModel):
    """会话状态快照。"""

    session_id: str = Field(..., description="tmux session 名称")
    user_id: str = Field(..., description="QQ 用户 ID")
    mode: SessionMode = Field(default=SessionMode.NATURAL_LANGUAGE, description="当前模式")

    pwd: str = Field(default="", description="当前工作目录")
    env: dict[str, str] = Field(default_factory=dict, description="关键环境变量")
    last_output: str = Field(default="", description="最近终端输出摘要")

    interactive_process: Optional[str] = Field(
        default=None, description="当前交互进程名 (vim, top, etc.)"
    )

    created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
    updated_at: datetime = Field(default_factory=datetime.utcnow, description="更新时间")
    last_active_at: datetime = Field(default_factory=datetime.utcnow, description="最后活跃时间")

    def touch(self) -> None:
        """更新活跃时间。"""
        self.updated_at = datetime.utcnow()
        self.last_active_at = datetime.utcnow()

    def idle_seconds(self) -> int:
        """计算空闲秒数。"""
        delta = datetime.utcnow() - self.last_active_at
        return int(delta.total_seconds())

class MessageContext(BaseModel):
    """消息处理上下文。"""

    message_id: str = Field(..., description="消息唯一 ID")
    user_id: str = Field(..., description="QQ 用户 ID")
    content: str = Field(..., description="消息内容")

    is_command: bool = Field(default=False, description="是否以 / 开头")
    requires_approval: bool = Field(default=False, description="危险命令标记")
    session: Optional[SessionState] = Field(default=None, description="关联的会话状态")

class NapCatEvent(BaseModel):
    """NapCatQQ 事件格式。"""

    time: int = Field(..., description="事件时间戳")
    self_id: int = Field(..., description="机器人 QQ 号")
    post_type: str = Field(..., description="事件类型")
    message_type: str = Field(default="", description="消息类型")
    user_id: int = Field(default=0, description="发送者 ID")
    message: str = Field(default="", description="消息内容")
    raw_message: str = Field(default="", description="原始消息")

    @property
    def is_private_message(self) -> bool:
        """是否是私聊消息。"""
        return self.post_type == "message" and self.message_type == "private"


File created successfully at: /root/projects/qq-shell/tests/test_state.py

"""数据模型测试。"""

import pytest
from butler.session.state import SessionMode, PtyState, SessionState, MessageContext

def test_session_mode_enum():
    """测试会话模式枚举。"""
    assert SessionMode.NATURAL_LANGUAGE.value == "nl"
    assert SessionMode.INTERACTIVE.value == "interactive"

def test_pty_state_enum():
    """测试 PTY 状态枚举。"""
    assert PtyState.IDLE.value == "idle"
    assert PtyState.RUNNING.value == "running"
    assert PtyState.BLOCKED.value == "blocked"
    assert PtyState.UNKNOWN.value == "unknown"

def test_session_state_creation():
    """测试会话状态创建。"""
    state = SessionState(
        session_id="butler_test123",
        user_id="test123",
        pwd="/home/user",
        env={"HOME": "/home/user"},
    )
    assert state.session_id == "butler_test123"
    assert state.mode == SessionMode.NATURAL_LANGUAGE
    assert state.pwd == "/home/user"
    assert state.env["HOME"] == "/home/user"

def test_session_state_default_mode():
    """测试默认模式为自然语言模式。"""
    state = SessionState(session_id="test", user_id="test")
    assert state.mode == SessionMode.NATURAL_LANGUAGE

def test_session_state_touch():
    """测试 touch 方法更新时间。"""
    state = SessionState(session_id="test", user_id="test")
    old_time = state.last_active_at
    state.touch()
    assert state.last_active_at >= old_time

def test_message_context_creation():
    """测试消息上下文创建。"""
    ctx = MessageContext(
        message_id="msg123",
        user_id="user123",
        content="ls -la",
    )
    assert ctx.message_id == "msg123"
    assert ctx.is_command is False
    assert ctx.requires_approval is False

def test_message_context_command_detection():
    """测试命令检测。"""
    ctx = MessageContext(
        message_id="msg123",
        user_id="user123",
        content="/log recent",
        is_command=True,
    )
    assert ctx.is_command is True


好的，现在继续实现 Chunk 2 - 安全模块


### Task 2.1-2.3: 安全模块


"""安全模块。"""

from butler.security.auth import check_user_permission, is_authorized
from butler.security.guardrail import get_danger_reason, is_dangerous_command
from butler.security.sanitizer import contains_secrets, sanitize_output

__all__ = [
    "is_authorized",
    "check_user_permission",
    "is_dangerous_command",
    "get_danger_reason",
    "sanitize_output",
    "contains_secrets",
]


"""认证授权模块。"""

from typing import Optional

def is_authorized(user_id: str, allowed_users: list[str]) -> bool:
    """
    检查用户是否被授权。

    Args:
        user_id: 用户 ID
        allowed_users: 允许的用户 ID 列表

    Returns:
        如果空白名单或用户在白名单中返回 True
    """
    if not allowed_users:
        return True
    return user_id in allowed_users

def check_user_permission(user_id: str, allowed_users: list[str]) -> tuple[bool, Optional[str]]:
    """
    检查用户权限并返回结果和消息。

    Args:
        user_id: 用户 ID
        allowed_users: 允许的用户 ID 列表

    Returns:
        (是否允许, 错误消息或 None)
    """
    if not is_authorized(user_id, allowed_users):
        return False, f"用户 {user_id} 无权限使用此服务"
    return True, None


"""命令安全检测模块。"""

import re
import shlex
from typing import Final, FrozenSet

DANGEROUS_PATTERNS: Final[list[str]] = [
    r"\brm\s+(-[rf]+\s+|.*-rf)",
    r"\bchmod\s+(-R\s+)?777",
    r"\bchown\s+",
    r"\bdd\s+",
    r"\bmkfs\b",
    r"\b(fdisk|parted)\b",
    r">\s*/dev/sd",
    r"\biptables\b",
    r"\bshutdown\b",
    r"\breboot\b",
    r"\bsudo\b",
    r"\bsh\s+-c\b",
    r"\bbash\s+-c\b",
    r"\|.*\b(bash|sh)\b",
    r"\$\([^)]+\)",
    r"`[^`]+`",
]

DANGEROUS_COMMANDS: Final[FrozenSet[str]] = frozenset([
    "rm", "chmod", "chown", "dd", "mkfs", "fdisk", "parted",
    "iptables", "shutdown", "reboot", "init",
])

_COMPILED_PATTERNS = [re.compile(p, re.IGNORECASE) for p in DANGEROUS_PATTERNS]

def is_dangerous_command(cmd: str) -> bool:
    """
    检查命令是否危险。

    Args:
        cmd: 要检查的命令字符串

    Returns:
        如果命令危险返回 True
    """
    for pattern in _COMPILED_PATTERNS:
        if pattern.search(cmd):
            return True

    try:
        parts = shlex.split(cmd)
        if parts and parts[0] in DANGEROUS_COMMANDS:
            if parts[0] == "rm":
                for part in parts[1:]:
                    if part.startswith("-") and ("r" in part or "f" in part):
                        return True
                return False
            return True
    except ValueError:
        return True

    return False

def get_danger_reason(cmd: str) -> str | None:
    """
    获取命令被标记为危险的原因。

    Args:
        cmd: 命令字符串

    Returns:
        危险原因描述，如果安全则返回 None
    """
    if not is_dangerous_command(cmd):
        return None

    if "rm" in cmd and ("-rf" in cmd or "-fr" in cmd):
        return "递归强制删除文件"
    if "chmod" in cmd and "777" in cmd:
        return "开放所有权限"
    if "sudo" in cmd:
        return "需要 root 权限"
    if "dd" in cmd:
        return "磁盘操作"
    if any(x in cmd for x in ["shutdown", "reboot"]):
        return "系统操作"

    return "潜在危险命令"


"""敏感信息过滤模块。"""

import re
from typing import Final

SENSITIVE_PATTERNS: Final[list[str]] = [
    r"(password|passwd|pwd)\s*=\s*\S+",
    r"(api[_-]?key|apikey)\s*[=:]\s*\S+",
    r"(token|access_token|auth_token)\s*[=:]\s*\S+",
    r"(secret|secret_key)\s*[=:]\s*\S+",
    r"Bearer\s+\S+",
    r"-----BEGIN\s+.*PRIVATE\s+KEY-----",
    r"-----BEGIN\s+.*CERTIFICATE-----",
]

_COMPILED_SANITIZERS = [
    (re.compile(pattern, re.IGNORECASE), "[REDACTED]")
    for pattern in SENSITIVE_PATTERNS
]

def sanitize_output(output: str) -> str:
    """
    过滤输出中的敏感信息。

    Args:
        output: 原始输出字符串

    Returns:
        过滤后的安全输出
    """
    sanitized = output
    for pattern, replacement in _COMPILED_SANITIZERS:
        sanitized = pattern.sub(replacement, sanitized)
    return sanitized

def contains_secrets(text: str) -> bool:
    """
    检查文本是否包含敏感信息。

    Args:
        text: 要检查的文本

    Returns:
        如果包含敏感信息返回 True
    """
    for pattern, _ in _COMPILED_SANITIZERS:
        if pattern.search(text):
            return True
    return False


File created successfully at: /root/projects/qq-shell/src/butler/security/__init__.py

"""安全模块。"""

from butler.security.auth import check_user_permission, is_authorized
from butler.security.guardrail import get_danger_reason, is_dangerous_command
from butler.security.sanitizer import contains_secrets, sanitize_output

__all__ = [
    "is_authorized",
    "check_user_permission",
    "is_dangerous_command",
    "get_danger_reason",
    "sanitize_output",
    "contains_secrets",
]


File created successfully at: /root/projects/qq-shell/src/butler/security/auth.py

"""认证授权模块。"""

from typing import Optional

def is_authorized(user_id: str, allowed_users: list[str]) -> bool:
    """
    检查用户是否被授权。

    Args:
        user_id: 用户 ID
        allowed_users: 允许的用户 ID 列表

    Returns:
        如果空白名单或用户在白名单中返回 True
    """
    if not allowed_users:
        return True
    return user_id in allowed_users

def check_user_permission(user_id: str, allowed_users: list[str]) -> tuple[bool, Optional[str]]:
    """
    检查用户权限并返回结果和消息。

    Args:
        user_id: 用户 ID
        allowed_users: 允许的用户 ID 列表

    Returns:
        (是否允许, 错误消息或 None)
    """
    if not is_authorized(user_id, allowed_users):
        return False, f"用户 {user_id} 无权限使用此服务"
    return True, None


File created successfully at: /root/projects/qq-shell/src/butler/security/guardrail.py

"""命令安全检测模块。"""

import re
import shlex
from typing import Final, FrozenSet

DANGEROUS_PATTERNS: Final[list[str]] = [
    r"\brm\s+(-[rf]+\s+|.*-rf)",
    r"\bchmod\s+(-R\s+)?777",
    r"\bchown\s+",
    r"\bdd\s+",
    r"\bmkfs\b",
    r"\b(fdisk|parted)\b",
    r">\s*/dev/sd",
    r"\biptables\b",
    r"\bshutdown\b",
    r"\breboot\b",
    r"\bsudo\b",
    r"\bsh\s+-c\b",
    r"\bbash\s+-c\b",
    r"\|.*\b(bash|sh)\b",
    r"\$\([^)]+\)",
    r"`[^`]+`",
]

DANGEROUS_COMMANDS: Final[FrozenSet[str]] = frozenset([
    "rm", "chmod", "chown", "dd", "mkfs", "fdisk", "parted",
    "iptables", "shutdown", "reboot", "init",
])

_COMPILED_PATTERNS = [re.compile(p, re.IGNORECASE) for p in DANGEROUS_PATTERNS]

def is_dangerous_command(cmd: str) -> bool:
    """
    检查命令是否危险。

    Args:
        cmd: 要检查的命令字符串

    Returns:
        如果命令危险返回 True
    """
    for pattern in _COMPILED_PATTERNS:
        if pattern.search(cmd):
            return True

    try:
        parts = shlex.split(cmd)
        if parts and parts[0] in DANGEROUS_COMMANDS:
            if parts[0] == "rm":
                for part in parts[1:]:
                    if part.startswith("-") and ("r" in part or "f" in part):
                        return True
                return False
            return True
    except ValueError:
        return True

    return False

def get_danger_reason(cmd: str) -> str | None:
    """
    获取命令被标记为危险的原因。

    Args:
        cmd: 命令字符串

    Returns:
        危险原因描述，如果安全则返回 None
    """
    if not is_dangerous_command(cmd):
        return None

    if "rm" in cmd and ("-rf" in cmd or "-fr" in cmd):
        return "递归强制删除文件"
    if "chmod" in cmd and "777" in cmd:
        return "开放所有权限"
    if "sudo" in cmd:
        return "需要 root 权限"
    if "dd" in cmd:
        return "磁盘操作"
    if any(x in cmd for x in ["shutdown", "reboot"]):
        return "系统操作"

    return "潜在危险命令"


File created successfully at: /root/projects/qq-shell/src/butler/security/sanitizer.py

"""敏感信息过滤模块。"""

import re
from typing import Final

SENSITIVE_PATTERNS: Final[list[str]] = [
    r"(password|passwd|pwd)\s*=\s*\S+",
    r"(api[_-]?key|apikey)\s*[=:]\s*\S+",
    r"(token|access_token|auth_token)\s*[=:]\s*\S+",
    r"(secret|secret_key)\s*[=:]\s*\S+",
    r"Bearer\s+\S+",
    r"-----BEGIN\s+.*PRIVATE\s+KEY-----",
    r"-----BEGIN\s+.*CERTIFICATE-----",
]

_COMPILED_SANITIZERS = [
    (re.compile(pattern, re.IGNORECASE), "[REDACTED]")
    for pattern in SENSITIVE_PATTERNS
]

def sanitize_output(output: str) -> str:
    """
    过滤输出中的敏感信息。

    Args:
        output: 原始输出字符串

    Returns:
        过滤后的安全输出
    """
    sanitized = output
    for pattern, replacement in _COMPILED_SANITIZERS:
        sanitized = pattern.sub(replacement, sanitized)
    return sanitized

def contains_secrets(text: str) -> bool:
    """
    检查文本是否包含敏感信息。

    Args:
        text: 要检查的文本

    Returns:
        如果包含敏感信息返回 True
    """
    for pattern, _ in _COMPILED_SANITIZERS:
        if pattern.search(text):
            return True
    return False


Now I need to create the security module tests.


"""认证模块测试。"""

import pytest
from butler.security.auth import is_authorized, check_user_permission

def test_is_authorized_with_empty_whitelist():
    """空白名单允许所有用户。"""
    assert is_authorized("any_user", []) is True

def test_is_authorized_with_whitelist():
    """白名单限制访问。"""
    whitelist = ["user123", "user456"]
    assert is_authorized("user123", whitelist) is True
    assert is_authorized("user789", whitelist) is False

def test_check_user_permission_allowed():
    """允许的用户通过检查。"""
    result, msg = check_user_permission("user123", ["user123"])
    assert result is True
    assert msg is None

def test_check_user_permission_denied():
    """禁止的用户被拒绝。"""
    result, msg = check_user_permission("user789", ["user123"])
    assert result is False
    assert "无权限" in msg


"""危险命令检测测试。"""

import pytest
from butler.security.guardrail import is_dangerous_command

class TestDangerousCommandDetection:
    """危险命令检测测试。"""

    def test_safe_commands(self):
        """安全命令不被标记。"""
        assert is_dangerous_command("ls -la") is False
        assert is_dangerous_command("cat file.txt") is False
        assert is_dangerous_command("echo hello") is False
        assert is_dangerous_command("pwd") is False

    def test_rm_rf_detection(self):
        """检测 rm -rf。"""
        assert is_dangerous_command("rm -rf /") is True
        assert is_dangerous_command("rm -rf ./node_modules") is True
        assert is_dangerous_command("rm -fr /tmp") is True

    def test_chmod_777_detection(self):
        """检测 chmod 777。"""
        assert is_dangerous_command("chmod 777 /tmp") is True
        assert is_dangerous_command("chmod -R 777 /var") is True

    def test_sudo_detection(self):
        """检测 sudo 提权。"""
        assert is_dangerous_command("sudo rm file") is True
        assert is_dangerous_command("sudo apt update") is True

    def test_command_injection_detection(self):
        """检测命令注入。"""
        assert is_dangerous_command("sh -c 'rm -rf /'") is True
        assert is_dangerous_command("bash -c 'evil'") is True
        assert is_dangerous_command("echo 'rm -rf' | bash") is True

    def test_disk_operations_detection(self):
        """检测磁盘操作。"""
        assert is_dangerous_command("dd if=/dev/zero of=/dev/sda") is True
        assert is_dangerous_command("mkfs.ext4 /dev/sda1") is True
        assert is_dangerous_command("fdisk /dev/sda") is True

    def test_system_operations_detection(self):
        """检测系统操作。"""
        assert is_dangerous_command("shutdown now") is True
        assert is_dangerous_command("reboot") is True
        assert is_dangerous_command("iptables -F") is True

    def test_safe_rm_single_file(self):
        """单独删除文件是安全的。"""
        assert is_dangerous_command("rm file.txt") is False
        assert is_dangerous_command("rm -i file.txt") is False


File created successfully at: /root/projects/qq-shell/tests/test_auth.py

"""认证模块测试。"""

import pytest
from butler.security.auth import is_authorized, check_user_permission

def test_is_authorized_with_empty_whitelist():
    """空白名单允许所有用户。"""
    assert is_authorized("any_user", []) is True

def test_is_authorized_with_whitelist():
    """白名单限制访问。"""
    whitelist = ["user123", "user456"]
    assert is_authorized("user123", whitelist) is True
    assert is_authorized("user789", whitelist) is False

def test_check_user_permission_allowed():
    """允许的用户通过检查。"""
    result, msg = check_user_permission("user123", ["user123"])
    assert result is True
    assert msg is None

def test_check_user_permission_denied():
    """禁止的用户被拒绝。"""
    result, msg = check_user_permission("user789", ["user123"])
    assert result is False
    assert "无权限" in msg


File created successfully at: /root/projects/qq-shell/tests/test_guardrail.py

"""危险命令检测测试。"""

import pytest
from butler.security.guardrail import is_dangerous_command

class TestDangerousCommandDetection:
    """危险命令检测测试。"""

    def test_safe_commands(self):
        """安全命令不被标记。"""
        assert is_dangerous_command("ls -la") is False
        assert is_dangerous_command("cat file.txt") is False
        assert is_dangerous_command("echo hello") is False
        assert is_dangerous_command("pwd") is False

    def test_rm_rf_detection(self):
        """检测 rm -rf。"""
        assert is_dangerous_command("rm -rf /") is True
        assert is_dangerous_command("rm -rf ./node_modules") is True
        assert is_dangerous_command("rm -fr /tmp") is True

    def test_chmod_777_detection(self):
        """检测 chmod 777。"""
        assert is_dangerous_command("chmod 777 /tmp") is True
        assert is_dangerous_command("chmod -R 777 /var") is True

    def test_sudo_detection(self):
        """检测 sudo 提权。"""
        assert is_dangerous_command("sudo rm file") is True
        assert is_dangerous_command("sudo apt update") is True

    def test_command_injection_detection(self):
        """检测命令注入。"""
        assert is_dangerous_command("sh -c 'rm -rf /'") is True
        assert is_dangerous_command("bash -c 'evil'") is True
        assert is_dangerous_command("echo 'rm -rf' | bash") is True

    def test_disk_operations_detection(self):
        """检测磁盘操作。"""
        assert is_dangerous_command("dd if=/dev/zero of=/dev/sda") is True
        assert is_dangerous_command("mkfs.ext4 /dev/sda1") is True
        assert is_dangerous_command("fdisk /dev/sda") is True

    def test_system_operations_detection(self):
        """检测系统操作。"""
        assert is_dangerous_command("shutdown now") is True
        assert is_dangerous_command("reboot") is True
        assert is_dangerous_command("iptables -F") is True

    def test_safe_rm_single_file(self):
        """单独删除文件是安全的。"""
        assert is_dangerous_command("rm file.txt") is False
        assert is_dangerous_command("rm -i file.txt") is False


File created successfully at: /root/projects/qq-shell/tests/test_sanitizer.py


Chunk 1-2 完成。继续 Chunk 3: TmuxWrapper 核心类。


"""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_"):
        self.session_prefix = session_prefix

    def create_session(self, user_id: str) -> str:
        """为用户创建独立 tmux 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 是否存在。"""
        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。"""
        subprocess.run(
            ["tmux", "kill-session", "-t", session_name],
            capture_output=True,
        )

    def get_or_create_session(self, user_id: str) -> str:
        """获取或创建用户 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

    def send_keys(self, session_name: str, keys: str, enter: bool = True) -> None:
        """向 session 发送按键。"""
        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:
            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）。"""
        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:
        """抓取终端屏幕内容。"""
        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 窗口中的活跃进程名。"""
        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]

        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")

            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 是否在等待输入。"""
        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:
        """混合模式检测。"""
        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


"""TmuxWrapper 单元测试。"""

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

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

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)

        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

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

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

        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


File created successfully at: /root/projects/qq-shell/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_"):
        self.session_prefix = session_prefix

    def create_session(self, user_id: str) -> str:
        """为用户创建独立 tmux 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 是否存在。"""
        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。"""
        subprocess.run(
            ["tmux", "kill-session", "-t", session_name],
            capture_output=True,
        )

    def get_or_create_session(self, user_id: str) -> str:
        """获取或创建用户 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

    def send_keys(self, session_name: str, keys: str, enter: bool = True) -> None:
        """向 session 发送按键。"""
        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:
            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）。"""
        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:
        """抓取终端屏幕内容。"""
        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 窗口中的活跃进程名。"""
        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]

        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")

            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 是否在等待输入。"""
        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:
        """混合模式检测。"""
        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


File created successfully at: /root/projects/qq-shell/tests/test_wrapper.py

"""TmuxWrapper 单元测试。"""

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

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

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)

        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

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

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

        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


让我继续实现剩余的模块：Skills系统、工作流、Gateway和主入口。


Chunk 3 完成。继续 Chunk 4: Skill 扩展系统。


"""Skill 扩展系统。"""

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

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


"""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。"""
        pass

    def matches(self, content: str) -> bool:
        """检查是否匹配触发条件。"""
        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]:
        """从内容中解析参数。"""
        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。"""
        self._skills[skill.name] = skill

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

    def find_matching(self, content: str) -> Optional[SkillBase]:
        """查找匹配内容的 Skill。"""
        for skill in self._skills.values():
            if skill.matches(content):
                return skill
        return None

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


"""日志记录 Skill。"""

import sqlite3
from pathlib import Path

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"):
        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:
        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.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 "无匹配记录")


"""系统信息 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.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}")


File created successfully at: /root/projects/qq-shell/src/butler/skills/__init__.py

"""Skill 扩展系统。"""

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

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


File created successfully at: /root/projects/qq-shell/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。"""
        pass

    def matches(self, content: str) -> bool:
        """检查是否匹配触发条件。"""
        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]:
        """从内容中解析参数。"""
        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。"""
        self._skills[skill.name] = skill

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

    def find_matching(self, content: str) -> Optional[SkillBase]:
        """查找匹配内容的 Skill。"""
        for skill in self._skills.values():
            if skill.matches(content):
                return skill
        return None

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


File created successfully at: /root/projects/qq-shell/src/butler/skills/log_skill.py

"""日志记录 Skill。"""

import sqlite3
from pathlib import Path

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"):
        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:
        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.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 "无匹配记录")


File created successfully at: /root/projects/qq-shell/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.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}")


Let me continue with the remaining chunks: Skills tests, Inngest workflows, Gateway, and main entry point. I need to write the test files and the remaining modules.