帮我查看/shumengya/project/agent/sproutclaw这个项目


用户想要了解 /shumengya/project/agent/sproutclaw 这个项目。我需要先查看这个项目的结构。让我先列出项目根目录的内容。


总计 492
drwxr-xr-x  11 root root   4096  6月 2日 12:26 .
drwxr-xr-x   4 www  www    4096  5月12日 16:53 ..
-rw-r--r--   1 root root   9179  5月22日 20:05 AGENTS.md
-rw-r--r--   1 root root    788  5月22日 20:05 biome.json
-rw-r--r--   1 root root 151289  5月 6日 20:54 bun.lock
drwxr-xr-x   2 root root   4096  5月 8日 17:47 .claude
-rw-r--r--   1 root root   6950  5月12日 14:46 context.md
-rw-r--r--   1 root root   4177  5月 6日 20:53 CONTRIBUTING.md
drwxr-xr-x   8 root root   4096  5月22日 20:45 .git
-rw-r--r--   1 root root    382  5月 6日 20:53 .gitattributes
drwxr-xr-x   4 root root   4096  5月22日 20:05 .github
-rw-r--r--   1 root root    939  5月30日 16:26 .gitignore
drwxr-xr-x   3 root root   4096  5月22日 20:05 .husky
-rw-r--r--   1 root root   1069  5月 6日 20:53 LICENSE
drwxr-xr-x 247 root root  20480  6月 1日 12:27 node_modules
-rw-r--r--   1 root root     34  5月22日 20:05 .npmrc
-rw-r--r--   1 root root   2663  6月 1日 12:27 package.json
-rw-r--r--   1 root root 195425  6月 1日 12:27 package-lock.json
drwxr-xr-x   7 root root   4096  5月 6日 20:53 packages
drwxr-xr-x   3 root root   4096  5月30日 14:06 .pi
-rwxr-xr-x   1 root root   1585  5月30日 16:26 pi-built.sh
-rw-r--r--   1 root root    352  5月22日 20:05 pi-test.bat
-rw-r--r--   1 root root   1741  5月22日 20:05 pi-test.ps1
-rwxr-xr-x   1 root root   1680  5月22日 20:05 pi-test.sh
-rw-r--r--   1 root root    518  5月16日 01:00 progress.md
-rw-r--r--   1 root root   4876  5月30日 16:26 README.md
drwxr-xr-x   2 root root   4096  5月22日 20:38 scripts
-rwxr-xr-x   1 root root   1830  5月22日 20:05 test.sh
drwxr-xr-x   2 root root   4096  6月 1日 12:22 tmp
-rw-r--r--   1 root root    625  5月22日 20:05 tsconfig.base.json
-rw-r--r--   1 root root   1217  5月22日 20:05 tsconfig.json


看起来这是一个 Node.js 项目，有 packages 目录，可能是 monorepo 结构。让我查看一下 package.json 来了解项目的基本信息。


pi-monorepo

cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build

biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke

node scripts/profile-coding-agent-node.mjs --mode tui

node scripts/profile-coding-agent-node.mjs --mode rpc

npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only

npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only

npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only

npm run clean && npm run build && npm run check

npm run prepublishOnly && npm publish -ws --access public --dry-run


这是一个名为 "pi-monorepo" 的 monorepo 项目，使用 npm workspaces。让我查看一下 packages 目录来了解具体的包结构。


总计 28
drwxr-xr-x  7 root root 4096  5月 6日 20:53 .
drwxr-xr-x 11 root root 4096  6月 2日 12:26 ..
drwxr-xr-x  7 root root 4096  5月22日 20:05 agent
drwxr-xr-x  7 root root 4096  5月22日 20:05 ai
drwxr-xr-x  9 root root 4096  5月22日 20:05 coding-agent
drwxr-xr-x  7 root root 4096  5月22日 20:05 tui
drwxr-xr-x  5 root root 4096  5月22日 20:05 web-ui


# sproutclaw

`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手，基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造，重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。

## 定位

- 服务器运维助手：协助 SSH 登录、服务状态检查、日志分析、配置调整和部署排障。
- 开发协作助手：理解本地项目结构，执行代码修改、测试验证和 Git 工作流。
- Docker 部署助手：按项目目录组织 `docker compose` 服务，关注数据持久化、端口规划和资源限制。
- 内网服务助手：适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
- WebUI/TUI 双入口：保留控制台 TUI，同时扩展网页前端，方便在浏览器里管理会话和模型。

## 项目结构

| 路径 | 说明 |
| --- | --- |
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体（含 sproutclaw 定制补丁） |
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `.pi/agent/extensions/webui` | 网页对话入口（React 前端 + HTTP 服务） |
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展（中文启动页、命令安装等） |
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |

## 常用命令

```bash
npm install --ignore-scripts
npm run build
npm run check
./test.sh
./pi-test.sh
```

运行本地源码版 Agent：

```bash
./pi-test.sh
```

启动 WebUI 时，在 TUI 中执行：

```text
/webui on 19133
```

关闭 WebUI：

```text
/webui off
```

WebUI 前端单独构建：

```bash
cd .pi/agent/extensions/webui/frontend && npm install && npm run build
```

## Git 分支策略

本仓库是 pi-mono 的 fork，采用三层分支，同时维护**上游同步**和 **sproutclaw 定制**。

| 分支 | 用途 |
| --- | --- |
| `main` | 稳定可跑版本：已合并的上游 + sproutclaw 定制，推送到 GitHub |
| `upstream-sync` | 临时同步分支：专门用来 `merge upstream/main`，解决冲突后再合回 `main` |
| `feature/*` | 日常开发：WebUI、扩展、补丁等，完成后 merge 进 `main` |

### Remote 配置

```text
origin    → https://github.com/shumengya/sproutclaw.git   （你的 GitHub）
upstream  → https://github.com/badlogic/pi-mono.git       （上游 pi）
gitea     → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git （内网备份，可选）
```

首次配置 upstream：

```bash
git remote add upstream https://github.com/badlogic/pi-mono.git
```

GitHub 网络不稳定时：

```bash
export GIT_HTTP_VERSION=1.1
```

### 日常开发（feature 分支）

```bash
./scripts/new-feature.sh webui-avatar-fix   # 从 main 创建 feature/webui-avatar-fix
# ... 开发、测试 ...
git add <改动的具体文件>                    # 不要用 git add -A
git commit -m "fix(webui): agent avatar during tool calls"
git checkout main
git merge feature/webui-avatar-fix
./scripts/push-sproutclaw.sh
git branch -d feature/webui-avatar-fix      # 合并后可删
```

### 推送本地更新到 GitHub

```bash
git checkout main
./scripts/push-sproutclaw.sh
```

### 合并上游 pi-mono 更新

```bash
./scripts/sync-upstream.sh
# 若有冲突，按下方规则解决后：
git add <resolved-files>
git commit
git checkout main && git merge upstream-sync
./scripts/push-sproutclaw.sh
```

**合并冲突处理惯例：**

| 文件 | 处理方式 |
| --- | --- |
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
| `package-lock.json` | 采用 upstream，再执行 `npm install --ignore-scripts` |
| `*.generated.ts` | 采用 upstream |
| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |

合并后必做：

```bash
npm install --ignore-scripts
npm run build
./pi-test.sh
```

### sproutclaw 相对 upstream 的 intentional 改动

合并上游时，以下定制**不要误删**：

- `showChangelogOnStartup` 设置项（关闭启动 What's New）
- `.pi/agent/extensions/webui/` 网页 UI
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
- 本 README（仅 sproutclaw 说明，不含上游文档）

## 本地配置

`.pi/agent/` 中以下内容**不进 Git**（含密钥/内网地址）：

- `settings.json` → 参考 `settings.example.json` 本地复制
- `models.json`、`auth.json`
- `sessions/`

首次部署：

```bash
cp .pi/agent/settings.example.json .pi/agent/settings.json
# 编辑 settings.json 填入 provider / model
```

## 说明

这个仓库是自用 fork，默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono，通过 `upstream-sync` 分支定期同步，sproutclaw 定制在 `feature/*` 开发后合入 `main`。


现在我对这个项目有了清晰的了解。这是一个基于 pi-mono 改造的 Agent 助手项目，主要用于服务器运维和开发工作。让我再看看一些关键目录的结构，比如 .pi/agent/extensions 和 packages/coding-agent 的结构。


总计 88
drwx------ 10 root root  4096  6月 4日 21:08 .
drwxr-xr-x  3 root root  4096  5月30日 14:06 ..
lrwxrwxrwx  1 root root    35  5月30日 15:20 AGENTS.md -> /shumengya/project/prompt/AGENTS.md
-rw-------  1 root root     2  3月18日 09:06 auth.json
drwxr-xr-x  2 root root  4096  5月12日 15:00 bin
drwxr-xr-x 10 root root  4096  6月 1日 15:06 extensions
-rw-r--r--  1 root root 16305  6月 3日 22:26 mcp-cache.json
-rw-r--r--  1 root root   441  6月 2日 23:53 mcp.json
-rw-r--r--  1 root root   296  5月11日 19:58 mcp-npx-cache.json
-rw-r--r--  1 root root  6543  6月 3日 14:53 models.json
drwxr-xr-x  3 root root  4096  5月22日 21:19 npm
drwxr-xr-x  2 root root  4096  6月 3日 08:47 prompts
-rw-r--r--  1 root root   221  5月12日 14:46 run-history.jsonl
drwxr-xr-x  7 root root  4096  5月27日 00:15 sessions
-rw-r--r--  1 root root   315  5月30日 16:26 settings.example.json
-rw-r--r--  1 root root  1136  6月 3日 15:02 settings.json
drwxr-xr-x 16 root root  4096  6月 3日 12:13 skills
drwxr-xr-x  2 www  www   4096  6月 3日 12:13 skills-disabled
drwxr-xr-x  2 root root  4096  5月 7日 08:24 taskplane


让我看看 extensions 目录的结构，了解有哪些扩展。


总计 40
drwxr-xr-x 10 root root 4096  6月 1日 15:06 .
drwx------ 10 root root 4096  6月 4日 21:08 ..
drwxr-xr-x  2 root root 4096  6月 1日 15:04 exit-command
drwxr-xr-x  2 root root 4096  6月 1日 15:05 prompt-url-widget
drwxr-xr-x  2 root root 4096  6月 1日 15:05 redraws
drwxr-xr-x  2 root root 4096  6月 1日 15:05 sproutclaw-setup
drwxr-xr-x  2 root root 4096  6月 1日 15:06 startup-chinese
drwxr-xr-x  2 root root 4096  6月 1日 15:06 status-line
drwxr-xr-x  2 root root 4096  6月 1日 15:06 tps
drwxr-xr-x  7 root root 4096  6月 3日 13:19 webui


总计 516
drwxr-xr-x 9 root root   4096  5月22日 20:05 .
drwxr-xr-x 7 root root   4096  5月 6日 20:53 ..
-rw-r--r-- 1 root root 380368  5月22日 20:05 CHANGELOG.md
drwxr-xr-x 7 root root   4096  5月 6日 20:59 dist
drwxr-xr-x 3 root root   4096  5月22日 20:05 docs
drwxr-xr-x 4 root root   4096  5月 8日 19:40 examples
-rw-r--r-- 1 root root     12  5月 6日 20:53 .gitignore
drwxr-xr-x 8 root root   4096  5月 6日 21:01 node_modules
-rw-r--r-- 1 root root  60486  5月22日 20:05 npm-shrinkwrap.json
-rw-r--r-- 1 root root   3461  5月22日 20:05 package.json
-rw-r--r-- 1 root root  27170  5月22日 20:05 README.md
drwxr-xr-x 2 root root   4096  5月 6日 20:53 scripts
drwxr-xr-x 7 root root   4096  5月22日 20:05 src
drwxr-xr-x 5 root root   4096  5月22日 20:05 test
-rw-r--r-- 1 root root    196  5月22日 20:05 tsconfig.build.json
-rw-r--r-- 1 root root    484  5月 8日 19:40 tsconfig.examples.json
-rw-r--r-- 1 root root    990  5月 8日 19:40 vitest.config.ts


总计 120
drwxr-xr-x 7 root root  4096  5月22日 20:05 .
drwxr-xr-x 9 root root  4096  5月22日 20:05 ..
drwxr-xr-x 2 root root  4096  5月22日 20:05 bun
drwxr-xr-x 2 root root  4096  5月22日 20:05 cli
-rw-r--r-- 1 root root   682  5月22日 20:05 cli.ts
-rw-r--r-- 1 root root 17678  5月22日 20:05 config.ts
drwxr-xr-x 6 root root  4096  5月22日 20:38 core
-rw-r--r-- 1 root root  8572  5月22日 20:05 index.ts
-rw-r--r-- 1 root root 23634  5月22日 20:05 main.ts
-rw-r--r-- 1 root root  9244  5月22日 20:05 migrations.ts
drwxr-xr-x 4 root root  4096  5月22日 20:05 modes
-rw-r--r-- 1 root root 17275  5月22日 20:05 package-manager-cli.ts
drwxr-xr-x 2 root root  4096  5月22日 20:05 utils


让我看看 git log 来了解项目的最近开发情况。

bash

cd /shumengya/project/agent/sproutclaw && git log --oneline -10


11c3a3a3 fix(ci): satisfy ts-imports and regenerate image models
09227912 fix(webui): pin frontend deps for CI check:pinned-deps
0a91cc99 chore: add sproutclaw git workflow and track local extensions
a8daa1d8 merge: sync upstream pi-mono main into sproutclaw
7426ce97 Tighten AGENTS.md and extract LLM provider checklist to skill
c100620b fix(coding-agent): Clean up Path Handling (#4873)
bf56a86e fix(coding-agent): reconcile git package refs
1a2a536d chore: update PR prompt template
ced73b39 docs: note Node 20 rescue release
11c3da4f fix(ai): set bedrock claude default max tokens


让我看看项目的当前状态，包括分支信息。

cd /shumengya/project/agent/sproutclaw && git status


位于分支 main
您的分支与上游分支 'origin/main' 一致。

尚未暂存以备提交的变更：
  （使用 "git add/rm <文件>..." 更新要提交的内容）
  （使用 "git restore <文件>..." 丢弃工作区的改动）
	类型变更： .pi/agent/AGENTS.md
	删除：     .pi/agent/extensions/exit-command.ts
	删除：     .pi/agent/extensions/prompt-url-widget.ts
	删除：     .pi/agent/extensions/redraws.ts
	删除：     .pi/agent/extensions/sproutclaw-setup.ts
	删除：     .pi/agent/extensions/startup-chinese.ts
	删除：     .pi/agent/extensions/status-line.ts
	删除：     .pi/agent/extensions/tps.ts
	修改：     .pi/agent/extensions/webui/.gitignore
	删除：     .pi/agent/extensions/webui/db.ts
	修改：     .pi/agent/extensions/webui/frontend/.gitignore
	修改：     .pi/agent/extensions/webui/frontend/index.html
	修改：     .pi/agent/extensions/webui/frontend/package-lock.json
	修改：     .pi/agent/extensions/webui/frontend/package.json
	修改：     .pi/agent/extensions/webui/frontend/src/App.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/api/chat.ts
	修改：     .pi/agent/extensions/webui/frontend/src/api/client.ts
	修改：     .pi/agent/extensions/webui/frontend/src/api/sessions.ts
	修改：     .pi/agent/extensions/webui/frontend/src/api/settings.ts
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/ChatInput.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/ExportMenu.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/ExportMenu.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/MessageBubble.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/MessageList.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/MessageList.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/SessionContextBar.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/SessionContextBar.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/components/chat/ToolCallBlock.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/components/layout/Header.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/components/layout/Header.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/components/session/SessionList.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/components/session/SessionList.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/main.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/routes/SettingsPage.module.css
	修改：     .pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
	修改：     .pi/agent/extensions/webui/frontend/src/styles/global.css
	修改：     .pi/agent/extensions/webui/frontend/src/styles/variables.css
	修改：     .pi/agent/extensions/webui/frontend/src/types/events.ts
	修改：     .pi/agent/extensions/webui/frontend/src/types/message.ts
	修改：     .pi/agent/extensions/webui/frontend/src/utils/exportConversation.ts
	修改：     .pi/agent/extensions/webui/frontend/src/utils/sessionTitle.ts
	修改：     .pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
	修改：     .pi/agent/extensions/webui/frontend/src/vite-env.d.ts
	修改：     .pi/agent/extensions/webui/frontend/vite.config.ts
	修改：     .pi/agent/extensions/webui/index.ts
	修改：     .pi/agent/extensions/webui/server.ts
	删除：     .pi/prompts/is.md
	删除：     .pi/prompts/pr.md
	删除：     .pi/prompts/wr.md
	删除：     .pi/skills/add-llm-provider.md
	修改：     package-lock.json
	修改：     packages/agent/src/harness/skills.ts
	修改：     packages/agent/test/harness/skills.test.ts
	修改：     packages/ai/src/image-models.generated.ts
	修改：     packages/ai/src/models.generated.ts
	修改：     packages/coding-agent/src/core/skills.ts
	修改：     packages/coding-agent/src/modes/interactive/interactive-mode.ts
	修改：     packages/coding-agent/test/interactive-mode-status.test.ts
	修改：     packages/coding-agent/test/skills.test.ts
	修改：     scripts/update-source-imports-to-ts.sh

未跟踪的文件:
  （使用 "git add <文件>..." 以包含要提交的内容）
	.pi/agent/bin/
	.pi/agent/extensions/exit-command/
	.pi/agent/extensions/prompt-url-widget/
	.pi/agent/extensions/redraws/
	.pi/agent/extensions/sproutclaw-setup/
	.pi/agent/extensions/startup-chinese/
	.pi/agent/extensions/status-line/
	.pi/agent/extensions/tps/
	.pi/agent/extensions/webui/backend/
	.pi/agent/extensions/webui/frontend/.env.desktop
	.pi/agent/extensions/webui/frontend/src/api/base.ts
	.pi/agent/extensions/webui/frontend/src/api/commands.ts
	.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css
	.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx
	.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.module.css
	.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.tsx
	.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.module.css
	.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.tsx
	.pi/agent/extensions/webui/frontend/src/types/commands.ts
	.pi/agent/extensions/webui/frontend/src/utils/bash.ts
	.pi/agent/extensions/webui/frontend/src/utils/slashCommands.ts
	.pi/agent/extensions/webui/scripts/
	.pi/agent/extensions/webui/systemd/
	.pi/agent/mcp-npx-cache.json
	.pi/agent/mcp.json
	.pi/agent/prompts/
	.pi/agent/skills/
	.pi/agent/taskplane/
	packages/coding-agent/test/fixtures/skills/skills-dir-readme/
	pi-built.sh
	progress.md

修改尚未加入提交（使用 "git add" 和/或 "git commit -a"）


让我查看一下项目的进度文件，了解当前的开发状态。


# Progress

## Task: 搜索 2026-05-13 全球 AI 研究论文、突破与开源项目

Status: ❌ 受阻 — 无可用网络搜索工具

- [x] 任务启动
- [ ] 执行网络搜索 — 受阻：当前子代理仅暴露了 `read`/`write` 工具，无 `web_search`/`web_fetch` 工具
- [ ] 整理发现
- [ ] 撰写报告

## 原因

子代理工具集仅包含文件读写（`read`, `write`），缺少执行网络搜索所需的工具。需要父编排器提供搜索数据，或赋予子代理网络访问能力。


总计 568
drwxr-xr-x 6 root root   4096  5月22日 20:38 .
drwxr-xr-x 7 root root   4096  5月22日 20:05 ..
-rw-r--r-- 1 root root  14132  5月22日 20:05 agent-session-runtime.ts
-rw-r--r-- 1 root root   6771  5月22日 20:05 agent-session-services.ts
-rw-r--r-- 1 root root 103318  5月22日 20:05 agent-session.ts
-rw-r--r-- 1 root root    869  5月22日 20:05 auth-guidance.ts
-rw-r--r-- 1 root root  14188  5月22日 20:05 auth-storage.ts
-rw-r--r-- 1 root root   4487  5月22日 20:05 bash-executor.ts
drwxr-xr-x 2 root root   4096  5月22日 20:05 compaction
-rw-r--r-- 1 root root    132  5月 8日 19:40 defaults.ts
-rw-r--r-- 1 root root    452  5月 6日 20:53 diagnostics.ts
-rw-r--r-- 1 root root    795  5月 6日 20:53 event-bus.ts
-rw-r--r-- 1 root root   2419  5月22日 20:05 exec.ts
drwxr-xr-x 3 root root   4096  5月22日 20:05 export-html
drwxr-xr-x 2 root root   4096  5月22日 20:05 extensions
-rw-r--r-- 1 root root  10410  5月22日 20:05 footer-data-provider.ts
-rw-r--r-- 1 root root   1732  5月22日 20:05 http-dispatcher.ts
-rw-r--r-- 1 root root   2143  5月22日 20:05 index.ts
-rw-r--r-- 1 root root  11339  5月22日 20:05 keybindings.ts
-rw-r--r-- 1 root root   5180  5月 8日 19:40 messages.ts
-rw-r--r-- 1 root root  33132  5月22日 20:05 model-registry.ts
-rw-r--r-- 1 root root  21201  5月22日 20:05 model-resolver.ts
-rw-r--r-- 1 root root   2009  5月 6日 20:53 output-guard.ts
-rw-r--r-- 1 root root  76913  5月22日 20:05 package-manager.ts
-rw-r--r-- 1 root root   8156  5月22日 20:05 prompt-templates.ts
-rw-r--r-- 1 root root   1063  5月 9日 13:48 provider-display-names.ts
-rw-r--r-- 1 root root   4420  5月22日 20:05 resolve-config-value.ts
-rw-r--r-- 1 root root  32271  5月22日 20:05 resource-loader.ts
-rw-r--r-- 1 root root  13874  5月22日 20:05 sdk.ts
-rw-r--r-- 1 root root   1628  5月 6日 20:53 session-cwd.ts
-rw-r--r-- 1 root root  44502  5月22日 20:05 session-manager.ts
-rw-r--r-- 1 root root  34423  5月30日 16:26 settings-manager.ts
-rw-r--r-- 1 root root  14430  5月30日 16:26 skills.ts
-rw-r--r-- 1 root root   1972  5月22日 20:05 slash-commands.ts
-rw-r--r-- 1 root root    852  5月22日 20:05 source-info.ts
-rw-r--r-- 1 root root   6387  5月22日 20:05 system-prompt.ts
-rw-r--r-- 1 root root    515  5月22日 20:05 telemetry.ts
-rw-r--r-- 1 root root    841  5月 6日 20:53 timings.ts
drwxr-xr-x 2 root root   4096  5月22日 20:05 tools


让我查看一下 packages/agent 目录的结构，了解 Agent 运行时部分。


总计 76
drwxr-xr-x 7 root root  4096  5月22日 20:05 .
drwxr-xr-x 7 root root  4096  5月 6日 20:53 ..
-rw-r--r-- 1 root root 13953  5月22日 20:05 CHANGELOG.md
drwxr-xr-x 3 root root  4096  5月22日 20:09 dist
drwxr-xr-x 2 root root  4096  5月22日 20:05 docs
drwxr-xr-x 8 root root  4096  5月 6日 21:00 node_modules
-rw-r--r-- 1 root root  1387  5月22日 20:05 package.json
-rw-r--r-- 1 root root 15981  5月 8日 19:40 README.md
drwxr-xr-x 3 root root  4096  5月22日 20:05 src
drwxr-xr-x 5 root root  4096  5月22日 20:05 test
-rw-r--r-- 1 root root   209  5月 6日 20:53 tsconfig.build.json
-rw-r--r-- 1 root root   184  5月 6日 20:53 vitest.config.ts
-rw-r--r-- 1 root root   435  5月22日 20:05 vitest.harness.config.ts


让我查看一下 packages/ai 目录的结构，了解 LLM 接入层。


总计 200
drwxr-xr-x  7 root root  4096  5月22日 20:05 .
drwxr-xr-x  7 root root  4096  5月 6日 20:53 ..
-rw-r--r--  1 root root    44  5月 6日 20:53 bedrock-provider.d.ts
-rw-r--r--  1 root root    44  5月 6日 20:53 bedrock-provider.js
-rw-r--r--  1 root root 94536  5月22日 20:05 CHANGELOG.md
drwxr-xr-x  4 root root  4096  5月12日 14:06 dist
drwxr-xr-x 11 root root  4096  5月 6日 21:00 node_modules
-rw-r--r--  1 root root  2734  5月22日 20:05 package.json
-rw-r--r--  1 root root 54025  5月22日 20:05 README.md
drwxr-xr-x  2 root root  4096  5月22日 20:05 scripts
drwxr-xr-x  4 root root  4096  5月22日 20:45 src
drwxr-xr-x  3 root root  4096  5月22日 20:05 test
-rw-r--r--  1 root root   208  5月 6日 20:53 tsconfig.build.json
-rw-r--r--  1 root root   190  5月 6日 20:53 vitest.config.ts


让我查看一下 packages/ai/src 目录的结构，了解具体的 LLM 模型实现。


总计 528
drwxr-xr-x 4 root root   4096  5月22日 20:45 .
drwxr-xr-x 7 root root   4096  5月22日 20:05 ..
-rw-r--r-- 1 root root   2563  5月22日 20:05 api-registry.ts
-rw-r--r-- 1 root root    165  5月22日 20:05 bedrock-provider.ts
-rw-r--r-- 1 root root   3881  5月22日 20:05 cli.ts
-rw-r--r-- 1 root root   7778  5月22日 20:05 env-api-keys.ts
-rw-r--r-- 1 root root  12376  6月 3日 13:23 image-models.generated.ts
-rw-r--r-- 1 root root   1667  5月22日 20:05 image-models.ts
-rw-r--r-- 1 root root   1640  5月22日 20:05 images-api-registry.ts
-rw-r--r-- 1 root root    727  5月22日 20:05 images.ts
-rw-r--r-- 1 root root   1851  5月22日 20:05 index.ts
-rw-r--r-- 1 root root 428219  6月 3日 13:23 models.generated.ts
-rw-r--r-- 1 root root   3609  5月22日 20:05 models.ts
-rw-r--r-- 1 root root     40  5月22日 20:05 oauth.ts
drwxr-xr-x 3 root root   4096  5月22日 20:05 providers
-rw-r--r-- 1 root root    671  5月 6日 20:53 session-resources.ts
-rw-r--r-- 1 root root   1486  5月22日 20:05 stream.ts
-rw-r--r-- 1 root root  21541  5月22日 20:05 types.ts
drwxr-xr-x 3 root root   4096  5月22日 20:05 utils


让我查看一下 packages/ai/src/providers 目录，了解支持的 provider。


总计 336
drwxr-xr-x 3 root root  4096  5月22日 20:05 .
drwxr-xr-x 4 root root  4096  5月22日 20:45 ..
-rw-r--r-- 1 root root 32715  5月22日 20:05 amazon-bedrock.ts
-rw-r--r-- 1 root root 37322  5月22日 20:05 anthropic.ts
-rw-r--r-- 1 root root  9838  5月22日 20:05 azure-openai-responses.ts
-rw-r--r-- 1 root root  1531  5月22日 20:05 cloudflare.ts
-rw-r--r-- 1 root root 15332  5月22日 20:05 faux.ts
-rw-r--r-- 1 root root  1184  5月22日 20:05 github-copilot-headers.ts
-rw-r--r-- 1 root root 11883  5月22日 20:05 google-shared.ts
-rw-r--r-- 1 root root 14819  5月22日 20:05 google.ts
-rw-r--r-- 1 root root 16724  5月22日 20:05 google-vertex.ts
drwxr-xr-x 2 root root  4096  5月22日 20:05 images
-rw-r--r-- 1 root root 20312  5月22日 20:05 mistral.ts
-rw-r--r-- 1 root root 43563  5月22日 20:05 openai-codex-responses.ts
-rw-r--r-- 1 root root 40585  5月22日 20:05 openai-completions.ts
-rw-r--r-- 1 root root   358  5月22日 20:05 openai-prompt-cache.ts
-rw-r--r-- 1 root root 19120  5月22日 20:05 openai-responses-shared.ts
-rw-r--r-- 1 root root 10193  5月22日 20:05 openai-responses.ts
-rw-r--r-- 1 root root 14520  5月22日 20:05 register-builtins.ts
-rw-r--r-- 1 root root  1763  5月22日 20:05 simple-options.ts
-rw-r--r-- 1 root root  6837  5月22日 20:05 transform-messages.ts


让我查看一下 packages/tui 目录的结构，了解终端 UI 部分。


总计 112
drwxr-xr-x 7 root root  4096  5月22日 20:05 .
drwxr-xr-x 7 root root  4096  5月 6日 20:53 ..
-rw-r--r-- 1 root root 46373  5月22日 20:05 CHANGELOG.md
drwxr-xr-x 3 root root  4096  5月 6日 20:54 dist
drwxr-xr-x 3 root root  4096  5月22日 20:05 native
drwxr-xr-x 5 root root  4096  5月 6日 20:58 node_modules
-rw-r--r-- 1 root root  1037  5月22日 20:05 package.json
-rw-r--r-- 1 root root 23668  5月 8日 19:40 README.md
drwxr-xr-x 3 root root  4096  5月22日 20:05 src
drwxr-xr-x 2 root root  4096  5月22日 20:05 test
-rw-r--r-- 1 root root   175  5月 6日 20:53 tsconfig.build.json
-rw-r--r-- 1 root root   133  5月 6日 20:53 vitest.config.ts


让我查看一下 packages/web-ui 目录的结构，了解 Web UI 部分。


总计 20
drwxr-xr-x 5 root root 4096  5月22日 20:05 .
drwxr-xr-x 7 root root 4096  5月 6日 20:53 ..
drwxr-xr-x 8 root root 4096  5月 6日 20:59 dist
drwxr-xr-x 3 root root 4096  5月22日 20:05 example
drwxr-xr-x 6 root root 4096  5月 6日 20:58 node_modules


让我查看一下 .pi/agent/extensions/webui 目录的结构，了解 WebUI 扩展。


总计 60
drwxr-xr-x  7 root root  4096  6月 3日 13:19 .
drwxr-xr-x 10 root root  4096  6月 1日 15:06 ..
drwxr-xr-x 11 root root  4096  6月 1日 17:13 backend
drwxr-xr-x  2 root root  4096  6月 3日 13:19 data
drwxr-xr-x  8 root root  4096  6月 1日 19:56 frontend
-rw-r--r--  1 root root    97  6月 3日 11:58 .gitignore
-rw-r--r--  1 root root 14562  6月 3日 11:58 index.ts
drwxr-xr-x  2 root root  4096  6月 1日 17:22 scripts
-rw-r--r--  1 root root   161  6月 3日 11:58 server.ts
drwxr-xr-x  2 root root  4096  6月 1日 17:21 systemd
-rw-r--r--  1 root root     7  6月 3日 13:19 .webui.pid
-rw-r--r--  1 root root   193  5月22日 16:16 webui-settings.json.migrated


让我查看一下 .pi/agent/skills 目录的结构，了解 skills 系统。


总计 68
drwxr-xr-x 16 root root 4096  6月 3日 12:13 .
drwx------ 10 root root 4096  6月 4日 21:08 ..
drwxr-xr-x  5 root root 4096  6月 3日 12:13 cloudflare-deploy
drwxr-xr-x  3 root root 4096  5月12日 15:00 command-help-zh-skill
drwxr-xr-x  3 root root 4096  5月13日 12:32 gitea-tea-skill
drwxr-xr-x  4 root root 4096  5月12日 15:00 github-gh-skill
drwxr-xr-x  6 root root 4096  5月12日 15:00 linux-ssh-operator-skill
drwxr-xr-x  4 root root 4096  5月13日 12:58 mcp-builder
drwxr-xr-x  4 root root 4096  5月12日 15:00 mengya-mail-skill
drwxr-xr-x  3 root root 4096  5月13日 14:51 mengya-rag
drwxr-xr-x  2 root root 4096  5月13日 00:52 officecli
-rw-r--r--  1 root root 1313  5月12日 14:54 README.md
drw-------  2 root root 4096  5月30日 15:13 .Recycle_bin
drwxr-xr-x  4 root root 4096  5月13日 12:01 repo-to-resume-tailor
drwxr-xr-x  7 root root 4096  5月13日 12:58 skill-creator
drwxr-xr-x  2 root root 4096  5月22日 20:05 skills
drwxr-xr-x  7 root root 4096  6月 2日 15:00 .system


让我查看一下 .pi/agent/skills/README.md，了解 skills 的使用方法。


# Skills

这里是本机通用 Skills 目录，给大模型统一放可复用的技能包、流程说明和模板。

## 目录约定

- 每个 Skill 单独一个子目录
- 入口文件通常使用 `SKILL.md`
- 可按需包含 `references/`、`scripts/`、`assets/` 等辅助目录
- Skill 内尽量写清楚触发条件、输入输出、约束和操作步骤

## 推荐结构

```text
/root/skills/
  your-skill/
    SKILL.md
    references/
    scripts/
    assets/
```

## 当前状态

- 目前这个目录已作为小萌芽本机的日常开发主目录使用
- 先在这里维护、测试和迭代 Skill，确认稳定后再同步到 `~/.codex/skills/` 和 `~/.claude/skills/`
- 当前已放入的自定义 Skill：
  - `command-help-zh-skill`
  - `mengya-mail-skill`
  - `mengya-search-skill`
  - `linux-ssh-operator-skill`
- 前两个 Skill 的实现分别对接：
  - `/shumengya/project/python/mengya-mail-api/src/mengya_mail_api/`
  - `/shumengya/project/python/mengya-search-api/src/mengya_search_api/`
- `linux-ssh-operator-skill` 的脚本与说明位于：
  - `/shumengya/project/skills/linux-ssh-operator-skill/`
- 如果某些 Skill 需要接入特定工具或工作流，建议在 `SKILL.md` 顶部先写清适用范围

当前入口请直接走 `skills/...` 和对应的 `python/*-api/`。


现在我对这个项目有了全面的了解。让我总结一下这个项目的基本情况。

根据我的查看，`/shumengya/project/agent/sproutclaw` 是一个基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造的 **Agent 助手项目**，主要面向服务器运维与开发工作。

## 项目概览

**定位**：服务器运维助手 + 开发协作助手 + Docker 部署助手

**技术栈**：Node.js (v22.19+) + TypeScript，使用 npm workspaces 管理的 monorepo 结构

## 核心包结构

| 包名 | 说明 |
|------|------|
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体（sproutclaw 定制） |
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `packages/web-ui` | Web UI 示例/前端 |

## 主要功能模块

- **LLM Provider 支持**：Anthropic、OpenAI、Google、Mistral、Amazon Bedrock、Cloudflare、Azure OpenAI 等
- **WebUI 扩展**：`.pi/agent/extensions/webui/` 提供网页对话入口（React 前端 + HTTP 服务）
- **Skills 系统**：`.pi/agent/skills/` 目录下的技能包（SSH 操作、邮件、搜索、文档处理等）
- **本地化扩展**：中文启动页、命令帮助翻译等

## 项目状态

- **分支**：main 分支，与 origin/main 同步
- **未提交变更**：有较多修改（主要是 WebUI 扩展和部分包的更新）
- **最近提交**：主要是 CI 修复、依赖固定、上游同步等

## 常用命令

```bash
npm install --ignore-scripts  # 安装依赖
npm run build                # 构建
npm run check                # 代码检查
./pi-test.sh                 # 本地测试运行
```

项目整体结构清晰，是一个功能完善的 AI Agent 开发框架，特别针对内网服务器环境进行了优化 (｡･ω･｡)


用户想了解 MCP 是什么，以及项目中 MCP 的完整工作流程。我需要先查看项目中关于 MCP 的文档和代码实现。

首先，我应该搜索项目中与 MCP 相关的文件和代码，了解 MCP 的具体实现。让我先查看项目根目录下是否有 MCP 相关的文档，然后查看代码中的 MCP 实现。

find /shumengya/project/agent/sproutclaw -name "*mcp*" -type f 2>/dev/null | head -20


让我查看一下项目中的 MCP 配置文件和相关文档。首先查看 mcp.json 配置文件。


现在让我查看一下项目中关于 MCP 的文档，特别是 mcp-builder 技能包中的参考文档。


# MCP Server Best Practices

## Quick Reference

### Server Naming
- **Python**: `{service}_mcp` (e.g., `slack_mcp`)
- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`)

### Tool Naming
- Use snake_case with service prefix
- Format: `{service}_{action}_{resource}`
- Example: `slack_send_message`, `github_create_issue`

### Response Formats
- Support both JSON and Markdown formats
- JSON for programmatic processing
- Markdown for human readability

### Pagination
- Always respect `limit` parameter
- Return `has_more`, `next_offset`, `total_count`
- Default to 20-50 items

### Transport
- **Streamable HTTP**: For remote servers, multi-client scenarios
- **stdio**: For local integrations, command-line tools
- Avoid SSE (deprecated in favor of streamable HTTP)

---

## Server Naming Conventions

Follow these standardized naming patterns:

**Python**: Use format `{service}_mcp` (lowercase with underscores)
- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`

**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens)
- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server`

The name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers.

---

## Tool Naming and Design

### Tool Naming

1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info`
2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers
   - Use `slack_send_message` instead of just `send_message`
   - Use `github_create_issue` instead of just `create_issue`
3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.)
4. **Be specific**: Avoid generic names that could conflict with other servers

### Tool Design

- Tool descriptions must narrowly and unambiguously describe functionality
- Descriptions must precisely match actual functionality
- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
- Keep tool operations focused and atomic

---

## Response Formats

All tools that return data should support multiple formats:

### JSON Format (`response_format="json"`)
- Machine-readable structured data
- Include all available fields and metadata
- Consistent field names and types
- Use for programmatic processing

### Markdown Format (`response_format="markdown"`, typically default)
- Human-readable formatted text
- Use headers, lists, and formatting for clarity
- Convert timestamps to human-readable format
- Show display names with IDs in parentheses
- Omit verbose metadata

---

## Pagination

For tools that list resources:

- **Always respect the `limit` parameter**
- **Implement pagination**: Use `offset` or cursor-based pagination
- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count`
- **Never load all results into memory**: Especially important for large datasets
- **Default to reasonable limits**: 20-50 items is typical

Example pagination response:
```json
{
  "total": 150,
  "count": 20,
  "offset": 0,
  "items": [...],
  "has_more": true,
  "next_offset": 20
}
```

---

## Transport Options

### Streamable HTTP

**Best for**: Remote servers, web services, multi-client scenarios

**Characteristics**:
- Bidirectional communication over HTTP
- Supports multiple simultaneous clients
- Can be deployed as a web service
- Enables server-to-client notifications

**Use when**:
- Serving multiple clients simultaneously
- Deploying as a cloud service
- Integration with web applications

### stdio

**Best for**: Local integrations, command-line tools

**Characteristics**:
- Standard input/output stream communication
- Simple setup, no network configuration needed
- Runs as a subprocess of the client

**Use when**:
- Building tools for local development environments
- Integrating with desktop applications
- Single-user, single-session scenarios

**Note**: stdio servers should NOT log to stdout (use stderr for logging)

### Transport Selection

| Criterion | stdio | Streamable HTTP |
|-----------|-------|-----------------|
| **Deployment** | Local | Remote |
| **Clients** | Single | Multiple |
| **Complexity** | Low | Medium |
| **Real-time** | No | Yes |

---

## Security Best Practices

### Authentication and Authorization

**OAuth 2.1**:
- Use secure OAuth 2.1 with certificates from recognized authorities
- Validate access tokens before processing requests
- Only accept tokens specifically intended for your server

**API Keys**:
- Store API keys in environment variables, never in code
- Validate keys on server startup
- Provide clear error messages when authentication fails

### Input Validation

- Sanitize file paths to prevent directory traversal
- Validate URLs and external identifiers
- Check parameter sizes and ranges
- Prevent command injection in system calls
- Use schema validation (Pydantic/Zod) for all inputs

### Error Handling

- Don't expose internal errors to clients
- Log security-relevant errors server-side
- Provide helpful but not revealing error messages
- Clean up resources after errors

### DNS Rebinding Protection

For streamable HTTP servers running locally:
- Enable DNS rebinding protection
- Validate the `Origin` header on all incoming connections
- Bind to `127.0.0.1` rather than `0.0.0.0`

---

## Tool Annotations

Provide annotations to help clients understand tool behavior:

| Annotation | Type | Default | Description |
|-----------|------|---------|-------------|
| `readOnlyHint` | boolean | false | Tool does not modify its environment |
| `destructiveHint` | boolean | true | Tool may perform destructive updates |
| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect |
| `openWorldHint` | boolean | true | Tool interacts with external entities |

**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations.

---

## Error Handling

- Use standard JSON-RPC error codes
- Report tool errors within result objects (not protocol-level errors)
- Provide helpful, specific error messages with suggested next steps
- Don't expose internal implementation details
- Clean up resources properly on errors

Example error handling:
```typescript
try {
  const result = performOperation();
  return { content: [{ type: "text", text: result }] };
} catch (error) {
  return {
    isError: true,
    content: [{
      type: "text",
      text: `Error: ${error.message}. Try using filter='active_only' to reduce results.`
    }]
  };
}
```

---

## Testing Requirements

Comprehensive testing should cover:

- **Functional testing**: Verify correct execution with valid/invalid inputs
- **Integration testing**: Test interaction with external systems
- **Security testing**: Validate auth, input sanitization, rate limiting
- **Performance testing**: Check behavior under load, timeouts
- **Error handling**: Ensure proper error reporting and cleanup

---

## Documentation Requirements

- Provide clear documentation of all tools and capabilities
- Include working examples (at least 3 per major feature)
- Document security considerations
- Specify required permissions and access levels
- Document rate limits and performance characteristics


让我查看一下项目中 MCP 的代码实现，特别是 coding-agent 中的 MCP 相关代码。

find /shumengya/project/agent/sproutclaw/packages -name "*.ts" -type f -exec grep -l "mcp\|MCP" {} \; 2>/dev/null | head -20


让我查看一下 coding-agent 中的 MCP 相关代码，特别是 skills.ts 文件，因为之前看到有修改。


import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import ignore from "ignore";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";

/** Max name length per spec */
const MAX_NAME_LENGTH = 64;

/** Max description length per spec */
const MAX_DESCRIPTION_LENGTH = 1024;

const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];

/** Root-level markdown files that document the skills directory, not skills themselves. */
const IGNORED_ROOT_SKILL_MARKDOWN = new Set(["README.md"]);

type IgnoreMatcher = ReturnType<typeof ignore>;

function isIgnoredRootSkillMarkdown(fileName: string): boolean {
	return IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === "readme.md";
}

function toPosixPath(p: string): string {
	return p.split(sep).join("/");
}

function prefixIgnorePattern(line: string, prefix: string): string | null {
	const trimmed = line.trim();
	if (!trimmed) return null;
	if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null;

	let pattern = line;
	let negated = false;

	if (pattern.startsWith("!")) {
		negated = true;
		pattern = pattern.slice(1);
	} else if (pattern.startsWith("\\!")) {
		pattern = pattern.slice(1);
	}

	if (pattern.startsWith("/")) {
		pattern = pattern.slice(1);
	}

	const prefixed = prefix ? `${prefix}${pattern}` : pattern;
	return negated ? `!${prefixed}` : prefixed;
}

function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {
	const relativeDir = relative(rootDir, dir);
	const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : "";

	for (const filename of IGNORE_FILE_NAMES) {
		const ignorePath = join(dir, filename);
		if (!existsSync(ignorePath)) continue;
		try {
			const content = readFileSync(ignorePath, "utf-8");
			const patterns = content
				.split(/\r?\n/)
				.map((line) => prefixIgnorePattern(line, prefix))
				.filter((line): line is string => Boolean(line));
			if (patterns.length > 0) {
				ig.add(patterns);
			}
		} catch {}
	}
}

export interface SkillFrontmatter {
	name?: string;
	description?: string;
	"disable-model-invocation"?: boolean;
	[key: string]: unknown;
}

export interface Skill {
	name: string;
	description: string;
	filePath: string;
	baseDir: string;
	sourceInfo: SourceInfo;
	disableModelInvocation: boolean;
}

export interface LoadSkillsResult {
	skills: Skill[];
	diagnostics: ResourceDiagnostic[];
}

/**
 * Validate skill name per Agent Skills spec.
 * Returns array of validation error messages (empty if valid).
 */
function validateName(name: string): string[] {
	const errors: string[] = [];

	if (name.length > MAX_NAME_LENGTH) {
		errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
	}

	if (!/^[a-z0-9-]+$/.test(name)) {
		errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);
	}

	if (name.startsWith("-") || name.endsWith("-")) {
		errors.push(`name must not start or end with a hyphen`);
	}

	if (name.includes("--")) {
		errors.push(`name must not contain consecutive hyphens`);
	}

	return errors;
}

/**
 * Validate description per Agent Skills spec.
 */
function validateDescription(description: string | undefined): string[] {
	const errors: string[] = [];

	if (!description || description.trim() === "") {
		errors.push("description is required");
	} else if (description.length > MAX_DESCRIPTION_LENGTH) {
		errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);
	}

	return errors;
}

export interface LoadSkillsFromDirOptions {
	/** Directory to scan for skills */
	dir: string;
	/** Source identifier for these skills */
	source: string;
}

function createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {
	switch (source) {
		case "user":
			return createSyntheticSourceInfo(filePath, {
				source: "local",
				scope: "user",
				baseDir,
			});
		case "project":
			return createSyntheticSourceInfo(filePath, {
				source: "local",
				scope: "project",
				baseDir,
			});
		case "path":
			return createSyntheticSourceInfo(filePath, {
				source: "local",
				baseDir,
			});
		default:
			return createSyntheticSourceInfo(filePath, { source, baseDir });
	}
}

/**
 * Load skills from a directory.
 *
 * Discovery rules:
 * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further
 * - otherwise, load direct .md children in the root
 * - recurse into subdirectories to find SKILL.md
 */
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
	const { dir, source } = options;
	return loadSkillsFromDirInternal(dir, source, true);
}

function loadSkillsFromDirInternal(
	dir: string,
	source: string,
	includeRootFiles: boolean,
	ignoreMatcher?: IgnoreMatcher,
	rootDir?: string,
): LoadSkillsResult {
	const skills: Skill[] = [];
	const diagnostics: ResourceDiagnostic[] = [];

	if (!existsSync(dir)) {
		return { skills, diagnostics };
	}

	const root = rootDir ?? dir;
	const ig = ignoreMatcher ?? ignore();
	addIgnoreRules(ig, dir, root);

	try {
		const entries = readdirSync(dir, { withFileTypes: true });

		for (const entry of entries) {
			if (entry.name !== "SKILL.md") {
				continue;
			}

			const fullPath = join(dir, entry.name);

			let isFile = entry.isFile();
			if (entry.isSymbolicLink()) {
				try {
					isFile = statSync(fullPath).isFile();
				} catch {
					continue;
				}
			}

			const relPath = toPosixPath(relative(root, fullPath));
			if (!isFile || ig.ignores(relPath)) {
				continue;
			}

			const result = loadSkillFromFile(fullPath, source);
			if (result.skill) {
				skills.push(result.skill);
			}
			diagnostics.push(...result.diagnostics);
			return { skills, diagnostics };
		}

		for (const entry of entries) {
			if (entry.name.startsWith(".")) {
				continue;
			}

			// Skip node_modules to avoid scanning dependencies
			if (entry.name === "node_modules") {
				continue;
			}

			const fullPath = join(dir, entry.name);

			// For symlinks, check if they point to a directory and follow them
			let isDirectory = entry.isDirectory();
			let isFile = entry.isFile();
			if (entry.isSymbolicLink()) {
				try {
					const stats = statSync(fullPath);
					isDirectory = stats.isDirectory();
					isFile = stats.isFile();
				} catch {
					// Broken symlink, skip it
					continue;
				}
			}

			const relPath = toPosixPath(relative(root, fullPath));
			const ignorePath = isDirectory ? `${relPath}/` : relPath;
			if (ig.ignores(ignorePath)) {
				continue;
			}

			if (isDirectory) {
				const subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root);
				skills.push(...subResult.skills);
				diagnostics.push(...subResult.diagnostics);
				continue;
			}

			if (!isFile || !includeRootFiles || !entry.name.endsWith(".md") || isIgnoredRootSkillMarkdown(entry.name)) {
				continue;
			}

			const result = loadSkillFromFile(fullPath, source);
			if (result.skill) {
				skills.push(result.skill);
			}
			diagnostics.push(...result.diagnostics);
		}
	} catch {}

	return { skills, diagnostics };
}

function loadSkillFromFile(
	filePath: string,
	source: string,
): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {
	if (isIgnoredRootSkillMarkdown(basename(filePath))) {
		return { skill: null, diagnostics: [] };
	}

	const diagnostics: ResourceDiagnostic[] = [];

	try {
		const rawContent = readFileSync(filePath, "utf-8");
		const { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);
		const skillDir = dirname(filePath);
		const parentDirName = basename(skillDir);

		// Validate description
		const descErrors = validateDescription(frontmatter.description);
		for (const error of descErrors) {
			diagnostics.push({ type: "warning", message: error, path: filePath });
		}

		// Use name from frontmatter, or fall back to parent directory name
		const name = frontmatter.name || parentDirName;

		// Validate name
		const nameErrors = validateName(name);
		for (const error of nameErrors) {
			diagnostics.push({ type: "warning", message: error, path: filePath });
		}

		// Still load the skill even with warnings (unless description is completely missing)
		if (!frontmatter.description || frontmatter.description.trim() === "") {
			return { skill: null, diagnostics };
		}

		return {
			skill: {
				name,
				description: frontmatter.description,
				filePath,
				baseDir: skillDir,
				sourceInfo: createSkillSourceInfo(filePath, skillDir, source),
				disableModelInvocation: frontmatter["disable-model-invocation"] === true,
			},
			diagnostics,
		};
	} catch (error) {
		const message = error instanceof Error ? error.message : "failed to parse skill file";
		diagnostics.push({ type: "warning", message, path: filePath });
		return { skill: null, diagnostics };
	}
}

/**
 * Format skills for inclusion in a system prompt.
 * Uses XML format per Agent Skills standard.
 * See: https://agentskills.io/integrate-skills
 *
 * Skills with disableModelInvocation=true are excluded from the prompt
 * (they can only be invoked explicitly via /skill:name commands).
 */
export function formatSkillsForPrompt(skills: Skill[]): string {
	const visibleSkills = skills.filter((s) => !s.disableModelInvocation);

	if (visibleSkills.length === 0) {
		return "";
	}

	const lines = [
		"\n\nThe following skills provide specialized instructions for specific tasks.",
		"Use the read tool to load a skill's file when the task matches its description.",
		"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
		"",
		"<available_skills>",
	];

	for (const skill of visibleSkills) {
		lines.push("  <skill>");
		lines.push(`    <name>${escapeXml(skill.name)}</name>`);
		lines.push(`    <description>${escapeXml(skill.description)}</description>`);
		lines.push(`    <location>${escapeXml(skill.filePath)}</location>`);
		lines.push("  </skill>");
	}

	lines.push("</available_skills>");

	return lines.join("\n");
}

function escapeXml(str: string): string {
	return str
		.replace(/&/g, "&")
		.replace(/</g, "<")
		.replace(/>/g, ">")
		.replace(/"/g, """)
		.replace(/'/g, "'");
}

export interface LoadSkillsOptions {
	/** Working directory for project-local skills. */
	cwd: string;
	/** Agent config directory for global skills. */
	agentDir: string;
	/** Explicit skill paths (files or directories) */
	skillPaths: string[];
	/** Include default skills directories. */
	includeDefaults: boolean;
}

/**
 * Load skills from all configured locations.
 * Returns skills and any validation diagnostics.
 */
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
	const { agentDir, skillPaths, includeDefaults } = options;

	// Resolve agentDir - if not provided, use default from config
	const resolvedCwd = resolvePath(options.cwd);
	const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());

	const skillMap = new Map<string, Skill>();
	const realPathSet = new Set<string>();
	const allDiagnostics: ResourceDiagnostic[] = [];
	const collisionDiagnostics: ResourceDiagnostic[] = [];

	function addSkills(result: LoadSkillsResult) {
		allDiagnostics.push(...result.diagnostics);
		for (const skill of result.skills) {
			// Resolve symlinks to detect duplicate files
			const realPath = canonicalizePath(skill.filePath);

			// Skip silently if we've already loaded this exact file (via symlink)
			if (realPathSet.has(realPath)) {
				continue;
			}

			const existing = skillMap.get(skill.name);
			if (existing) {
				collisionDiagnostics.push({
					type: "collision",
					message: `name "${skill.name}" collision`,
					path: skill.filePath,
					collision: {
						resourceType: "skill",
						name: skill.name,
						winnerPath: existing.filePath,
						loserPath: skill.filePath,
					},
				});
			} else {
				skillMap.set(skill.name, skill);
				realPathSet.add(realPath);
			}
		}
	}

	if (includeDefaults) {
		addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
		addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
	}

	const userSkillsDir = join(resolvedAgentDir, "skills");
	const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");

	const isUnderPath = (target: string, root: string): boolean => {
		const normalizedRoot = resolve(root);
		if (target === normalizedRoot) {
			return true;
		}
		const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
		return target.startsWith(prefix);
	};

	const getSource = (resolvedPath: string): "user" | "project" | "path" => {
		if (!includeDefaults) {
			if (isUnderPath(resolvedPath, userSkillsDir)) return "user";
			if (isUnderPath(resolvedPath, projectSkillsDir)) return "project";
		}
		return "path";
	};

	for (const rawPath of skillPaths) {
		const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
		if (!existsSync(resolvedPath)) {
			allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath });
			continue;
		}

		try {
			const stats = statSync(resolvedPath);
			const source = getSource(resolvedPath);
			if (stats.isDirectory()) {
				addSkills(loadSkillsFromDirInternal(resolvedPath, source, true));
			} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
				const result = loadSkillFromFile(resolvedPath, source);
				if (result.skill) {
					addSkills({ skills: [result.skill], diagnostics: result.diagnostics });
				} else {
					allDiagnostics.push(...result.diagnostics);
				}
			} else {
				allDiagnostics.push({ type: "warning", message: "skill path is not a markdown file", path: resolvedPath });
			}
		} catch (error) {
			const message = error instanceof Error ? error.message : "failed to read skill path";
			allDiagnostics.push({ type: "warning", message, path: resolvedPath });
		}
	}

	return {
		skills: Array.from(skillMap.values()),
		diagnostics: [...allDiagnostics, ...collisionDiagnostics],
	};
}


让我查看一下 coding-agent 中的 agent-session.ts 文件，看看 MCP 是如何被集成的。先搜索一下 MCP 相关的代码。

grep -n "mcp\|MCP" /shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session.ts | head -30


(no output)


让我搜索一下项目中所有与 MCP 相关的代码，看看 MCP 是如何被使用的。

grep -r "mcp\|MCP" /shumengya/project/agent/sproutclaw/packages --include="*.ts" --include="*.js" | grep -v node_modules | head -30


/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts:				packages: ["npm:pi-mcp-adapter"],
/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts:		// At this point, globalSettings.packages = ["npm:pi-mcp-adapter"]
/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts:		expect(manager.getPackages()).toEqual(["npm:pi-mcp-adapter"]);
/shumengya/project/agent/sproutclaw/packages/coding-agent/dist/core/export-html/vendor/highlight.min.js:built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr"
/shumengya/project/agent/sproutclaw/packages/coding-agent/dist/core/export-html/vendor/highlight.min.js:_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"]
/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/export-html/vendor/highlight.min.js:built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr"
/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/export-html/vendor/highlight.min.js:_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"]
/shumengya/project/agent/sproutclaw/packages/ai/dist/utils/oauth/anthropic.js:const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts:	"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";


让我查看一下 coding-agent 的 package.json 文件，看看是否有 MCP 相关的依赖。


@earendil-works/pi-coding-agent

Coding agent CLI with read, bash, edit, write tools and session management

tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets

npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets

shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/

shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/

npm run clean && npm run build && npm run shrinkwrap


让我查看一下项目的 README.md 文件中关于 MCP 的说明，或者查看其他文档。让我先看看 coding-agent 的 README.md。

bash

grep -i "mcp" /shumengya/project/agent/sproutclaw/packages/coding-agent/README.md


- MCP server integration
**No MCP.** Build CLI tools with READMEs (see [Skills](#skills)), or build an extension that adds MCP support. [Why?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)


让我查看一下项目中关于 MCP 的更详细的文档。让我查看一下 coding-agent 的文档目录。


总计 416
drwxr-xr-x 3 root root  4096  5月22日 20:05 .
drwxr-xr-x 9 root root  4096  5月22日 20:05 ..
-rw-r--r-- 1 root root 17088  5月 8日 19:40 compaction.md
-rw-r--r-- 1 root root 23911  5月22日 20:05 custom-provider.md
-rw-r--r-- 1 root root  1440  5月 8日 19:40 development.md
-rw-r--r-- 1 root root  2854  5月 6日 20:53 docs.json
-rw-r--r-- 1 root root 97059  5月22日 20:05 extensions.md
drwxr-xr-x 2 root root  4096  5月 6日 20:53 images
-rw-r--r-- 1 root root  2974  5月22日 20:05 index.md
-rw-r--r-- 1 root root  3285  5月 8日 19:40 json.md
-rw-r--r-- 1 root root  8919  5月 6日 20:53 keybindings.md
-rw-r--r-- 1 root root 15862  5月 9日 13:48 models.md
-rw-r--r-- 1 root root  8169  5月22日 20:05 packages.md
-rw-r--r-- 1 root root  2616  5月 6日 20:53 prompt-templates.md
-rw-r--r-- 1 root root  9829  5月 9日 13:48 providers.md
-rw-r--r-- 1 root root  4419  5月22日 20:05 quickstart.md
-rw-r--r-- 1 root root 35429  5月 8日 19:40 rpc.md
-rw-r--r-- 1 root root 33456  5月22日 20:05 sdk.md
-rw-r--r-- 1 root root 14334  5月 8日 19:40 session-format.md
-rw-r--r-- 1 root root  5133  5月 6日 20:53 sessions.md
-rw-r--r-- 1 root root  9846  5月22日 20:05 settings.md
-rw-r--r-- 1 root root   356  5月 6日 20:53 shell-aliases.md
-rw-r--r-- 1 root root  6861  5月22日 20:05 skills.md
-rw-r--r-- 1 root root  3503  5月 6日 20:53 terminal-setup.md
-rw-r--r-- 1 root root  3213  5月22日 20:05 termux.md
-rw-r--r-- 1 root root  7936  5月10日 09:48 themes.md
-rw-r--r-- 1 root root  1818  5月 6日 20:53 tmux.md
-rw-r--r-- 1 root root 28984  5月 8日 19:40 tui.md
-rw-r--r-- 1 root root 11003  5月22日 20:05 usage.md
-rw-r--r-- 1 root root   394  5月 6日 20:53 windows.md


让我查看一下 extensions.md 文档，看看是否有关于 MCP 的说明。

grep -i "mcp" /shumengya/project/agent/sproutclaw/packages/coding-agent/docs/extensions.md


(no output)

Command exited with code 1


让我查看一下项目中关于 MCP 的实际使用情况。首先查看一下 mcp-npx-cache.json 文件。


让我查看一下项目中关于 MCP 的实际代码实现。让我搜索一下 MCP 相关的工具或适配器代码。

find /shumengya/project/agent/sproutclaw -name "*.ts" -type f -exec grep -l "MCP\|mcp" {} \; 2>/dev/null | grep -v node_modules | head -20


让我查看一下 webui 后端中的 MCP 设置文件，了解 MCP 在项目中的实际应用。


import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

const DISABLED_SERVERS_KEY = "mcpServersDisabled";

type McpServerEntry = Record<string, unknown>;

export interface McpToolSettingsEntry {
  server: string;
  name: string;
  description: string;
  parameters: string[];
  required: string[];
  enabled: boolean;
}

export interface McpServerSettingsEntry {
  name: string;
  configured: boolean;
  enabled: boolean;
  cached: boolean;
  toolCount: number;
  enabledToolCount: number;
  resourceCount: number;
  cachedAt: string;
  tools: McpToolSettingsEntry[];
}

function readRawConfig(filePath: string): Record<string, unknown> {
  if (!existsSync(filePath)) return { mcpServers: {} };
  try {
    const raw = JSON.parse(readFileSync(filePath, "utf8"));
    return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { mcpServers: {} };
  } catch {
    return { mcpServers: {} };
  }
}

function writeRawConfig(filePath: string, raw: Record<string, unknown>): void {
  mkdirSync(dirname(filePath), { recursive: true });
  const tmpPath = `${filePath}.${process.pid}.tmp`;
  writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
  renameSync(tmpPath, filePath);
}

function getServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
  const existing = raw.mcpServers ?? raw["mcp-servers"];
  if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
    return {};
  }
  return existing as Record<string, McpServerEntry>;
}

function getDisabledServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {
  const existing = raw[DISABLED_SERVERS_KEY];
  if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
    return {};
  }
  return existing as Record<string, McpServerEntry>;
}

function setServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
  delete raw["mcp-servers"];
  raw.mcpServers = servers;
}

function setDisabledServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {
  if (Object.keys(servers).length === 0) {
    delete raw[DISABLED_SERVERS_KEY];
    return;
  }
  raw[DISABLED_SERVERS_KEY] = servers;
}

function normalizeToolName(value: string): string {
  return value.replace(/-/g, "_");
}

function isToolExcluded(toolName: string, serverName: string, excludeTools: unknown): boolean {
  if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;

  const candidates = new Set<string>([
    normalizeToolName(toolName),
    normalizeToolName(`${serverName}_${toolName}`),
    normalizeToolName(`${serverName.replace(/-/g, "_")}_${toolName}`),
  ]);

  for (const excluded of excludeTools) {
    if (typeof excluded !== "string") continue;
    if (candidates.has(normalizeToolName(excluded))) {
      return true;
    }
  }
  return false;
}

function removeToolFromExcludeList(excludeTools: string[], toolName: string, serverName: string): string[] {
  return excludeTools.filter((entry) => !isToolExcluded(toolName, serverName, [entry]));
}

function readJsonFile(filePath: string): any | null {
  if (!existsSync(filePath)) return null;
  try {
    return JSON.parse(readFileSync(filePath, "utf8"));
  } catch {
    return null;
  }
}

function normalizeMcpTool(serverName: string, tool: any, serverEntry: McpServerEntry): McpToolSettingsEntry {
  const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
  const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
  const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
  const name = String(tool?.name || "");
  return {
    server: serverName,
    name,
    description: String(tool?.description || ""),
    parameters: properties,
    required,
    enabled: name ? !isToolExcluded(name, serverName, serverEntry.excludeTools) : false,
  };
}

export function listMcpSettings(configPath: string, cachePath: string): McpServerSettingsEntry[] {
  const raw = readRawConfig(configPath);
  const activeServers = getServersObject(raw);
  const disabledServers = getDisabledServersObject(raw);
  const cache = readJsonFile(cachePath);
  const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};

  const serverNames = Array.from(
    new Set([...Object.keys(activeServers), ...Object.keys(disabledServers), ...Object.keys(cachedServers)]),
  ).sort();

  return serverNames.map((serverName) => {
    const enabled = Object.prototype.hasOwnProperty.call(activeServers, serverName);
    const serverEntry = (enabled ? activeServers[serverName] : disabledServers[serverName]) || {};
    const entry = cachedServers[serverName] || {};
    const tools = Array.isArray(entry.tools)
      ? entry.tools
          .map((tool: any) => normalizeMcpTool(serverName, tool, serverEntry))
          .filter((tool: McpToolSettingsEntry) => tool.name)
          .sort((a: McpToolSettingsEntry, b: McpToolSettingsEntry) => a.name.localeCompare(b.name))
      : [];
    const enabledTools = enabled ? tools.filter((tool) => tool.enabled) : [];
    const resources = Array.isArray(entry.resources) ? entry.resources : [];

    return {
      name: serverName,
      configured: enabled || Object.prototype.hasOwnProperty.call(disabledServers, serverName),
      enabled,
      cached: Boolean(cachedServers[serverName]),
      toolCount: tools.length,
      enabledToolCount: enabledTools.length,
      resourceCount: resources.length,
      cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
      tools,
    };
  });
}

export function setMcpServerEnabled(
  configPath: string,
  cachePath: string,
  serverName: string,
  enabled: boolean,
): McpServerSettingsEntry {
  const raw = readRawConfig(configPath);
  const activeServers = getServersObject(raw);
  const disabledServers = getDisabledServersObject(raw);

  if (enabled) {
    const entry = disabledServers[serverName];
    if (!entry) {
      const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
      if (current?.enabled) return current;
      throw new Error(`未找到已禁用的 MCP Server：${serverName}`);
    }
    activeServers[serverName] = entry;
    delete disabledServers[serverName];
  } else {
    const entry = activeServers[serverName];
    if (!entry) {
      const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
      if (current && !current.enabled) return current;
      throw new Error(`未找到 MCP Server：${serverName}`);
    }
    disabledServers[serverName] = entry;
    delete activeServers[serverName];
  }

  setServersObject(raw, activeServers);
  setDisabledServersObject(raw, disabledServers);
  writeRawConfig(configPath, raw);

  const updated = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);
  if (!updated) {
    throw new Error(`更新 MCP Server 状态失败：${serverName}`);
  }
  return updated;
}

export function setMcpToolEnabled(
  configPath: string,
  cachePath: string,
  serverName: string,
  toolName: string,
  enabled: boolean,
): McpToolSettingsEntry {
  const raw = readRawConfig(configPath);
  const activeServers = getServersObject(raw);
  const serverEntry = activeServers[serverName];
  if (!serverEntry) {
    throw new Error(`MCP Server 未启用：${serverName}`);
  }

  const excludeTools = Array.isArray(serverEntry.excludeTools)
    ? serverEntry.excludeTools.filter((value): value is string => typeof value === "string")
    : [];

  let nextExclude = excludeTools;
  if (enabled) {
    nextExclude = removeToolFromExcludeList(excludeTools, toolName, serverName);
  } else if (!isToolExcluded(toolName, serverName, excludeTools)) {
    nextExclude = [...excludeTools, toolName];
  }

  if (nextExclude.length > 0) {
    serverEntry.excludeTools = nextExclude;
  } else {
    delete serverEntry.excludeTools;
  }

  activeServers[serverName] = serverEntry;
  setServersObject(raw, activeServers);
  writeRawConfig(configPath, raw);

  const server = listMcpSettings(configPath, cachePath).find((item) => item.name === serverName);
  const tool = server?.tools.find((item) => item.name === toolName);
  if (!tool) {
    throw new Error(`未找到 MCP工具：${serverName}/${toolName}`);
  }
  return tool;
}


让我查看一下 webui 中的设置路由，了解 MCP 是如何被管理的。


import type { IncomingMessage, ServerResponse } from "node:http";
import { readWebuiAvatarSettings, writeWebuiAvatarSettings } from "../db/index.ts";
import { setExtensionEnabled } from "../settings/extension-settings.ts";
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
import { listSkillSettings, setSkillEnabled } from "../settings/skills-settings.ts";
import { normalizeAvatarUrl } from "../services/avatars.ts";
import {
	listExtensionsForSettings,
	listLoadedExtensionsByPath,
	mergeExtensionToggleResponse,
} from "../services/extensions-display.ts";
import { readModelsConfig, writeModelsConfig } from "../services/models-config.ts";
import { readSystemPrompt, writeSystemPrompt } from "../services/system-prompt.ts";
import type { WebUiContext } from "../types/context.ts";
import { json, readBody } from "../http/request.ts";

function listMcpTools(ctx: WebUiContext): Record<string, unknown>[] {
	return listMcpSettings(ctx.config.paths.mcpConfigFile, ctx.config.paths.mcpCacheFile);
}

export function handleSettingsRoute(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: WebUiContext,
	pathname: string,
): boolean {
	const { paths } = ctx.config;
	const { sendCmd } = ctx.rpc;

	if (req.method === "GET" && pathname === "/api/settings") {
		void listSkillSettings(paths.repoRoot, paths.agentDir)
			.then((skills) =>
				listExtensionsForSettings(paths, sendCmd).then((extensions) =>
					json(res, {
						...readWebuiAvatarSettings(),
						webuiDbPath: ctx.db.dbPath,
						systemPrompt: readSystemPrompt(paths),
						systemPromptPath: paths.systemPromptFile,
						modelsConfig: readModelsConfig(paths),
						modelsConfigPath: paths.modelsConfigFile,
						extensionsPath: paths.agentExtensionsDir,
						mcpCachePath: paths.mcpCacheFile,
						mcpConfigPath: paths.mcpConfigFile,
						skills,
						extensions,
						mcpTools: listMcpTools(ctx),
					}),
				),
			)
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/skills/toggle") {
		void readBody(req)
			.then(async ({ path: skillPath, enabled }) => {
				if (typeof skillPath !== "string" || !skillPath.trim()) {
					throw new Error("skill path 无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const skill = await setSkillEnabled(paths.repoRoot, paths.agentDir, skillPath.trim(), enabled);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, skill });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/extensions/toggle") {
		void readBody(req)
			.then(async ({ path: extensionPath, enabled }) => {
				if (typeof extensionPath !== "string" || !extensionPath.trim()) {
					throw new Error("extension path 无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const extension = await setExtensionEnabled(
					paths.repoRoot,
					paths.agentDir,
					extensionPath.trim(),
					enabled,
				);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				let loadedByPath = new Map<string, any>();
				try {
					loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
				} catch {
					/* ignore */
				}
				json(res, {
					ok: true,
					extension: mergeExtensionToggleResponse(paths, extension, loadedByPath),
				});
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/mcp/server/toggle") {
		void readBody(req)
			.then(async ({ server, enabled }) => {
				if (typeof server !== "string" || !server.trim()) {
					throw new Error("server 名称无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const entry = setMcpServerEnabled(
					paths.mcpConfigFile,
					paths.mcpCacheFile,
					server.trim(),
					enabled,
				);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, server: entry });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/mcp/tool/toggle") {
		void readBody(req)
			.then(async ({ server, tool, enabled }) => {
				if (typeof server !== "string" || !server.trim()) {
					throw new Error("server 名称无效");
				}
				if (typeof tool !== "string" || !tool.trim()) {
					throw new Error("tool 名称无效");
				}
				if (typeof enabled !== "boolean") {
					throw new Error("enabled 必须是 boolean");
				}
				const entry = setMcpToolEnabled(
					paths.mcpConfigFile,
					paths.mcpCacheFile,
					server.trim(),
					tool.trim(),
					enabled,
				);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, tool: entry });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/reload") {
		void sendCmd({ type: "reload" })
			.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/models-config") {
		void readBody(req)
			.then(async ({ modelsConfig }) => {
				if (typeof modelsConfig !== "string") {
					throw new Error("modelsConfig 必须是字符串");
				}
				writeModelsConfig(paths, modelsConfig);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, modelsConfigPath: paths.modelsConfigFile });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/system-prompt") {
		void readBody(req)
			.then(async ({ systemPrompt }) => {
				if (typeof systemPrompt !== "string") {
					throw new Error("systemPrompt 必须是字符串");
				}
				writeSystemPrompt(paths, systemPrompt);
				const reload = await sendCmd({ type: "reload" });
				if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
				json(res, { ok: true, systemPromptPath: paths.systemPromptFile });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	if (req.method === "POST" && pathname === "/api/settings/avatars") {
		void readBody(req)
			.then(({ userAvatarUrl, agentAvatarUrl }) => {
				const settings = {
					userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
					agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
				};
				writeWebuiAvatarSettings(settings);
				json(res, { ok: true, ...settings });
			})
			.catch((err) => json(res, { error: err.message }, 500));
		return true;
	}

	return false;
}


让我查看一下 coding-agent 中的 interactive-mode.ts 文件，看看 MCP 是如何被集成的。首先搜索一下 MCP 相关的代码。

grep -n "mcp\|MCP" /shumengya/project/agent/sproutclaw/packages/coding-agent/src/modes/interactive/interactive-mode.ts


让我查看一下项目中 MCP 的实际使用情况。让我查看一下 mcp-cache.json 文件，看看缓存了哪些 MCP 服务器信息。


Search the web for current information, news, or any topic. Uses multiple engines (Brave, DuckDuckGo, Google, Bing) simultaneously and returns aggregated results with source URLs. Use this when you need real-time information not in your training data.

The search query string

Optional: Array of search engines to use. If not specified, uses default engines. Available engines: google, brave, duckduckgo, bing

search

Search across multiple search engines (Google, Brave, DuckDuckGo, Bing) and return aggregated results. This tool provides comprehensive search results from multiple sources, with source attribution for each result.

The search query string

Search the web for current information on any topic. Use for news, facts, or data beyond your knowledge cutoff. Returns snippets and source URLs.

Search query

The maximum number of search results to return

The depth of the search. 'basic' for generic results, 'advanced' for more thorough search, 'fast' for optimized low latency with high relevance, 'ultra-fast' for prioritizing latency above all else

The category of the search. This will determine which of our agents will be used for the search

The time range back from the current date to include in the search results

Include a list of query-related images in the response

Include a list of query-related images and their descriptions in the response

Include the cleaned and parsed HTML content of each search result

A list of domains to specifically include in the search results, if the user asks to search on specific sites set this to the domain of the site

List of domains to specifically exclude, if the user asks to exclude a domain set this to the domain of the site

Boost search results from a specific country. Must be a full country name (e.g., 'United States', 'Japan', 'Germany'). ISO country codes (e.g., 'us', 'jp') are not supported. Available only if topic is general. See https://docs.tavily.com/documentation/api-reference/search for the full list of supported countries.

Whether to include the favicon URL for each result

Will return all results after the specified start date. Required to be written in the format YYYY-MM-DD.

Will return all results before the specified end date. Required to be written in the format YYYY-MM-DD

Only return results containing the exact phrase(s) in quotes in your query

Extract content from URLs. Returns raw page content in markdown or text format.

List of URLs to extract content from

Use 'advanced' for LinkedIn, protected sites, or tables/embedded content

Include images from pages

Output format

Include favicon URLs

Query to rerank content chunks by relevance

Crawl a website starting from a URL. Extracts content from pages with configurable depth and breadth.

The root URL to begin the crawl

Max depth of the crawl. Defines how far from the base URL the crawler can explore.

Max number of links to follow per level of the tree (i.e., per page)

Total number of links the crawler will process before stopping

Natural language instructions for the crawler. Instructions specify which types of pages the crawler should return.

Regex patterns to select only URLs with specific path patterns (e.g., /docs/.*, /api/v1.*)

Regex patterns to restrict crawling to specific domains or subdomains (e.g., ^docs\.example\.com$)

Whether to return external links in the final response

Advanced extraction retrieves more data, including tables and embedded content, with higher success but may increase latency

The format of the extracted web page content. markdown returns content in markdown format. text returns plain text and may increase latency.

Map a website's structure. Returns a list of URLs found starting from the base URL.

The root URL to begin the mapping

Max depth of the mapping. Defines how far from the base URL the crawler can explore

Natural language instructions for the crawler

Perform comprehensive research on a given topic or question. Use this tool when you need to gather information from multiple sources, including web pages, documents, and other resources, to answer a question or complete a task. Returns a detailed response based on the research findings. Rate limit: 20 requests per minute.

A comprehensive description of the research task

Defines the degree of depth of the research. 'mini' is good for narrow tasks with few subtopics. 'pro' is good for broad tasks with many subtopics


现在我对项目中的 MCP 有了更清晰的了解。让我查看一下项目中 MCP 工具的实际使用情况。让我搜索一下 MCP 工具的调用代码。

bash

grep -r "mcp\|MCP" /shumengya/project/agent/sproutclaw/packages/coding-agent/src --include="*.ts" | grep -v "node_modules" | head -20


(no output)


让我查看一下项目中是否有专门的 MCP 工具或适配器代码。让我搜索一下 pi-mcp-adapter 相关的代码。

find /shumengya/project/agent/sproutclaw -name "*mcp*" -type f | grep -v node_modules | head -20


你好


用户只是简单地打招呼，我应该友好地回应，并询问用户需要什么帮助。

你好！我是萌小芽，树萌芽的智能 AI 助手 🌱

有什么我可以帮你的吗？无论是写代码、查文档、分析问题，还是其他事情，我都很乐意效劳！


MCP是什么，项目中的MCP工作流程是怎样的


用户想了解 MCP 是什么，以及项目中 MCP 的完整工作流程。我需要先查看项目中关于 MCP 的文档和代码实现。

首先，我应该搜索项目中与 MCP 相关的文件和代码，了解 MCP 的具体实现。让我先查看项目根目录下是否有 MCP 相关的文档，然后查看代码中的 MCP 实现。


Search the web for current information, news, or any topic. Uses multiple engines (Brave, DuckDuckGo, Google, Bing) simultaneously and returns aggregated results with source URLs. Use this when you need real-time information not in your training data.

The search query string

Optional: Array of search engines to use. If not specified, uses default engines. Available engines: google, brave, duckduckgo, bing

search

Search across multiple search engines (Google, Brave, DuckDuckGo, Bing) and return aggregated results. This tool provides comprehensive search results from multiple sources, with source attribution for each result.

The search query string

Search the web for current information on any topic. Use for news, facts, or data beyond your knowledge cutoff. Returns snippets and source URLs.

Search query

The maximum number of search results to return

The depth of the search. 'basic' for generic results, 'advanced' for more thorough search, 'fast' for optimized low latency with high relevance, 'ultra-fast' for prioritizing latency above all else

The category of the search. This will determine which of our agents will be used for the search

The time range back from the current date to include in the search results

Include a list of query-related images in the response

Include a list of query-related images and their descriptions in the response

Include the cleaned and parsed HTML content of each search result

A list of domains to specifically include in the search results, if the user asks to search on specific sites set this to the domain of the site

List of domains to specifically exclude, if the user asks to exclude a domain set this to the domain of the site

Boost search results from a specific country. Must be a full country name (e.g., 'United States', 'Japan', 'Germany'). ISO country codes (e.g., 'us', 'jp') are not supported. Available only if topic is general. See https://docs.tavily.com/documentation/api-reference/search for the full list of supported countries.

Whether to include the favicon URL for each result

Will return all results after the specified start date. Required to be written in the format YYYY-MM-DD.

Will return all results before the specified end date. Required to be written in the format YYYY-MM-DD

Only return results containing the exact phrase(s) in quotes in your query

Extract content from URLs. Returns raw page content in markdown or text format.

List of URLs to extract content from

Use 'advanced' for LinkedIn, protected sites, or tables/embedded content

Include images from pages

Output format

Include favicon URLs

Query to rerank content chunks by relevance

Crawl a website starting from a URL. Extracts content from pages with configurable depth and breadth.

The root URL to begin the crawl

Max depth of the crawl. Defines how far from the base URL the crawler can explore.

Max number of links to follow per level of the tree (i.e., per page)

Total number of links the crawler will process before stopping

Natural language instructions for the crawler. Instructions specify which types of pages the crawler should return.

Regex patterns to select only URLs with specific path patterns (e.g., /docs/.*, /api/v1.*)

Regex patterns to restrict crawling to specific domains or subdomains (e.g., ^docs\.example\.com$)

Whether to return external links in the final response

Advanced extraction retrieves more data, including tables and embedded content, with higher success but may increase latency

The format of the extracted web page content. markdown returns content in markdown format. text returns plain text and may increase latency.

Map a website's structure. Returns a list of URLs found starting from the base URL.

The root URL to begin the mapping

Max depth of the mapping. Defines how far from the base URL the crawler can explore

Natural language instructions for the crawler

Perform comprehensive research on a given topic or question. Use this tool when you need to gather information from multiple sources, including web pages, documents, and other resources, to answer a question or complete a task. Returns a detailed response based on the research findings. Rate limit: 20 requests per minute.

A comprehensive description of the research task

Defines the degree of depth of the research. 'mini' is good for narrow tasks with few subtopics. 'pro' is good for broad tasks with many subtopics


我已经看到了MCP相关的配置文件和缓存文件。现在让我查看一下项目中MCP相关的代码，特别是webui后端的MCP设置和前端API调用。


我已经查看了项目中MCP相关的配置文件和代码。现在让我查看一下MCP的架构设计和核心实现。让我搜索一下pi-mcp-adapter相关的代码，看看MCP是如何被集成到agent中的。

bash

find /shumengya/project/agent/sproutclaw -path "*/node_modules" -prune -o -name "*.ts" -type f -exec grep -l "pi-mcp-adapter\|mcpServer\|mcp-client\|mcp_server\|mcpClient" {} \; 2>/dev/null | head -20

find /shumengya/project/agent/sproutclaw -path "*/node_modules" -prune -o -name "*.ts" -type f -exec grep -l "MCP\|mcp" {} \; 2>/dev/null | grep -v node_modules | head -20


我已经看到了项目中MCP相关的代码和配置文件。让我查看一下coding-agent中MCP相关的测试文件，了解MCP的集成方式。


import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SettingsManager } from "../src/core/settings-manager.ts";

/**
 * Tests for the fix to a bug where external file changes to arrays were overwritten.
 *
 * The bug scenario was:
 * 1. Pi starts with settings.json containing packages: ["npm:some-pkg"]
 * 2. User externally edits file to packages: []
 * 3. User changes an unrelated setting (e.g., theme) via UI
 * 4. save() would overwrite packages back to ["npm:some-pkg"] from stale in-memory state
 *
 * The fix tracks which fields were explicitly modified during the session, and only
 * those fields override file values during save().
 */
describe("SettingsManager - External Edit Preservation", () => {
	const testDir = join(process.cwd(), "test-settings-bug-tmp");
	const agentDir = join(testDir, "agent");
	const projectDir = join(testDir, "project");

	beforeEach(() => {
		if (existsSync(testDir)) {
			rmSync(testDir, { recursive: true });
		}
		mkdirSync(agentDir, { recursive: true });
		mkdirSync(join(projectDir, ".pi"), { recursive: true });
	});

	afterEach(() => {
		if (existsSync(testDir)) {
			rmSync(testDir, { recursive: true });
		}
	});

	it("should preserve file changes to packages array when changing unrelated setting", async () => {
		const settingsPath = join(agentDir, "settings.json");

		// Initial state: packages has one item
		writeFileSync(
			settingsPath,
			JSON.stringify({
				theme: "dark",
				packages: ["npm:pi-mcp-adapter"],
			}),
		);

		// Pi starts up, loads settings into memory
		const manager = SettingsManager.create(projectDir, agentDir);

		// At this point, globalSettings.packages = ["npm:pi-mcp-adapter"]
		expect(manager.getPackages()).toEqual(["npm:pi-mcp-adapter"]);

		// User externally edits settings.json to remove the package
		const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
		currentSettings.packages = []; // User wants to remove this!
		writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));

		// Verify file was changed
		expect(JSON.parse(readFileSync(settingsPath, "utf-8")).packages).toEqual([]);

		// User changes an UNRELATED setting via UI (this triggers save)
		manager.setTheme("light");
		await manager.flush();

		// With the fix, packages should be preserved as [] (not reverted to startup value)
		const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));

		expect(savedSettings.packages).toEqual([]);
		expect(savedSettings.theme).toBe("light");
	});

	it("should preserve file changes to extensions array when changing unrelated setting", async () => {
		const settingsPath = join(agentDir, "settings.json");

		writeFileSync(
			settingsPath,
			JSON.stringify({
				theme: "dark",
				extensions: ["/old/extension.ts"],
			}),
		);

		const manager = SettingsManager.create(projectDir, agentDir);

		// User externally updates extensions
		const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
		currentSettings.extensions = ["/new/extension.ts"];
		writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));

		// Change unrelated setting
		manager.setDefaultThinkingLevel("high");
		await manager.flush();

		const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));

		// With the fix, extensions should be preserved (not reverted to startup value)
		expect(savedSettings.extensions).toEqual(["/new/extension.ts"]);
	});

	it("should preserve external project settings changes when updating unrelated project field", async () => {
		const projectSettingsPath = join(projectDir, ".pi", "settings.json");
		writeFileSync(
			projectSettingsPath,
			JSON.stringify({
				extensions: ["./old-extension.ts"],
				prompts: ["./old-prompt.md"],
			}),
		);

		const manager = SettingsManager.create(projectDir, agentDir);

		const currentProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
		currentProjectSettings.prompts = ["./new-prompt.md"];
		writeFileSync(projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2));

		manager.setProjectExtensionPaths(["./updated-extension.ts"]);
		await manager.flush();

		const savedProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
		expect(savedProjectSettings.prompts).toEqual(["./new-prompt.md"]);
		expect(savedProjectSettings.extensions).toEqual(["./updated-extension.ts"]);
	});

	it("should let in-memory project changes override external changes for the same project field", async () => {
		const projectSettingsPath = join(projectDir, ".pi", "settings.json");
		writeFileSync(
			projectSettingsPath,
			JSON.stringify({
				extensions: ["./initial-extension.ts"],
			}),
		);

		const manager = SettingsManager.create(projectDir, agentDir);

		const currentProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
		currentProjectSettings.extensions = ["./external-extension.ts"];
		writeFileSync(projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2));

		manager.setProjectExtensionPaths(["./in-memory-extension.ts"]);
		await manager.flush();

		const savedProjectSettings = JSON.parse(readFileSync(projectSettingsPath, "utf-8"));
		expect(savedProjectSettings.extensions).toEqual(["./in-memory-extension.ts"]);
	});
});