Skill: jast-hub/agent-office (version 1.5.1)

--- name: agent-office description: "Agent Office：创建本地 AI 员工、office worker、AI employee 与 multi-agent office team。每个员工以独立 HTTP Worker 运行，支持 openclaw / hermes / deerflow / cli / external / stub 六种引擎，适合 office automation、agent worker 管理与团队协作。" homepage: https://clawhub.com tags: [productivity, automation, multi-agent, office, worker, ai-employee] version: "1.5.1" requirements: cli: - openclaw - hermes system: - lsof - git - uv optional: - memory-cli ports: range: "5011-5020" purpose: "one per worker HTTP server" directories: read: - "~/.openclaw/agents/" - "~/.hermes/office/" write: - "~/.hermes/office/workers/" - "~/.hermes/office/state/" - "~/.hermes/office/deerflow-runtime/" daemon: background: true autostart: false --- # Agent Office ## 技能定位 这个技能用于把“员工”作为本地 HTTP Worker 管理： - 每个员工有自己的 `worker_id` - 每个员工占用一个端口 - 每个员工有自己的 `SOUL.md` 和日志目录 - 主控通过 `/tasks` 下发任务，通过 `/health` 和 `/state` 查看状态 搜索关键词： - `agent-office` - `agent office` - `office worker` - `AI employee` - `agent worker` - `multi-agent office` 发布名和本地目录名统一为 `agent-office`。文档中一律优先使用相对路径 `scripts/...`，避免路径漂移。 ## 内置团队预设 除了单个员工创建外，技能还支持一键套用团队预设： | 预设 | 说明 | |------|------| | `出版公司` | 主编、策划、校对、封设、发行，适合内容与出版流程 | | `编程公司` | 产品、前端、后端、设计、测试、项目，适合软件交付流程 | 命令示例： ```bash bash scripts/apply_preset.sh --list bash scripts/apply_preset.sh 出版公司 bash scripts/apply_preset.sh 编程公司 --dry-run ``` ## 支持的引擎 | 引擎 | 适用场景 | 实际调用 | |------|----------|----------| | `openclaw` | 通用员工、代码、调研、设计 | `openclaw agent --agent {worker_id}` | | `hermes` | Hermes 生态员工 | `hermes agent --json --message` | | `deerflow` | 外包型复杂任务 | 复用共享 DeerFlow runtime，并以内嵌 DeerFlow 团队执行链完成任务 | | `cli` | 接大多数本地 CLI 员工 | 按 profile 调用 `codex` / `claude` / `aider` / `gemini` / `opencode` | | `external` | 接入已在运行的现有 agent / worker | 把现成 HTTP worker 桥接成员工，保留上游设定与记忆 | | `stub` | 测试、占位 | 不启动真实进程 | ### CLI 员工说明 `cli` 引擎用于把主流本地命令行代理接成一个办公室员工，而不是把它们硬编码成单独实现。 内置 profile： | profile | 实际工具 | 传参方式 | |---------|----------|----------| | `codex` | OpenAI Codex CLI | 标准输入，默认走 `codex exec --skip-git-repo-check` | | `claude-code` | Claude Code | 标准输入 | | `aider` | Aider | `--message` | | `gemini-cli` | Gemini CLI | 标准输入 | | `opencode` | OpenCode | 标准输入 | 支持两种接法： - 用内置 profile：适合常见 CLI，直接复用预设命令与超时 - 用 `--cli-cmd` 覆盖：适合本机有自定义包装命令的场景 常用命令： ```bash python3 scripts/add_worker.py --list-cli-profiles bash scripts/add_worker.sh 小扣 cli code --cli-profile codex bash scripts/add_worker.sh 小克 cli code --cli-profile claude-code --workspace ~/projects/demo bash scripts/add_worker.sh 小助 cli general --cli-cmd "codex exec --skip-git-repo-check" --cli-args "--model gpt-5.4" ``` Codex 员工要点： - 不直接跑裸 `codex`，而是默认使用非交互模式 `codex exec` - 默认补 `--skip-git-repo-check`，避免员工工作目录不是 trusted git repo 时直接失败 - 需要指定模型时，只追加自己的参数，例如 `--cli-args "--model gpt-5.4"` ### External 员工说明 `external` 引擎用于接入已经在本机某个端口跑着的现有 worker / agent，而不是重新创建一个新员工内核。 它的运行规则是： - 上游 worker 保持原本的身份、workspace、长期记忆与系统提示 - Agent Office 只在办公室侧创建一个桥接员工 - 派单时会附加只读共享记忆上下文，再转发给上游 - 移除桥接员工时，不会删除上游 worker 常用命令： ```bash bash scripts/add_worker.sh 外挂小龙 external general --external-upstream-port 18750 bash scripts/add_worker.sh 外挂小龙 external general --external-upstream-url http://127.0.0.1:18750 ``` ### DeerFlow 员工说明 `deerflow` 引擎用于把一个完整 DeerFlow 2.0 团队封装成办公室里的单个“外包型员工”。 它的运行规则是： - 办公室共享一套 DeerFlow runtime，避免每个 DeerFlow 员工重复安装 - 每个 DeerFlow 员工单独持有自己的 `home`、`config`、线程与工作目录 - 新增 DeerFlow 员工时，技能会自动安装或复用共享 runtime - 需要跟官方 DeerFlow 更新时，可直接执行 `bash scripts/update_deerflow_runtime.sh` 常用命令： ```bash bash scripts/add_worker.sh 小D deerflow complex bash scripts/add_worker.sh 小D deerflow complex --deerflow-update-runtime bash scripts/update_deerflow_runtime.sh ``` ## 当前约束 - 没有默认员工，所有员工必须显式添加。 - `deerflow` 员工在办公室中仍然是“一个员工”，但底层已切换为技能内嵌 DeerFlow runtime 团队。 - 共享记忆通过 `MEMORY_CLI` 以可选方式启用，未配置时不影响主流程。 ## 实际目录结构 ```text ~/.hermes/office/ ├── deerflow-runtime/ │ ├── deer-flow/ │ └── homes/ │ └── {worker_id}/ ├── state/ │ └── office_state.json └── workers/ └── {worker_id}/ ├── SOUL.md ├── config.json └── logs/ └── worker.log 技能预设目录： ~/.hermes/skills/agent-office/presets/ ├── publishing-company.json └── coding-company.json ``` 说明： - `SOUL.md`：员工身份说明，由 `templates/` 渲染生成 - `config.json`：员工配置快照 - `worker.log`：启动与运行日志 ## office_state.json 结构 ```json { "workers": { "xiaolong": { "name": "小龙", "port": 5011, "role": "research", "engine": "openclaw", "workspace_dir": "~/.hermes/office/workers/xiaolong", "status": "idle", "config_path": "~/.hermes/office/workers/xiaolong/config.json", "soul_path": "~/.hermes/office/workers/xiaolong/SOUL.md", "added_at": "2026-04-13T00:00:00", "last_active": null } }, "port_pool": { "used": [5011], "available": [5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5020] } } ``` ## 添加员工流程 当用户说“添加一个员工，叫 XXX，职责是 YYY”时，按以下顺序执行： ### Step 1：生成 worker_id 与端口 - 中文名字转拼音 / ASCII 工号 - 从 `port_pool.available` 里取最小可用端口 ### Step 2：创建目录 ```bash mkdir -p ~/.hermes/office/workers/{worker_id}/logs ``` ### Step 3：生成 SOUL.md 从 `templates/` 里按引擎选模板并渲染： | 引擎 | 模板文件 | |------|----------| | `openclaw` | `templates/openclaw_prompt.md` | | `hermes` | `templates/hermes_prompt.md` | | `deerflow` | `templates/deerflow_prompt.md` | | `cli` | `templates/cli_prompt.md` | | `external` | `templates/external_prompt.md` | | `stub` | `templates/stub_prompt.md` | 模板变量包括： - `{{NAME}}` - `{{ROLE}}` - `{{WORKER_ID}}` - `{{PORT}}` - `{{ROLE_DESCRIPTION}}` - `{{TIMEOUT}}` ### Step 4：注册 agent / 准备运行时 - `openclaw` / `hermes` 引擎会注册本地 agent - `cli` 引擎写入 CLI profile / 命令配置 - `deerflow` 引擎会自动准备共享 DeerFlow runtime、员工独立 home 与 runtime config ```bash openclaw agents add {worker_id} --workspace ~/.hermes/office/workers/{worker_id} ``` ### Step 5：启动 worker_server.py ```bash python3 worker_server.py \ --port {port} \ --worker-id {worker_id} \ --name {name} \ --role {role} \ --engine {engine} \ --workspace-dir {workspace_dir} ``` ### Step 6：严格健康检查 必须满足： - `/health` 返回 HTTP `200` - 响应体里 `status == "ok"` 只要有一项不满足，员工状态就记为 `not_ready`，不会误记成 `idle`。 ## worker_server 行为 ### API | 方法 | 路径 | 用途 | |------|------|------| | `GET` | `/health` | 存活检查 | | `GET` | `/state` | 当前员工状态 | | `POST` | `/tasks` | 创建任务 | | `GET` | `/tasks/{task_id}` | 查任务状态或结果 | | `GET` | `/tasks/{task_id}/result` | 只取结果 | ### 关键实现约束 - `openclaw` 员工执行任务时必须使用 `worker_id`，不能直接用中文显示名 - 任务标题不能通过全局共享变量透传，避免并发串单 - `hermes` 子进程在员工工作目录执行，方便读本地上下文与日志 - `deerflow` 任务通过技能内的 `deerflow_runtime_runner.py` 调共享 runtime，并读取员工独立 home/config ## 命令参考 以下命令都建议在技能目录中执行： ### 添加员工 ```bash bash scripts/add_worker.sh 小龙 openclaw research bash scripts/add_worker.sh 小扣 cli code --cli-profile codex ``` ### 应用团队预设 ```bash bash scripts/apply_preset.sh 出版公司 bash scripts/apply_preset.sh 编程公司 --dry-run ``` ### 查看团队 ```bash bash scripts/list_workers.sh ``` ### 健康检查 ```bash bash scripts/demo.sh ``` ### 移除员工 ```bash bash scripts/remove_worker.sh 小龙 bash scripts/remove_worker.sh xiaolong ``` ## 环境变量 | 变量 | 默认值 | 说明 | |------|--------|------| | `HERMES_OFFICE_DIR` | `~/.hermes/office` | 办公室目录 | | `MEMORY_CLI` | 空 | MemPalace CLI 路径 | | `AGENT_OFFICE_DEERFLOW_REPO_URL` | `https://github.com/bytedance/deer-flow.git` | DeerFlow runtime 来源仓库 | | [REDACTED_SECRET] | `0` | 设为 `1` 时，每次新增 DeerFlow 员工前都先更新共享 runtime | ## 发布前自检 至少验证下面几项： 1. `python3 -m py_compile worker_server.py scripts/add_worker.py scripts/remove_worker.py scripts/apply_preset.py scripts/run_flow.py` 2. `bash -n scripts/add_worker.sh scripts/list_workers.sh scripts/demo.sh scripts/remove_worker.sh scripts/apply_preset.sh scripts/run_flow.sh` 3. 添加一个 `stub` 员工，确认状态写入正常 4. 添加一个 `openclaw` 员工，确认 `/health` 正常 5. 添加一个 `cli` 员工，确认 `--list-cli-profiles` 与 worker 启动正常 6. 添加一个 `deerflow` 员工，确认共享 runtime 初始化、任务回传与离职清理正常 7. 提交一个任务，确认 `/tasks/{id}` 能返回 `done` ## 版本说明 ### 1.4.2 - 修复 DeerFlow 沙箱默认挂载范围过宽的问题，不再把整个办公室目录暴露给 DeerFlow 子任务 - 当前 DeerFlow 员工默认只暴露 `/mnt/workspace` 与 `/mnt/worker`，避免误读其他员工目录 - 补充隔离挂载与模板边界约束测试，验证小D不会再串读小龙资料 ### 1.4.1 - 移除 `1.4.0` 中误写到 DeerFlow 模板与文档里的旧网关 / 旧目录调试说明 - 将 `templates/deerflow_prompt.md` 恢复为与当前内嵌 runtime 架构一致的员工说明 - 校正文档版本说明，避免后续员工和经理被旧 DeerFlow 资产误导 ### 1.3.0 - `deerflow` 引擎改为技能内嵌 DeerFlow runtime，不再依赖外部 `deerflow-worker` 网关 - 新增共享 runtime + 每员工独立 home/config 设计 - 新增 `scripts/update_deerflow_runtime.py` / `.sh`，可直接跟随官方 DeerFlow 更新 - 新增 DeerFlow runtime 相关测试与真实隔离烟测 ### 1.2.0 - 新增 `cli` 引擎，可把主流本地 CLI 直接接成员工 - 内置 `codex`、`claude-code`、`aider`、`gemini-cli`、`opencode` 五个 profile - 支持 `--cli-profile`、`--cli-cmd`、`--cli-args`、`--cli-timeout`、`--workspace` - 新增 `templates/cli_prompt.md` 与对应测试 ### 1.1.0 - 新增 `apply_preset.py` / `apply_preset.sh` - 内置 `出版公司` 与 `编程公司` 两组团队预设 - 预设支持 `--list` 和 `--dry-run` ### 1.0.1 - 增强技能描述与标签，优化 `agent office` / `office worker` / `AI employee` 等搜索命中 ### 1.0.0 - 修复 `worker_name` 被误当作 `worker_id` 的执行错误 - 启动后改为严格健康检查，不再用宽松返回码误判就绪 - `SOUL.md` 改为真实使用 `templates/` 渲染 - shell 入口收敛到 Python 主实现，减少逻辑漂移 - 文档与真实目录结构、命令路径、默认员工说明重新对齐

path: README.md
content: # Agent Office

一句话加员工，构建本地 AI office、office worker、AI employee 团队。员工以独立 HTTP Worker 形式运行，支持 `openclaw`、`hermes`、`deerflow`、`cli`、`external`、`stub` 六种引擎。

## 包名与目录名

- ClawHub / 技能市场发布名：`agent-office`
- 本地目录名：`agent-office`

## 前置要求

- `openclaw`：用于 `openclaw` 引擎
- `hermes`：用于 `hermes` 引擎
- `git`：用于首次拉取或更新共享 DeerFlow runtime
- `uv`：用于安装 / 更新共享 DeerFlow runtime 依赖
- `lsof`：用于移除员工时查进程
- 如需 `cli` 员工：本机需已安装对应 CLI

示例安装：

```bash
pip install openclaw
pip install hermes-ai
```

说明：

- `deerflow` 员工不需要你先手工维护一套外部 DeerFlow 网关
- 首次添加 `deerflow` 员工时，技能会自动安装或复用办公室共享 DeerFlow runtime
- 后续如需跟进官方更新，可直接运行 `bash scripts/update_deerflow_runtime.sh`

## 快速开始

先进入技能目录：

```bash
cd /path/to/agent-office
```

添加员工：

```bash
bash scripts/add_worker.sh 小龙 openclaw research
bash scripts/add_worker.sh 小扣 cli code --cli-profile codex
bash scripts/add_worker.sh 小D deerflow complex
bash scripts/add_worker.sh 外挂小龙 external general --external-upstream-port 18750
```

应用团队预设：

```bash
# 查看可用预设
bash scripts/apply_preset.sh --list

# 一键创建出版公司
bash scripts/apply_preset.sh 出版公司

# 只预览，不真正创建
bash scripts/apply_preset.sh 编程公司 --dry-run
```

查看团队：

```bash
bash scripts/list_workers.sh
```

健康检查：

```bash
bash scripts/demo.sh
```

移除员工：

```bash
bash scripts/remove_worker.sh 小龙
# 或
bash scripts/remove_worker.sh xiaolong
```

## 引擎说明

| 引擎 | 用途 | 实际行为 |
|------|------|----------|
| `openclaw` | 通用执行、代码、调研、设计 | 以 `worker_id` 调用 `openclaw agent --agent {worker_id}` |
| `hermes` | Hermes 生态任务 | 通过 `hermes agent` 执行，并在员工工作目录运行 |
| `deerflow` | 外包型复杂任务 | 自动安装或复用共享 DeerFlow runtime，并以单个员工身份调起内嵌 DeerFlow 团队执行链 |
| `cli` | 接主流本地 CLI 员工 | 按 profile 调用对应 CLI，可接 Codex / Claude Code / Aider / Gemini CLI / OpenCode |
| `external` | 接入已在运行的现有 agent / worker | 保留上游原设定和记忆不动，在办公室里挂一个桥接员工并注入共享记忆上下文 |
| `stub` | 测试 / 占位 | 不启动真实进程，直接返回模拟结果 |

## CLI 员工

`cli` 引擎适合把大多数主流命令行代理接成办公室员工，不需要为每个 CLI 单独做一套 worker 实现。

当前内置 profile：

| profile | 工具 | 说明 |
|------|------|------|
| `codex` | OpenAI Codex CLI | 默认走 `codex exec --skip-git-repo-check`，适合后台 worker 非交互调用 |
| `claude-code` | Claude Code | 走标准输入 |
| `aider` | Aider | 使用 `--message` 传任务 |
| `gemini-cli` | Gemini CLI | 走标准输入 |
| `opencode` | OpenCode | 走标准输入 |

先看支持列表：

```bash
python3 scripts/add_worker.py --list-cli-profiles
```

直接添加内置 CLI 员工：

```bash
bash scripts/add_worker.sh 小扣 cli code --cli-profile codex
bash scripts/add_worker.sh 小克 cli code --cli-profile claude-code --workspace ~/projects/demo
bash scripts/add_worker.sh 小助 cli general --cli-profile aider
```

Codex 员工的默认接法已经内置为：

```text
codex exec --skip-git-repo-check
```

原因：

- worker_server 是后台 HTTP worker，不能依赖交互式 TTY，所以不能直接跑裸 `codex`
- 员工工作目录通常不是 trusted git repo，默认补 `--skip-git-repo-check` 更稳
- 如需指定模型，只需要追加你自己的参数，例如 `--cli-args "--model gpt-5.4"`

如果你的机器上用了自定义包装命令，也可以覆盖：

```bash
bash scripts/add_worker.sh 小试 cli code --cli-cmd "codex exec --skip-git-repo-check" --cli-args "--model gpt-5.4" --workspace ~/projects/demo
```

说明：

- `--workspace` 控制 CLI 真正工作的目录，不填时默认用员工目录
- `--cli-timeout` 可覆盖默认超时
- `--cli-cmd` 适合你本机已经封装好命令的情况
- `cli` 员工不会注册成 openclaw agent，而是由 `worker_server.py` 直接调本地 CLI

## 外挂现有员工

`external` 引擎适合把已经在本机跑起来的现有 agent / worker 直接挂进 Agent Office，当成一名办公室员工来派单。

典型场景：

- 你本机 `18750` 端口已经跑着一个 openclaw / 其他兼容 worker
- 你不想改它原有的 workspace、系统提示、长期记忆
- 但你想让办公室给它派单，并在派单时附带共享记忆上下文

使用方式：

```bash
bash scripts/add_worker.sh 外挂小龙 external general --external-upstream-port 18750
# 或
bash scripts/add_worker.sh 外挂小龙 external general --external-upstream-url http://127.0.0.1:18750
```

说明：

- `external` 员工是办公室里的桥接代理，不会重写上游 agent 的设置
- 共享记忆只在办公室转发任务时作为附加上下文注入
- 移除 `external` 员工时，只会删除办公室这层代理，不会动上游进程
- 上游需要兼容 `GET /health`、`POST /tasks`、`GET /tasks/{id}` 这套 HTTP 协议

## DeerFlow 员工

`deerflow` 引擎适合“外包团队型”员工。对办公室来说它仍然只是一个员工，但员工内部会以 DeerFlow 2.0 团队方式接任务、拆任务、汇总结果。

核心设计：

- 办公室只维护一套共享 DeerFlow runtime，默认放在 `~/.hermes/office/deerflow-runtime/deer-flow`
- 每个 DeerFlow 员工有自己独立的 `home`、`config`、线程与工作目录，互不串线
- 默认模型是 `gpt-5.4`，默认 `reasoning_effort=medium`
- 员工创建时可选先更新官方 runtime，后续也可单独执行更新脚本

常用命令：

```bash
bash scripts/add_worker.sh 小D deerflow complex
bash scripts/add_worker.sh 小D deerflow complex --deerflow-update-runtime
bash scripts/update_deerflow_runtime.sh
```

如果你想固定到自己的 DeerFlow fork，也可以在创建前指定：

```bash
export AGENT_OFFICE_DEERFLOW_REPO_URL=https://github.com/your-org/deer-flow.git
```

如果你希望每次新增 DeerFlow 员工前都自动拉一次最新官方代码：

```bash
export AGENT_OFFICE_DEERFLOW_UPDATE_ON_ADD=1
```

## 团队预设

当前内置两组预设：

| 预设 | 适合场景 | 典型员工 |
|------|----------|----------|
| `出版公司` | 选题、编辑、校对、封设、发行 | 主编、策划、校对、封设、发行 |
| `编程公司` | 需求、设计、前后端开发、测试、项目推进 | 产品、前端、后端、设计、测试、项目 |

预设文件位于 `presets/` 目录，后续你可以继续扩展新的公司模板。

## 真实运行流程

执行 `add_worker.py` 时会做这些事：

1. 中文名字转 `worker_id`，并分配端口。
2. 在 `templates/` 中选模板，渲染成员工专属 `SOUL.md`。
3. 为 `openclaw` / `hermes` 员工注册对应 agent workspace；`cli` 员工写入 CLI 配置；`deerflow` 员工自动准备共享 runtime、独立 home 与 runtime config；`external` 员工写入上游桥接配置。
4. 启动 `worker_server.py`。
5. 严格检查 `http://127.0.0.1:{port}/health` 是否返回 `200` 且 `{"status":"ok"}`。
6. 写入 `office_state.json`。

## 目录结构

默认办公室目录：

```text
~/.hermes/office/
├── deerflow-runtime/
│   ├── deer-flow/
│   └── homes/
│       └── {worker_id}/
├── state/
│   └── office_state.json
└── workers/
    └── {worker_id}/
        ├── SOUL.md
        ├── config.json
        └── logs/
            └── worker.log
```

说明：

- `SOUL.md`：员工身份与任务规范，由模板渲染生成
- `config.json`：员工配置快照
- `worker.log`：启动和运行日志
- `deerflow-runtime/`：办公室共享 DeerFlow runtime，以及各 DeerFlow 员工独立 home

## 环境变量

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `HERMES_OFFICE_DIR` | `~/.hermes/office` | 办公室状态与员工目录 |
| `MEMORY_CLI` | 空 | 启用共享记忆 CLI 路径 |
| `AGENT_OFFICE_DEERFLOW_REPO_URL` | `https://github.com/bytedance/deer-flow.git` | DeerFlow runtime 来源仓库 |
| [REDACTED_SECRET] | `0` | 设为 `1` 时，每次新增 DeerFlow 员工前都先更新共享 runtime |

## 故障排除

| 问题 | 常见原因 | 处理方式 |
|------|----------|----------|
| 员工状态是 `not_ready` | 进程启动了，但健康检查没通过 | 查看 `workers/{worker_id}/logs/worker.log` |
| 添加时报 `openclaw 未安装` | CLI 未安装或不在 PATH | 先执行 `pip install openclaw` |
| 添加时报 `hermes 未安装` | CLI 未安装或不在 PATH | 先执行 `pip install hermes-ai` |
| 添加 `deerflow` 员工时报缺少自动安装依赖 | 本机没有 `git` 或 `uv` | 先安装 `git` / `uv`，再重试 |
| DeerFlow 员工能启动但任务失败 | DeerFlow runtime 配置或上游模型配置有误 | 查看 `workers/{worker_id}/logs/worker.log`，必要时执行 `bash scripts/update_deerflow_runtime.sh` 后重试 |
| 添加 `cli` 员工时报 profile 不可用 | 对应 CLI 不在 PATH，或命令覆盖写错 | 先执行 `python3 scripts/add_worker.py --list-cli-profiles`，再检查 `which codex` / `which claude` 等 |
| 添加 `external` 员工时报无法连通上游 | 上游端口未启动，或不兼容 HTTP worker 协议 | 先检查上游 `GET /health` 是否返回 `{"status":"ok"}` |
| 添加时报 `没有可用端口` | 5011-5020 端口池耗尽 | 先移除不用的员工 |
| 员工能创建但任务失败 | `worker_id` / agent 未注册完整 | 重新添加该员工，让脚本重新注册 |

## 当前约束

- 没有默认员工，团队成员由用户显式添加。
- `deerflow` 员工本质上仍是办公室里的单个外包入口，但底层已改为技能内嵌的 DeerFlow runtime 团队执行链。
- 共享记忆是可选功能，未配置 `MEMORY_CLI` 时不会影响主流程。

## 版本历史

| 版本 | 日期 | 更新 |
|------|------|------|
| 1.5.0 | 2026-04-16 | 新增 `external` 引擎，可把已在运行的现有 agent / worker 作为桥接员工接入办公室；保留上游设定与记忆不动，并在办公室侧注入共享记忆 |
| 1.4.2 | 2026-04-14 | 修复 DeerFlow 沙箱挂载范围过宽导致可能串读其他员工目录的问题；当前只暴露 `/mnt/workspace` 与 `/mnt/worker`，并补充隔离验证 |
| 1.4.1 | 2026-04-13 | 修正 `1.4.0` 中误写到 DeerFlow 模板与文档里的旧调试说明；恢复与当前内嵌 runtime 架构一致的 DeerFlow 员工说明 |
| 1.3.0 | 2026-04-13 | `deerflow` 引擎改为技能内嵌 DeerFlow runtime；支持共享 runtime、每员工独立 home/config、`update_deerflow_runtime` 更新脚本；不再依赖外部 `deerflow-worker` 网关 |
| 1.2.0 | 2026-04-13 | 新增 `cli` 引擎，支持主流 CLI 员工接入；内置 `codex`、`claude-code`、`aider`、`gemini-cli`、`opencode` profiles；支持自定义 `--cli-cmd` / `--workspace` |
| 1.1.0 | 2026-04-13 | 新增团队预设：支持 `出版公司` 与 `编程公司` 一键建组；补充 `apply_preset` 脚本与测试 |
| 1.0.1 | 2026-04-13 | 优化搜索可见性：补充 `agent office`、`office worker`、`AI employee` 等关键词与标签 |
| 1.0.0 | 2026-04-13 | 首个稳定可发布版本；修复 `worker_id` 调用错误；严格化健康检查；接通模板渲染；统一 shell/python 入口；同步 README 与实现 |


path: _meta.json
content: ownerId: kn74ej29cecm60khxvavjbenzn84s35g
slug: agent-office
version: 1.5.1
publishedAt: 1776278400000

path: deerflow_runtime.py
content: #!/usr/bin/env python3
"""Helpers for managing the shared embedded DeerFlow runtime."""
from __future__ import annotations

import json
import os
import shutil
import subprocess
from pathlib import Path

DEERFLOW_REPO_URL = os.environ.get(
    "AGENT_OFFICE_DEERFLOW_REPO_URL",
    "https://github.com/bytedance/deer-flow.git",
)
DEFAULT_MODEL = os.environ.get("AGENT_OFFICE_DEERFLOW_MODEL", "gpt-5.4")
DEFAULT_REASONING_EFFORT = os.environ.get(
    "AGENT_OFFICE_DEERFLOW_REASONING_EFFORT",
    "medium",
)
DEFAULT_RECURSION_LIMIT = int(
    os.environ.get("AGENT_OFFICE_DEERFLOW_RECURSION_LIMIT", "48")
)
DEFAULT_TASK_TIMEOUT = int(os.environ.get("AGENT_OFFICE_DEERFLOW_TIMEOUT", "600"))


def office_dir() -> Path:
    return Path(os.environ.get("HERMES_OFFICE_DIR", Path.home() / ".hermes" / "office"))


def runtime_root(base_dir: Path | None = None) -> Path:
    return (base_dir or office_dir()) / "deerflow-runtime" / "deer-flow"


def runtime_backend_dir(base_dir: Path | None = None) -> Path:
    return runtime_root(base_dir) / "backend"


def runtime_python(base_dir: Path | None = None) -> Path:
    return runtime_backend_dir(base_dir) / ".venv" / "bin" / "python"


def runtime_homes_root(base_dir: Path | None = None) -> Path:
    return (base_dir or office_dir()) / "deerflow-runtime" / "homes"


def worker_home(worker_id: str, base_dir: Path | None = None) -> Path:
    return runtime_homes_root(base_dir) / worker_id


def runtime_update_on_add() -> bool:
    return os.environ.get("AGENT_OFFICE_DEERFLOW_UPDATE_ON_ADD", "").lower() in {
        "1",
        "true",
        "yes",
    }


def missing_prerequisites() -> list[str]:
    missing = []
    for executable in ("git", "uv"):
        if not shutil.which(executable):
            missing.append(executable)
    return missing


def _run_checked(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess:
    result = subprocess.run(
        cmd,
        cwd=str(cwd) if cwd else None,
        capture_output=True,
        text=True,
        timeout=600,
    )
    if result.returncode != 0:
        detail = (result.stderr or result.stdout or "unknown error").strip()
        raise RuntimeError(f"{' '.join(cmd)} failed: {detail[:500]}")
    return result


def ensure_deerflow_runtime(update: bool = False) -> dict[str, str]:
    base_dir = office_dir()
    shared_root = base_dir / "deerflow-runtime"
    root = runtime_root(base_dir)
    backend_dir = runtime_backend_dir(base_dir)
    python_bin = runtime_python(base_dir)
    shared_root.mkdir(parents=True, exist_ok=True)

    missing = missing_prerequisites()
    if missing:
        raise RuntimeError(
            "DeerFlow 自动安装缺少依赖: " + ", ".join(missing)
        )

    if not root.exists():
        _run_checked(["git", "clone", "--depth=1", DEERFLOW_REPO_URL, str(root)])
    elif update:
        _run_checked(["git", "-C", str(root), "pull", "--ff-only"])

    if not backend_dir.exists():
        raise RuntimeError(f"DeerFlow backend 目录缺失: {backend_dir}")

    if update or not python_bin.exists():
        _run_checked(["uv", "sync"], cwd=backend_dir)

    return {
        "runtime_root": str(root),
        "runtime_backend_dir": str(backend_dir),
        "runtime_python": str(python_bin),
    }


def _yaml_string(value: str) -> str:
    return json.dumps(str(value), ensure_ascii=False)


def _optional_mounts() -> list[tuple[Path, str, bool]]:
    mounts: list[tuple[Path, str, bool]] = []
    extra_mounts = os.environ.get("AGENT_OFFICE_DEERFLOW_EXTRA_MOUNTS", "").strip()
    if not extra_mounts:
        return mounts
    for index, raw in enumerate(extra_mounts.split(os.pathsep), 1):
        if not raw.strip():
            continue
        path = Path(os.path.expanduser(raw.strip())).resolve()
        if path.exists():
            mounts.append((path, f"/mnt/extra-{index}", False))
    return mounts


def ensure_worker_home(
    worker_id: str,
    worker_name: str,
    role: str,
    model: str = DEFAULT_MODEL,
    base_dir: Path | None = None,
) -> Path:
    home_dir = worker_home(worker_id, base_dir)
    agent_dir = home_dir / "agents" / worker_id
    agent_dir.mkdir(parents=True, exist_ok=True)
    (home_dir / "threads").mkdir(exist_ok=True)
    (home_dir / "artifacts").mkdir(exist_ok=True)

    (agent_dir / "config.yaml").write_text(
        "\n".join(
            [
                f"name: {worker_id}",
                f"description: Agent Office DeerFlow team for {worker_name}",
                f"model: {model}",
                "",
            ]
        ),
        encoding="utf-8",
    )
    (agent_dir / "SOUL.md").write_text(
        "\n".join(
            [
                f"你是 {worker_name}（{worker_id}），是 Agent Office 自动创建的 DeerFlow 2.0 团队型员工。",
                "",
                f"- 主要职责：{role}",
                "- 默认承接复杂、多步、需要内部拆分的任务。",
                "- 优先把任务当作团队任务处理，而不是单人直答。",
                "- 可以读取共享办公室上下文，但不要假装没有发生的内部协作。",
                "",
            ]
        ),
        encoding="utf-8",
    )
    return home_dir


def write_worker_runtime_config(
    worker_dir: Path,
    workspace_dir: str,
    worker_id: str,
    worker_name: str,
    role: str,
    model: str = DEFAULT_MODEL,
    reasoning_effort: str = DEFAULT_REASONING_EFFORT,
) -> Path:
    resolved_worker_dir = worker_dir.resolve()
    resolved_workspace = Path(os.path.expanduser(workspace_dir)).resolve()
    mounts: list[tuple[Path, str, bool]] = [
        (resolved_workspace, "/mnt/workspace", False),
        # Expose only the current worker's local files, not the whole office.
        (resolved_worker_dir, "/mnt/worker", True),
        * _optional_mounts(),
    ]

    mount_lines: list[str] = []
    seen_mounts: set[tuple[str, str]] = set()
    for path, container_path, read_only in mounts:
        key = (str(path), container_path)
        if key in seen_mounts or not path.exists():
            continue
        seen_mounts.add(key)
        mount_lines.extend(
            [
                f"    - host_path: {_yaml_string(path)}",
                f"      container_path: {_yaml_string(container_path)}",
                f"      read_only: {'true' if read_only else 'false'}",
            ]
        )

    config_lines = [
        "config_version: 6",
        "log_level: info",
        "",
        "models:",
        f"  - name: {_yaml_string(model)}",
        f"    display_name: {_yaml_string(f'{worker_name} · {model} (Codex CLI)')}",
        "    use: deerflow.models.openai_codex_provider:CodexChatModel",
        f"    model: {_yaml_string(model)}",
        f"    reasoning_effort: {_yaml_string(reasoning_effort)}",
        "    supports_thinking: true",
        "    supports_reasoning_effort: true",
        "",
        "tool_groups:",
        "  - name: web",
        "  - name: file:read",
        "  - name: file:write",
        "  - name: bash",
        "",
        "tools:",
        "  - name: web_search",
        "    group: web",
        "    use: deerflow.community.ddg_search.tools:web_search_tool",
        "    max_results: 5",
        "  - name: web_fetch",
        "    group: web",
        "    use: deerflow.community.jina_ai.tools:web_fetch_tool",
        "    timeout: 10",
        "  - name: image_search",
        "    group: web",
        "    use: deerflow.community.image_search.tools:image_search_tool",
        "    max_results: 5",
        "  - name: ls",
        "    group: file:read",
        "    use: deerflow.sandbox.tools:ls_tool",
        "  - name: read_file",
        "    group: file:read",
        "    use: deerflow.sandbox.tools:read_file_tool",
        "  - name: glob",
        "    group: file:read",
        "    use: deerflow.sandbox.tools:glob_tool",
        "    max_results: 200",
        "  - name: grep",
        "    group: file:read",
        "    use: deerflow.sandbox.tools:grep_tool",
        "    max_results: 100",
        "  - name: write_file",
        "    group: file:write",
        "    use: deerflow.sandbox.tools:write_file_tool",
        "  - name: str_replace",
        "    group: file:write",
        "    use: deerflow.sandbox.tools:str_replace_tool",
        "  - name: bash",
        "    group: bash",
        "    use: deerflow.sandbox.tools:bash_tool",
        "",
        "sandbox:",
        "  use: deerflow.sandbox.local:LocalSandboxProvider",
        "  allow_host_bash: true",
        "  mounts:",
        *mount_lines,
        "  bash_output_max_chars: 20000",
        "  read_file_output_max_chars: 50000",
        "  ls_output_max_chars: 20000",
        "",
        "skills:",
        "  container_path: /mnt/skills",
        "",
        "memory:",
        "  enabled: true",
        "  storage_path: memory.json",
        "  debounce_seconds: 5",
        "  model_name: null",
        "  max_facts: 80",
        "  fact_confidence_threshold: 0.7",
        "  injection_enabled: true",
        "  max_injection_tokens: 2000",
        "",
        "title:",
        "  enabled: true",
        "  max_words: 6",
        "  max_chars: 60",
        "  model_name: null",
        "",
        "summarization:",
        "  enabled: true",
        "  model_name: null",
        "  trigger:",
        "    - type: tokens",
        "      value: 15564",
        "  keep:",
        "    type: messages",
        "    value: 10",
        "  trim_tokens_to_summarize: 15564",
        "  summary_prompt: null",
        "",
    ]
    config_path = worker_dir / "deerflow_config.yaml"
    config_path.write_text("\n".join(config_lines), encoding="utf-8")
    return config_path


path: deerflow_runtime_runner.py
content: #!/usr/bin/env python3
"""Standalone embedded DeerFlow runner for Agent Office workers."""
from __future__ import annotations

import argparse
import asyncio
import json
import os
from typing import Any


def _extract_text(content: Any) -> str:
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts: list[str] = []
        for item in content:
            if isinstance(item, str):
                parts.append(item)
            elif isinstance(item, dict):
                text = item.get("text")
                if isinstance(text, str):
                    parts.append(text)
        return "\n".join(part for part in parts if part)
    if isinstance(content, dict):
        text = content.get("text")
        if isinstance(text, str):
            return text
    return str(content)


def _normalize_string_list(value: Any) -> list[str]:
    if not isinstance(value, list):
        return []
    result: list[str] = []
    for item in value:
        text = _extract_text(item).strip()
        if text:
            result.append(text)
    return result


def _extract_json_block(text: str) -> dict[str, Any] | None:
    raw = text.strip()
    if not raw:
        return None
    candidates = [raw]
    if "```json" in raw:
        candidates.extend(chunk.strip() for chunk in raw.split("```json") if chunk.strip())
    if "```" in raw:
        candidates.extend(chunk.strip() for chunk in raw.split("```") if chunk.strip())
    for candidate in candidates:
        cleaned = candidate[4:].strip() if candidate.startswith("json") else candidate
        try:
            return json.loads(cleaned)
        except json.JSONDecodeError:
            start = cleaned.find("{")
            end = cleaned.rfind("}")
            if start != -1 and end != -1 and end > start:
                snippet = cleaned[start : end + 1]
                try:
                    return json.loads(snippet)
                except json.JSONDecodeError:
                    continue
    return None


def _summarize_event(event: dict[str, Any], task_labels: dict[str, str]) -> str | None:
    event_type = str(event.get("type") or "")
    task_id = str(event.get("task_id") or "")
    label = task_labels.get(task_id) or task_id
    if event_type == "task_started":
        description = str(event.get("description") or label)
        task_labels[task_id] = description
        return f"subagent started: {description}"
    if event_type == "task_running":
        message = event.get("message")
        content = ""
        if isinstance(message, dict):
            content = _extract_text(message.get("content") or "")
        if not content:
            return None
        return f"subagent running: {label} -> {content[:200]}"
    if event_type == "task_completed":
        result = _extract_text(event.get("result") or "")
        summary = result[:200] if result else "completed"
        return f"subagent completed: {label} -> {summary}"
    if event_type == "task_failed":
        error = _extract_text(event.get("error") or "")
        return f"subagent failed: {label} -> {error[:200]}"
    if event_type == "task_cancelled":
        return f"subagent cancelled: {label}"
    if event_type == "task_timed_out":
        error = _extract_text(event.get("error") or "")
        return f"subagent timed out: {label} -> {error[:200]}"
    return None


async def _run(args: argparse.Namespace) -> dict[str, Any]:
    os.environ["DEER_FLOW_HOME"] = args.home
    os.environ["DEER_FLOW_CONFIG_PATH"] = args.config

    from langchain_core.messages import AIMessage, HumanMessage
    from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver

    from deerflow.client import DeerFlowClient

    task_labels: dict[str, str] = {}
    delegation_trace: list[str] = []
    last_ai_text = ""
    parse_note = ""
    checkpoint_path = os.path.join(args.home, "checkpoints.sqlite")
    os.makedirs(args.home, exist_ok=True)

    async with AsyncSqliteSaver.from_conn_string(checkpoint_path) as saver:
        client = DeerFlowClient(
            agent_name=args.agent_name,
            subagent_enabled=True,
            model_name=args.model,
            checkpointer=saver,
        )
        config = client._get_runnable_config(
            args.thread_id,
            subagent_enabled=True,
            model_name=args.model,
            recursion_limit=args.recursion_limit,
        )
        client._ensure_agent(config)

        state = {"messages": [HumanMessage(content=args.prompt)]}
        context = {"thread_id": args.thread_id, "agent_name": args.agent_name}
        async for item in client._agent.astream(
            state,
            config=config,
            context=context,
            stream_mode=["values", "custom"],
        ):
            if isinstance(item, tuple) and len(item) == 2:
                mode, chunk = item
                mode = str(mode)
            else:
                mode, chunk = "values", item

            if mode == "custom":
                if isinstance(chunk, dict):
                    summary = _summarize_event(chunk, task_labels)
                    if summary:
                        delegation_trace.append(summary)
                continue

            messages = chunk.get("messages", []) if isinstance(chunk, dict) else []
            for msg in messages:
                if isinstance(msg, AIMessage):
                    text = client._extract_text(msg.content)
                    if text:
                        last_ai_text = text

    parsed = _extract_json_block(last_ai_text)
    if parsed is None:
        parse_note = "DeerFlow returned non-JSON text; preserved raw_team_output for inspection."
        parsed = {
            "team_summary": last_ai_text[:2000] or f"{args.agent_name} 已接到《{args.title}》。",
            "collaboration_mode": "deerflow_team",
            "current_scope": [
                "当前以 DeerFlow 2.0 团队形态接单",
                "本次运行返回了非 JSON 文本，已按原始回复收下",
            ],
            "next_step_suggestions": [
                "检查 DeerFlow prompt 是否要求最终只返回 JSON",
            ],
            "handoff_note": "DeerFlow 返回了非 JSON 文本，已保留原始回复供排查。",
            "raw_team_output": last_ai_text[:12000],
            "parse_note": parse_note,
        }
    else:
        raw_team_output = str(parsed.get("raw_team_output") or "")
        if raw_team_output:
            parsed["raw_team_output"] = raw_team_output[:12000]
        if parsed.get("parse_note"):
            parse_note = str(parsed.get("parse_note") or "")

    return {
        "team_summary": str(parsed.get("team_summary") or ""),
        "collaboration_mode": str(parsed.get("collaboration_mode") or "deerflow_team"),
        "current_scope": _normalize_string_list(parsed.get("current_scope", [])),
        "next_step_suggestions": _normalize_string_list(
            parsed.get("next_step_suggestions", [])
        ),
        "handoff_note": str(parsed.get("handoff_note") or ""),
        "delegation_trace": delegation_trace,
        "runtime_backend": "deerflow2_embedded",
        "team_thread_id": args.thread_id,
        "raw_team_output": str(parsed.get("raw_team_output") or ""),
        "parse_note": parse_note,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Run an embedded DeerFlow team task.")
    parser.add_argument("--config", required=True)
    parser.add_argument("--home", required=True)
    parser.add_argument("--agent-name", required=True)
    parser.add_argument("--model", required=True)
    parser.add_argument("--thread-id", required=True)
    parser.add_argument("--title", required=True)
    parser.add_argument("--prompt", required=True)
    parser.add_argument("--recursion-limit", type=int, default=48)
    parser.add_argument("--reasoning-effort", default="medium")
    args = parser.parse_args()

    result = asyncio.run(_run(args))
    print(json.dumps(result, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: flows/fanout-synthesis.json
content: {
  "name": "fanout-synthesis",
  "display_name": "多方并行收集 + 统一综合",
  "description": "把同一个任务同时交给多个岗位并行产出，再由综合岗位统一整合。",
  "slots": {
    "contributor_a": "第一位并行贡献者",
    "contributor_b": "第二位并行贡献者",
    "contributor_c": "第三位并行贡献者",
    "synthesizer": "负责综合总结的岗位"
  },
  "steps": [
    {
      "id": "collect",
      "type": "parallel",
      "branches": [
        {
          "id": "collect_a",
          "worker_slot": "contributor_a",
          "title": "并行贡献A：{{input.title}}",
          "description": "请独立完成你对这个任务的方案或建议。\n\n任务：\n{{input.description}}"
        },
        {
          "id": "collect_b",
          "worker_slot": "contributor_b",
          "title": "并行贡献B：{{input.title}}",
          "description": "请独立完成你对这个任务的方案或建议。\n\n任务：\n{{input.description}}"
        },
        {
          "id": "collect_c",
          "worker_slot": "contributor_c",
          "title": "并行贡献C：{{input.title}}",
          "description": "请独立完成你对这个任务的方案或建议。\n\n任务：\n{{input.description}}"
        }
      ]
    },
    {
      "id": "synthesize",
      "type": "single",
      "worker_slot": "synthesizer",
      "title": "综合输出：{{input.title}}",
      "description": "请综合以下多方结果，给出统一方案、冲突点和下一步建议。\n\n多方结果：\n{{steps.collect.combined_content}}"
    }
  ]
}


path: flows/handoff-review.json
content: {
  "name": "handoff-review",
  "display_name": "串行推进 + 并行评审 + 汇总",
  "description": "先由规划岗位拆解，再交给执行岗位产出，随后并行交给两位评审，最后由汇总岗位出总结。",
  "slots": {
    "planner": "负责拆解任务的岗位",
    "executor": "负责核心执行的岗位",
    "reviewer_a": "第一位评审岗位",
    "reviewer_b": "第二位评审岗位",
    "finalizer": "负责最终汇总的岗位"
  },
  "steps": [
    {
      "id": "plan",
      "type": "single",
      "worker_slot": "planner",
      "title": "任务拆解：{{input.title}}",
      "description": "请先拆解下面这个任务，明确目标、关键点、风险与建议执行顺序。\n\n原始任务：\n{{input.description}}"
    },
    {
      "id": "execute",
      "type": "single",
      "worker_slot": "executor",
      "title": "执行任务：{{input.title}}",
      "description": "请根据下面的任务拆解继续推进，并给出你的产出。\n\n任务拆解：\n{{steps.plan.content}}\n\n原始任务：\n{{input.description}}"
    },
    {
      "id": "review",
      "type": "parallel",
      "branches": [
        {
          "id": "review_a",
          "worker_slot": "reviewer_a",
          "title": "评审A：{{input.title}}",
          "description": "请从质量和风险角度评审以下执行结果，并指出主要问题或建议。\n\n执行结果：\n{{steps.execute.content}}"
        },
        {
          "id": "review_b",
          "worker_slot": "reviewer_b",
          "title": "评审B：{{input.title}}",
          "description": "请从可读性、可交付性和下一步建议的角度评审以下执行结果。\n\n执行结果：\n{{steps.execute.content}}"
        }
      ]
    },
    {
      "id": "finalize",
      "type": "single",
      "worker_slot": "finalizer",
      "title": "汇总结论：{{input.title}}",
      "description": "请综合以下信息，输出一个最终结论和下一步建议。\n\n任务拆解：\n{{steps.plan.content}}\n\n执行结果：\n{{steps.execute.content}}\n\n并行评审结果：\n{{steps.review.combined_content}}"
    }
  ]
}


path: presets/coding-company.json
content: {
  "slug": "coding-company",
  "name": "编程公司",
  "description": "适合做产品设计、前后端开发、测试交付和复杂项目推进的技术型团队。",
  "workers": [
    {
      "name": "产品",
      "worker_id": "product_manager",
      "engine": "hermes",
      "role": "research",
      "duty": "负责需求梳理、竞品分析与任务拆解"
    },
    {
      "name": "前端",
      "worker_id": "frontend_engineer",
      "engine": "openclaw",
      "role": "code",
      "duty": "负责前端页面、交互逻辑与组件实现"
    },
    {
      "name": "后端",
      "worker_id": "backend_engineer",
      "engine": "openclaw",
      "role": "code",
      "duty": "负责接口、数据层、服务端逻辑与排障"
    },
    {
      "name": "设计",
      "worker_id": "ui_designer",
      "engine": "openclaw",
      "role": "design",
      "duty": "负责界面方案、组件视觉与用户体验"
    },
    {
      "name": "测试",
      "worker_id": "qa_engineer",
      "engine": "hermes",
      "role": "general",
      "duty": "负责测试用例、冒烟验证与问题复测"
    },
    {
      "name": "项目",
      "worker_id": "project_orchestrator",
      "engine": "deerflow",
      "role": "complex",
      "duty": "负责复杂任务推进、跨岗位协调与阶段汇总"
    }
  ]
}


path: presets/publishing-company.json
content: {
  "slug": "publishing-company",
  "name": "出版公司",
  "description": "适合选题策划、内容编辑、封面设计、发行宣传的出版型团队。",
  "workers": [
    {
      "name": "主编",
      "worker_id": "chief_editor",
      "engine": "openclaw",
      "role": "research",
      "duty": "负责选题方向、内容把关与结构审稿"
    },
    {
      "name": "策划",
      "worker_id": "book_planner",
      "engine": "openclaw",
      "role": "research",
      "duty": "负责选题策划、竞品调研与市场定位"
    },
    {
      "name": "校对",
      "worker_id": "proofreader",
      "engine": "hermes",
      "role": "general",
      "duty": "负责文稿校对、错漏检查与出版前整理"
    },
    {
      "name": "封设",
      "worker_id": "cover_designer",
      "engine": "openclaw",
      "role": "design",
      "duty": "负责封面设计、版式建议与视觉统一"
    },
    {
      "name": "发行",
      "worker_id": "distribution_manager",
      "engine": "hermes",
      "role": "general",
      "duty": "负责发行文案、渠道沟通与宣传协作"
    }
  ]
}


path: scripts/add_worker.py
content: #!/usr/bin/env python3
"""
add_worker.py —— 添加新员工（纯 Python 版，避免 shell 编码问题）
用法: python3 add_worker.py <名字> <引擎> <角色> [可选参数]
示例: python3 add_worker.py 小龙 openclaw research
      python3 add_worker.py 小克 cli code --cli-profile claude-code
"""
import argparse
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional
from urllib import error, request

SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
if str(SKILL_DIR) not in sys.path:
    sys.path.insert(0, str(SKILL_DIR))

from deerflow_runtime import (
    DEFAULT_MODEL as DEERFLOW_DEFAULT_MODEL,
    DEFAULT_REASONING_EFFORT as DEERFLOW_DEFAULT_REASONING_EFFORT,
    DEFAULT_RECURSION_LIMIT as DEERFLOW_DEFAULT_RECURSION_LIMIT,
    DEFAULT_TASK_TIMEOUT as DEERFLOW_DEFAULT_TIMEOUT,
    ensure_deerflow_runtime,
    ensure_worker_home,
    missing_prerequisites,
    runtime_update_on_add,
    write_worker_runtime_config,
)

# ════════════════════════════════════════════════════════════
# 路径配置
# ════════════════════════════════════════════════════════════
OFFICE_DIR = Path(os.environ.get("HERMES_OFFICE_DIR", Path.home() / ".hermes" / "office"))
STATE_FILE = OFFICE_DIR / "state" / "office_state.json"
WORKER_SERVER = SKILL_DIR / "worker_server.py"
TEMPLATE_DIR = SKILL_DIR / "templates"

ENGINE_TEMPLATES = {
    "openclaw": "openclaw_prompt.md",
    "hermes": "hermes_prompt.md",
    "deerflow": "deerflow_prompt.md",
    "cli": "cli_prompt.md",
    "external": "external_prompt.md",
    "stub": "stub_prompt.md",
}

ROLE_DESCRIPTIONS = {
    "research": "竞品调研、信息收集、事实核查与资料整理",
    "code": "脚本开发、代码实现、问题排查与技术修复",
    "design": "界面设计、页面结构、视觉与交互建议",
    "finance": "财务记录、记账核对与台账整理",
    "complex": "复杂任务拆解、跨步骤推进与整体验收",
    "general": "通用执行、沟通协调与任务跟进",
}

ENGINE_TIMEOUTS = {
    "openclaw": 120,
    "hermes": 120,
    "deerflow": 300,
    "cli": 600,
    "external": 300,
    "stub": 30,
}

CLI_PROFILES = {
    "codex": {
        "display_name": "OpenAI Codex CLI",
        "executables": ("codex",),
        "base_args": ("exec", "--skip-git-repo-check"),
        "prompt_mode": "stdin",
        "prompt_flag": "",
        "timeout": 600,
    },
    "claude-code": {
        "display_name": "Claude Code",
        "executables": ("claude", "claude-code"),
        "base_args": (),
        "prompt_mode": "stdin",
        "prompt_flag": "",
        "timeout": 600,
    },
    "aider": {
        "display_name": "Aider",
        "executables": ("aider",),
        "base_args": (),
        "prompt_mode": "arg",
        "prompt_flag": "--message",
        "timeout": 600,
    },
    "gemini-cli": {
        "display_name": "Gemini CLI",
        "executables": ("gemini", "gemini-cli"),
        "base_args": (),
        "prompt_mode": "stdin",
        "prompt_flag": "",
        "timeout": 600,
    },
    "opencode": {
        "display_name": "OpenCode",
        "executables": ("opencode",),
        "base_args": (),
        "prompt_mode": "stdin",
        "prompt_flag": "",
        "timeout": 600,
    },
}


# ════════════════════════════════════════════════════════════
# 工具函数
# ════════════════════════════════════════════════════════════
def pinyin(name: str) -> str:
    """中文名字 → worker_id (拼音)"""
    m = {
        '小':'xiao','龙':'long','扣':'kou','设':'she','秘':'mi',
        '虾':'xia','会':'hui','计':'ji','助':'zhu','行':'xing',
        '政':'zheng','理':'li','D':'d','d':'d',
        '阿':'a','杰':'jie','文':'wen','案':'an','财':'cai',
        '务':'wu'
    }
    result = []
    i = 0
    s = name.strip()
    while i < len(s):
        two = s[i:i+2]
        if len(two) == 2 and two in m:
            result.append(m[two]); i += 2; continue
        ch = s[i]
        if ch in m:
            result.append(m[ch])
        elif ch.isascii():
            result.append(ch)
        i += 1
    pid = re.sub(r'[^a-z0-9]', '_', ''.join(result).lower())
    return pid if pid else f"worker_{int(time.time())}"


def init_state():
    """初始化状态文件"""
    OFFICE_DIR.mkdir(parents=True, exist_ok=True)
    (OFFICE_DIR / "state").mkdir(exist_ok=True)
    if not STATE_FILE.exists():
        data = {
            "workers": {},
            "port_pool": {
                "used": [],
                "available": list(range(5011, 5021))
            }
        }
        with open(STATE_FILE, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)


def load_state() -> dict:
    init_state()
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_state(data: dict):
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def alloc_port(state: dict) -> Optional[int]:
    available = state.get("port_pool", {}).get("available", [])
    if not available:
        return None
    return available[0]


def alloc_port_commit(state: dict, port: int):
    state["port_pool"]["available"] = [p for p in state["port_pool"].get("available", []) if p != port]
    state["port_pool"]["used"] = sorted(set(state["port_pool"].get("used", []) + [port]))


def role_description(role: str) -> str:
    return ROLE_DESCRIPTIONS.get(role, f"{role} 相关任务执行与交付")


def get_cli_profile(profile_id: str) -> dict:
    normalized = (profile_id or "codex").strip().lower()
    if normalized not in CLI_PROFILES:
        available = ", ".join(sorted(CLI_PROFILES))
        raise ValueError(f"未知 CLI profile: {profile_id}，支持: {available}")
    return CLI_PROFILES[normalized]


def cli_profile_display(profile_id: str) -> str:
    return get_cli_profile(profile_id)["display_name"]


def cli_executable_ready(cli_cmd: str, cli_profile: str) -> bool:
    if cli_cmd:
        try:
            parts = shlex.split(cli_cmd)
        except ValueError:
            return False
        if not parts:
            return False
        executable = parts[0]
        return bool(shutil.which(executable) or os.path.exists(os.path.expanduser(executable)))

    profile = get_cli_profile(cli_profile)
    for candidate in profile["executables"]:
        if shutil.which(candidate):
            return True
    return False


def normalize_workspace_dir(worker_dir: Path, workspace_override: str) -> str:
    if workspace_override:
        return str(Path(os.path.expanduser(workspace_override)).resolve())
    return str(worker_dir)


def render_template(template_text: str, values: dict[str, str]) -> str:
    rendered = template_text
    for key, value in values.items():
        rendered = rendered.replace(f"{{{{{key}}}}}", str(value))
    return rendered


def worker_healthcheck(port: int, timeout: int = 5) -> bool:
    url = f"http://127.0.0.1:{port}/health"
    try:
        with request.urlopen(url, timeout=timeout) as resp:
            if resp.status != 200:
                return False
            payload = json.loads(resp.read().decode("utf-8"))
            return payload.get("status") == "ok"
    except (error.URLError, error.HTTPError, TimeoutError, json.JSONDecodeError, ValueError):
        return False


def normalize_external_upstream_url(url: str, port: int = 0) -> str:
    raw = (url or "").strip()
    if not raw and port:
        raw = f"http://127.0.0.1:{port}"
    if not raw:
        return ""
    if "://" not in raw:
        raw = f"http://{raw}"
    return raw.rstrip("/")


def external_upstream_ready(url: str, timeout: int = 5) -> bool:
    if not url:
        return False
    health_url = f"{normalize_external_upstream_url(url)}/health"
    try:
        with request.urlopen(health_url, timeout=timeout) as resp:
            if resp.status != 200:
                return False
            payload = json.loads(resp.read().decode("utf-8"))
            return payload.get("status") == "ok"
    except (error.URLError, error.HTTPError, TimeoutError, json.JSONDecodeError, ValueError):
        return False


# ════════════════════════════════════════════════════════════
# 核心逻辑
# ════════════════════════════════════════════════════════════
def check_duplicate(state: dict, name: str, worker_id: str, engine: str):
    """重名检测，两层都查：state文件 + openclaw agents"""
    # ── 层1：state文件中的name ──────────────────────────
    for wid, w in state.get("workers", {}).items():
        if wid == worker_id:
            print(f"❌ 工号 '{worker_id}' 已存在")
            print(f"   已有员工: {w.get('name', wid)}")
            print(f"   端口: {w.get('port','?')} | 引擎: {w.get('engine','?')} | 状态: {w.get('status','?')}")
            sys.exit(1)
        if w.get("name") == name:
            print(f"❌ 员工「{name}」已存在")
            print(f"   工号: {wid}")
            print(f"   端口: {w.get('port','?')} | 引擎: {w.get('engine','?')} | 状态: {w.get('status','?')}")
            print()
            print(f"   请换一个新名字，例如：")
            print(f"   · {name} → {name}2 / 阿杰 / {name}同学")
            print()
            print(f"   或先移除旧员工：python3 remove_worker.py {wid}")
            sys.exit(1)

    # ── 层2：openclaw agents（仅openclaw/hermes）──────
    if engine in ("openclaw", "hermes"):
        try:
            r = subprocess.run(
                ["openclaw", "agents", "list", "--json"],
                capture_output=True, text=True, timeout=3
            )
            if r.returncode == 0:
                try:
                    agents = json.loads(r.stdout)
                    ids = [a.get("id") for a in agents] if isinstance(agents, list) else []
                    if worker_id in ids:
                        print(f"❌ openclaw agent '{worker_id}' 已存在（与「{name}」同名冲突）")
                        print(f"   请换一个员工名字")
                        sys.exit(1)
                except json.JSONDecodeError:
                    pass
        except Exception:
            pass  # 网络/进程问题不阻塞


def check_engine(
    engine: str,
    cli_profile: str = "codex",
    cli_cmd: str = "",
    external_upstream_url: str = "",
) -> bool:
    """检测引擎是否可用。只检测，不安装；未安装时提示用户手动安装。"""
    if engine == "stub":
        print("📦 stub 模式无需安装（纯模拟，不启动真实进程）")
        return True

    if engine == "openclaw":
        if subprocess.run(["which", "openclaw"], capture_output=True).returncode == 0:
            v = subprocess.run(["openclaw", "--version"],
                             capture_output=True, text=True, timeout=3)
            print(f"✅ openclaw 已安装: {v.stdout.strip() or 'ok'}")
            return True
        print("❌ openclaw 未安装。请先安装：pip install openclaw")
        return False

    if engine == "hermes":
        if subprocess.run(["which", "hermes"], capture_output=True).returncode == 0:
            print("✅ hermes 已安装")
            return True
        print("❌ hermes 未安装。请先安装：pip install hermes-ai")
        return False

    if engine == "deerflow":
        missing = missing_prerequisites()
        if not missing:
            print("✅ deerflow 引擎可用（技能内将自动安装/复用共享 DeerFlow runtime）")
            return True
        print("❌ deerflow 引擎缺少自动安装依赖: " + ", ".join(missing))
        return False

    if engine == "cli":
        if cli_executable_ready(cli_cmd, cli_profile):
            profile_name = cli_profile_display(cli_profile)
            if cli_cmd:
                print(f"✅ cli 引擎可用（命令覆盖: {cli_cmd}）")
            else:
                print(f"✅ cli 引擎可用（profile: {profile_name}）")
            return True
        profile_name = cli_profile_display(cli_profile)
        print(f"❌ cli 引擎不可用。当前 profile: {profile_name}")
        print("   请先安装对应 CLI，或通过 --cli-cmd 指定可执行命令。")
        return False

    if engine == "external":
        if not external_upstream_url:
            print("❌ external 引擎需要 --external-upstream-url 或 --external-upstream-port")
            return False
        if external_upstream_ready(external_upstream_url):
            print(f"✅ external 引擎可用（上游: {normalize_external_upstream_url(external_upstream_url)}）")
            return True
        print(f"❌ external 引擎无法连通上游: {normalize_external_upstream_url(external_upstream_url)}")
        print("   需要上游 worker 兼容 /health 与 /tasks HTTP 协议。")
        return False

    return True


def write_soul(
    worker_dir: Path,
    name: str,
    worker_id: str,
    role: str,
    engine: str,
    port: int,
    cli_profile: str = "",
    external_upstream_url: str = "",
):
    """生成 SOUL.md（基于 templates/ 渲染）"""
    template_name = ENGINE_TEMPLATES.get(engine, "openclaw_prompt.md")
    template_path = TEMPLATE_DIR / template_name
    if template_path.exists():
        template_text = template_path.read_text(encoding="utf-8")
    else:
        template_text = (
            "# {{NAME}}\n\n"
            "- 工号：{{WORKER_ID}}\n"
            "- 职责：{{ROLE}}\n"
            "- 引擎：{{ENGINE}}\n"
        )

    rendered = render_template(
        template_text,
        {
            "NAME": name,
            "ROLE": role,
            "WORKER_ID": worker_id,
            "PORT": port,
            "ROLE_DESCRIPTION": role_description(role),
            "TIMEOUT": ENGINE_TIMEOUTS.get(engine, 120),
            "ENGINE": engine,
            "CLI_PROFILE": cli_profile or "-",
            "CLI_PROFILE_DISPLAY": cli_profile_display(cli_profile) if cli_profile else "-",
            "EXTERNAL_UPSTREAM_URL": external_upstream_url or "-",
        },
    )
    with open(worker_dir / "SOUL.md", "w", encoding="utf-8") as f:
        f.write(rendered.strip() + "\n")


def register_openclaw_agent(worker_id: str, worker_dir: Path, name: str, role: str):
    """注册 openclaw agent"""
    try:
        # 先检查是否已存在（设短超时）
        r = subprocess.run(
            ["openclaw", "agents", "list", "--json"],
            capture_output=True, text=True, timeout=3
        )
        existing_ids = []
        if r.returncode == 0:
            try:
                existing_ids = [a.get("id") for a in json.loads(r.stdout)]
            except json.JSONDecodeError:
                pass

        if worker_id not in existing_ids:
            print(f"📋 注册 openclaw agent: {worker_id}")
            subproc = subprocess.run(
                ["openclaw", "agents", "add", worker_id,
                 "--workspace", str(worker_dir),
                 "--non-interactive"],
                capture_output=True, text=True, timeout=10
            )
            if subproc.returncode != 0:
                print(f"   (非阻塞，{subproc.stderr[:80] if subproc.stderr else 'continue'})")

        # 设置身份
        emoji_map = {"research": "🔍", "code": "💻", "design": "🎨",
                     "finance": "💰", "complex": "🧠", "custom": "🤖"}
        emoji = emoji_map.get(role, "🤖")
        subprocess.run(
            ["openclaw", "agents", "set-identity",
             "--agent", worker_id, "--name", name, "--emoji", emoji],
            capture_output=True, timeout=5
        )
    except subprocess.TimeoutExpired:
        print(f"   (openclaw agent 注册超时，非阻塞继续)")
    except Exception as e:
        print(f"   (openclaw agent 注册非阻塞继续: {e})")


def start_worker(
    port: int,
    worker_id: str,
    name: str,
    role: str,
    engine: str,
    worker_dir: Path,
    workspace_dir: str,
    cli_profile: str = "",
    cli_cmd: str = "",
    cli_args: str = "",
    cli_timeout: int = 0,
    deerflow_runtime_python: str = "",
    deerflow_config: str = "",
    deerflow_home: str = "",
    deerflow_agent_name: str = "",
    deerflow_model: str = "",
    deerflow_reasoning_effort: str = "",
    deerflow_recursion_limit: int = 0,
    deerflow_timeout: int = 0,
    external_upstream_url: str = "",
    external_poll_interval: float = 2.0,
    external_timeout: int = 0,
):
    """启动 worker HTTP 服务器"""
    if engine == "stub":
        print("📦 stub 模式已注册（无独立进程）")
        return "idle"

    log_file = worker_dir / "logs" / "worker.log"
    log_file.parent.mkdir(exist_ok=True)

    with open(log_file, "w", encoding="utf-8") as lf:
        lf.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting worker_server.py\n")
        lf.flush()
        cmd = [
            sys.executable, str(WORKER_SERVER),
            "--port", str(port),
            "--worker-id", worker_id,
            "--name", name,
            "--role", role,
            "--engine", engine,
            "--workspace-dir", workspace_dir,
        ]
        if engine == "cli":
            cmd += ["--cli-profile", cli_profile or "codex"]
            if cli_cmd:
                cmd += ["--cli-cmd", cli_cmd]
            if cli_args:
                cmd += ["--cli-args", cli_args]
            if cli_timeout:
                cmd += ["--cli-timeout", str(cli_timeout)]
        if engine == "deerflow":
            cmd += [
                "--deerflow-runtime-python", deerflow_runtime_python,
                "--deerflow-config", deerflow_config,
                "--deerflow-home", deerflow_home,
                "--deerflow-agent-name", deerflow_agent_name,
                "--deerflow-model", deerflow_model,
                "--deerflow-reasoning-effort", deerflow_reasoning_effort,
                "--deerflow-recursion-limit", str(deerflow_recursion_limit or DEERFLOW_DEFAULT_RECURSION_LIMIT),
                "--deerflow-timeout", str(deerflow_timeout or DEERFLOW_DEFAULT_TIMEOUT),
            ]
        if engine == "external":
            cmd += [
                "--external-upstream-url", external_upstream_url,
                "--external-poll-interval", str(external_poll_interval or 2.0),
                "--external-timeout", str(external_timeout or ENGINE_TIMEOUTS["external"]),
            ]
        proc = subprocess.Popen(
            cmd,
            stdout=lf, stderr=subprocess.STDOUT
        )

    # 等待启动并严格健康检查
    for _ in range(6):
        time.sleep(1)
        if worker_healthcheck(port):
            print(f"🚀 {name} 已启动 (PID: {proc.pid}, 端口: {port})")
            return "idle"

    print(f"⚠️ {name} 已启动 (PID: {proc.pid})，健康检查未通过")
    return "not_ready"


def create_worker(
    name: str,
    engine: str,
    role: str,
    worker_id_override: Optional[str] = None,
    cli_profile: str = "codex",
    cli_cmd: str = "",
    cli_args: str = "",
    cli_timeout: int = 0,
    workspace_override: str = "",
    deerflow_update_runtime: bool = False,
    external_upstream_url: str = "",
    external_upstream_port: int = 0,
    external_timeout: int = 0,
    external_poll_interval: float = 2.0,
) -> dict:
    valid_engines = ("openclaw", "hermes", "deerflow", "cli", "external", "stub")
    if engine not in valid_engines:
        raise ValueError(f"未知引擎: {engine}，支持: {', '.join(valid_engines)}")

    worker_id = worker_id_override or pinyin(name)
    print(f"🔍 检查 {engine} 运行环境...")

    state = load_state()
    check_duplicate(state, name, worker_id, engine)

    normalized_external_upstream = normalize_external_upstream_url(
        external_upstream_url,
        port=external_upstream_port,
    )

    if not check_engine(
        engine,
        cli_profile=cli_profile,
        cli_cmd=cli_cmd,
        external_upstream_url=normalized_external_upstream,
    ):
        raise RuntimeError("引擎未就绪，无法添加员工。请先安装对应 CLI 再重试。")

    port = alloc_port(state)
    if not port:
        raise RuntimeError("没有可用端口，请先移除一些员工")

    worker_dir = OFFICE_DIR / "workers" / worker_id
    worker_dir.mkdir(parents=True, exist_ok=True)
    (worker_dir / "logs").mkdir(exist_ok=True)
    workspace_dir = normalize_workspace_dir(worker_dir, workspace_override)
    deerflow_runtime = {}
    deerflow_home = ""
    deerflow_config = ""
    if engine == "deerflow":
        should_update_runtime = deerflow_update_runtime or runtime_update_on_add()
        print("🦌 准备 DeerFlow runtime...")
        deerflow_runtime = ensure_deerflow_runtime(update=should_update_runtime)
        deerflow_home = str(
            ensure_worker_home(
                worker_id,
                name,
                role,
                model=DEERFLOW_DEFAULT_MODEL,
            )
        )
        deerflow_config = str(
            write_worker_runtime_config(
                worker_dir,
                workspace_dir,
                worker_id,
                name,
                role,
                model=DEERFLOW_DEFAULT_MODEL,
                reasoning_effort=DEERFLOW_DEFAULT_REASONING_EFFORT,
            )
        )

    # ── 生成 SOUL.md ──────────────────────────────────
    write_soul(
        worker_dir,
        name,
        worker_id,
        role,
        engine,
        port,
        cli_profile=cli_profile if engine == "cli" else "",
        external_upstream_url=normalized_external_upstream if engine == "external" else "",
    )

    # ── 注册 openclaw agent ────────────────────────────
    if engine in ("openclaw", "hermes"):
        register_openclaw_agent(worker_id, worker_dir, name, role)

    # ── 写配置 ────────────────────────────────────────
    config = {
        "worker_id": worker_id,
        "name": name,
        "port": port,
        "role": role,
        "engine": engine,
        "workspace_dir": workspace_dir,
        "status": "onboarding",
        "added_at": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()),
    }
    if engine == "cli":
        config.update(
            {
                "cli_profile": cli_profile,
                "cli_cmd": cli_cmd,
                "cli_args": cli_args,
                "cli_timeout": cli_timeout or get_cli_profile(cli_profile)["timeout"],
            }
        )
    if engine == "deerflow":
        config.update(
            {
                "deerflow_runtime_root": deerflow_runtime["runtime_root"],
                "deerflow_runtime_backend_dir": deerflow_runtime["runtime_backend_dir"],
                "deerflow_runtime_python": deerflow_runtime["runtime_python"],
                "deerflow_config": deerflow_config,
                "deerflow_home": deerflow_home,
                "deerflow_agent_name": worker_id,
                "deerflow_model": DEERFLOW_DEFAULT_MODEL,
                "deerflow_reasoning_effort": DEERFLOW_DEFAULT_REASONING_EFFORT,
                "deerflow_recursion_limit": DEERFLOW_DEFAULT_RECURSION_LIMIT,
                "deerflow_timeout": DEERFLOW_DEFAULT_TIMEOUT,
            }
        )
    if engine == "external":
        config.update(
            {
                "external_upstream_url": normalized_external_upstream,
                "external_timeout": external_timeout or ENGINE_TIMEOUTS["external"],
                "external_poll_interval": external_poll_interval or 2.0,
                "external_mode": "bridge",
            }
        )
    with open(worker_dir / "config.json", "w", encoding="utf-8") as f:
        json.dump(config, f, ensure_ascii=False, indent=2)

    # ── 更新 state ─────────────────────────────────────
    state["workers"][worker_id] = {
        "name": name,
        "port": port,
        "role": role,
        "engine": engine,
        "workspace_dir": workspace_dir,
        "status": "onboarding",
        "config_path": str(worker_dir / "config.json"),
        "soul_path": str(worker_dir / "SOUL.md"),
        "added_at": config["added_at"],
        "last_active": None
    }
    if engine == "cli":
        state["workers"][worker_id]["cli_profile"] = cli_profile
    if engine == "deerflow":
        state["workers"][worker_id]["deerflow_home"] = deerflow_home
    if engine == "external":
        state["workers"][worker_id]["external_upstream_url"] = normalized_external_upstream
    alloc_port_commit(state, port)
    save_state(state)
    print("✅ 状态中心已更新")

    # ── 启动 worker ────────────────────────────────────
    status = start_worker(
        port,
        worker_id,
        name,
        role,
        engine,
        worker_dir,
        workspace_dir,
        cli_profile=cli_profile,
        cli_cmd=cli_cmd,
        cli_args=cli_args,
        cli_timeout=cli_timeout or get_cli_profile(cli_profile)["timeout"] if engine == "cli" else 0,
        deerflow_runtime_python=config.get("deerflow_runtime_python", ""),
        deerflow_config=config.get("deerflow_config", ""),
        deerflow_home=config.get("deerflow_home", ""),
        deerflow_agent_name=config.get("deerflow_agent_name", worker_id),
        deerflow_model=config.get("deerflow_model", DEERFLOW_DEFAULT_MODEL),
        deerflow_reasoning_effort=config.get("deerflow_reasoning_effort", DEERFLOW_DEFAULT_REASONING_EFFORT),
        deerflow_recursion_limit=config.get("deerflow_recursion_limit", DEERFLOW_DEFAULT_RECURSION_LIMIT),
        deerflow_timeout=config.get("deerflow_timeout", DEERFLOW_DEFAULT_TIMEOUT),
        external_upstream_url=config.get("external_upstream_url", ""),
        external_poll_interval=config.get("external_poll_interval", 2.0),
        external_timeout=config.get("external_timeout", ENGINE_TIMEOUTS["external"]),
    )

    # ── 更新状态 ──────────────────────────────────────
    state["workers"][worker_id]["status"] = status
    save_state(state)

    return {
        "ok": True,
        "worker_id": worker_id,
        "name": name,
        "engine": engine,
        "role": role,
        "port": port,
        "status": status,
        "worker_dir": str(worker_dir),
        "workspace_dir": workspace_dir,
        "cli_profile": cli_profile if engine == "cli" else "",
        "deerflow_runtime_root": config.get("deerflow_runtime_root", ""),
        "external_upstream_url": config.get("external_upstream_url", ""),
    }


# ════════════════════════════════════════════════════════════
# 主流程
# ════════════════════════════════════════════════════════════
def main():
    parser = argparse.ArgumentParser(description="Add an Agent Office worker")
    parser.add_argument("name", nargs="?", help="员工名字")
    parser.add_argument("engine", nargs="?", help="引擎: openclaw | hermes | deerflow | cli | external | stub")
    parser.add_argument("role", nargs="?", help="职责关键词")
    parser.add_argument("--worker-id", default="", help="手动指定 worker_id")
    parser.add_argument("--workspace", default="", help="真实执行工作目录；默认使用员工目录")
    parser.add_argument("--cli-profile", default="codex", help="cli 引擎 profile")
    parser.add_argument("--cli-cmd", default="", help="cli 引擎命令覆盖，例如 'codex exec'")
    parser.add_argument("--cli-args", default="", help="追加给 cli 引擎的参数")
    parser.add_argument("--cli-timeout", type=int, default=0, help="cli 引擎超时秒数")
    parser.add_argument("--deerflow-update-runtime", action="store_true", help="添加 deerflow 员工前先更新共享 runtime")
    parser.add_argument("--external-upstream-url", default="", help="external 引擎上游 URL，例如 http://127.0.0.1:18750")
    parser.add_argument("--external-upstream-port", type=int, default=0, help="external 引擎上游端口，例如 18750")
    parser.add_argument("--external-timeout", type=int, default=0, help="external 引擎整单超时秒数")
    parser.add_argument("--external-poll-interval", type=float, default=2.0, help="external 引擎轮询间隔秒数")
    parser.add_argument("--list-cli-profiles", action="store_true", help="列出内置 CLI profiles")
    args = parser.parse_args()

    if args.list_cli_profiles:
        print("可用 CLI profiles:")
        for profile_id in sorted(CLI_PROFILES):
            print(f"- {profile_id}: {CLI_PROFILES[profile_id]['display_name']}")
        return

    if not args.name or not args.engine or not args.role:
        print("❌ 用法: python3 add_worker.py <名字> <引擎> <角色> [可选参数]")
        print("   引擎: openclaw | hermes | deerflow | cli | external | stub")
        print("   示例: python3 add_worker.py 小龙 openclaw research")
        print("         python3 add_worker.py 小D deerflow complex")
        print("         python3 add_worker.py 小克 cli code --cli-profile claude-code")
        print("         python3 add_worker.py 外挂小龙 external general --external-upstream-port 18750")
        sys.exit(1)

    try:
        result = create_worker(
            args.name,
            args.engine,
            args.role,
            worker_id_override=args.worker_id or None,
            cli_profile=args.cli_profile,
            cli_cmd=args.cli_cmd,
            cli_args=args.cli_args,
            cli_timeout=args.cli_timeout,
            workspace_override=args.workspace,
            deerflow_update_runtime=args.deerflow_update_runtime,
            external_upstream_url=args.external_upstream_url,
            external_upstream_port=args.external_upstream_port,
            external_timeout=args.external_timeout,
            external_poll_interval=args.external_poll_interval,
        )
    except ValueError as e:
        print(f"❌ {e}")
        sys.exit(1)
    except RuntimeError as e:
        print(f"❌ {e}")
        sys.exit(1)

    # ── 汇报 ──────────────────────────────────────────
    print()
    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
    print(f"✅ {result['name']} 入职完成")
    print(f"   工号: {result['worker_id']}")
    print(f"   引擎: {result['engine']}")
    print(f"   端口: {result['port']}")
    print(f"   职责: {result['role']}")
    print(f"   状态: {result['status']}")
    print(f"   工作目录: {result['workspace_dir']}")
    if result["engine"] == "cli":
        print(f"   CLI Profile: {result.get('cli_profile', 'codex')}")
    if result["engine"] == "deerflow":
        print(f"   DeerFlow Runtime: {result.get('deerflow_runtime_root', '-')}")
    if result["engine"] == "external":
        print(f"   Upstream: {result.get('external_upstream_url', '-')}")
    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")


if __name__ == "__main__":
    main()


path: scripts/add_worker.sh
content: #!/usr/bin/env bash
# add_worker.sh —— 调用 Python 版添加员工脚本
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/add_worker.py" "$@"


path: scripts/apply_preset.py
content: #!/usr/bin/env python3
"""
apply_preset.py —— 应用团队预设

用法:
  python3 apply_preset.py --list
  python3 apply_preset.py 出版公司
  python3 apply_preset.py coding-company --dry-run
"""
import argparse
import json
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

import add_worker


PRESET_DIR = Path(__file__).resolve().parent.parent / "presets"
PRESET_ALIASES = {
    "出版公司": "publishing-company",
    "出版": "publishing-company",
    "publishing": "publishing-company",
    "publishing-company": "publishing-company",
    "编程公司": "coding-company",
    "程序公司": "coding-company",
    "coding": "coding-company",
    "coding-company": "coding-company",
}


def list_presets() -> list[dict]:
    presets = []
    for path in sorted(PRESET_DIR.glob("*.json")):
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        presets.append(data)
    return presets


def resolve_preset_name(name: str) -> str:
    key = (name or "").strip()
    return PRESET_ALIASES.get(key, key)


def load_preset(name: str) -> dict:
    slug = resolve_preset_name(name)
    path = PRESET_DIR / f"{slug}.json"
    if not path.exists():
        raise FileNotFoundError(f"找不到预设：{name}")
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def print_presets() -> None:
    print("可用预设：")
    for preset in list_presets():
        print(f"- {preset['name']} ({preset['slug']})")
        print(f"  {preset['description']}")
        print(f"  员工数量: {len(preset.get('workers', []))}")


def apply_preset(name: str, dry_run: bool = False) -> list[dict]:
    preset = load_preset(name)
    results = []

    print(f"🏢 应用预设：{preset['name']}")
    print(f"   {preset['description']}")
    print(f"   计划添加 {len(preset.get('workers', []))} 位员工")
    print()

    for item in preset.get("workers", []):
        worker_name = item["name"]
        worker_id = item.get("worker_id")
        engine = item["engine"]
        role = item["role"]
        duty = item.get("duty", "")

        print(f"→ {worker_name} | {engine} | {role}")
        if duty:
            print(f"   职责: {duty}")

        if dry_run:
            results.append(
                {
                    "name": worker_name,
                    "worker_id": worker_id,
                    "engine": engine,
                    "role": role,
                    "status": "planned",
                }
            )
            continue

        result = add_worker.create_worker(
            name=worker_name,
            engine=engine,
            role=role,
            worker_id_override=worker_id,
        )
        results.append(result)
        print(f"   ✅ 已添加：{result['worker_id']} @ {result['port']}")
        print()

    return results


def main() -> None:
    parser = argparse.ArgumentParser(description="Apply Agent Office presets")
    parser.add_argument("preset", nargs="?", help="预设名称，如 出版公司 / 编程公司")
    parser.add_argument("--list", action="store_true", help="列出可用预设")
    parser.add_argument("--dry-run", action="store_true", help="只预览，不真正创建员工")
    args = parser.parse_args()

    if args.list:
        print_presets()
        return

    if not args.preset:
        print("❌ 用法: python3 apply_preset.py <预设名> [--dry-run]")
        print("   先看列表: python3 apply_preset.py --list")
        sys.exit(1)

    try:
        results = apply_preset(args.preset, dry_run=args.dry_run)
    except FileNotFoundError as e:
        print(f"❌ {e}")
        sys.exit(1)
    except Exception as e:
        print(f"❌ 应用预设失败: {e}")
        sys.exit(1)

    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
    print(f"✅ 预设处理完成，共 {len(results)} 项")
    if args.dry_run:
        print("   当前为预演模式，未真正创建员工")
    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")


if __name__ == "__main__":
    main()


path: scripts/apply_preset.sh
content: #!/usr/bin/env bash
# apply_preset.sh —— 调用 Python 版团队预设脚本
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/apply_preset.py" "$@"


path: scripts/demo.sh
content: #!/usr/bin/env bash
# demo.sh —— 一键验证团队是否正常运行
set -euo pipefail

OFFICE_DIR="${HERMES_OFFICE_DIR:-$HOME/.hermes/office}"
STATE_FILE="$OFFICE_DIR/state/office_state.json"

echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  Agent Office 团队健康检查"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

if [[ ! -f "$STATE_FILE" ]]; then
  echo "❌ 团队未初始化，请先安装 skill 并重启 OpenClaw"
  exit 1
fi

python3 -c "
import json, sys, urllib.error, urllib.request

with open('$STATE_FILE') as f:
    state = json.load(f)

workers = state.get('workers', {})
if not workers:
    print('⚠️  当前没有员工，请说「添加员工」开始')
    sys.exit(0)

print(f'\n员工总数: {len(workers)}\n')

all_ok = True
for wid, w in workers.items():
    port = w.get('port', 0)
    name = w.get('name', wid)
    engine = w.get('engine', 'unknown')
    role = w.get('role', '')
    status = w.get('status', 'unknown')
    last = w.get('last_active', '从未')

    # 严格检查 /health 是否返回 200 且 status=ok
    if port:
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{port}/health', timeout=3) as resp:
                payload = json.loads(resp.read().decode('utf-8'))
                if resp.status == 200 and payload.get('status') == 'ok':
                    health = '🟢 在线'
                else:
                    health = f'🟡 异常({resp.status})'
        except urllib.error.URLError:
            health = '🔴 未启动'
        except Exception:
            health = '🟡 响应异常'
    else:
        health = '⚪ 端口未分配'

    print(f'{health}  {name}({wid}) | 引擎:{engine} | 端口:{port} | 职责:{role} | 状态:{status}')
    if last and last != '从未':
        print(f'         最后活跃: {last}')

    if status == 'not_ready':
        all_ok = False

print()
if all_ok:
    print('✅ 团队运行正常')
else:
    print('⚠️  部分员工未就绪，请检查安装')
"


path: scripts/list_workers.sh
content: #!/usr/bin/env bash
# list_workers.sh —— 查看团队成员
# 用法: bash list_workers.sh
set -euo pipefail

OFFICE_DIR="${HERMES_OFFICE_DIR:-$HOME/.hermes/office}"
STATE_FILE="$OFFICE_DIR/state/office_state.json"

echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  Agent Office 团队成员"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

if [[ ! -f "$STATE_FILE" ]]; then
  echo "⚠️  团队未初始化，请说「添加员工」开始"
  exit 0
fi

python3 -c "
import json

with open('$STATE_FILE') as f:
    s = json.load(f)

workers = s.get('workers', {})
port_pool = s.get('port_pool', {})

if not workers:
    print('⚠️  当前没有员工，请说「添加员工」')
    print()
    print('端口池状态：')
    print(f'  已用: {port_pool.get(\"used\", [])}')
    print(f'  可用: {port_pool.get(\"available\", [])}')
    exit(0)

print(f'\n员工总数: {len(workers)}')
print(f'端口池: 已用 {len(port_pool.get(\"used\", []))} 个，可用 {len(port_pool.get(\"available\", []))} 个')
print()

# 表头
STATUS_HDR='状态'; NAME_HDR='名字'; ID_HDR='工号'; ENGINE_HDR='引擎'; PORT_HDR='端口'; ROLE_HDR='职责'
print(f"{STATUS_HDR:<8} {NAME_HDR:<10} {ID_HDR:<18} {ENGINE_HDR:<10} {PORT_HDR:<6} {ROLE_HDR:<15}")
print('-' * 75)

status_icon = {'idle':'🟢','running':'🔵','busy':'🟡','not_ready':'🔴','onboarding':'🟠','offline':'⚪'}

for wid, w in workers.items():
    icon = status_icon.get(w.get('status','unknown'), '⚪')
    name = w.get('name', wid)
    engine = w.get('engine', '-')
    port = w.get('port', '-')
    role = w.get('role', '-')
    last = w.get('last_active', '')
    print(f'{icon}  {name:<8} {wid:<18} {engine:<10} {port:<6} {role:<15}')

print()
print('状态说明：')
print('  🟢 idle       空闲，可接收任务')
print('  🔵 running    正在执行任务')
print('  🟡 busy       忙碌中')
print('  🟠 onboarding 入职中')
print('  🔴 not_ready  未就绪（依赖未安装）')
print('  ⚪ offline    已离线')
"


path: scripts/remove_worker.py
content: #!/usr/bin/env python3
"""
remove_worker.py —— 移除员工（纯 Python 版）
用法: python3 remove_worker.py <worker_id>
"""
import json
import os
import signal
import subprocess
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
if str(SKILL_DIR) not in sys.path:
    sys.path.insert(0, str(SKILL_DIR))

from deerflow_runtime import worker_home

OFFICE_DIR = Path(os.environ.get("HERMES_OFFICE_DIR", Path.home() / ".hermes" / "office"))
STATE_FILE = OFFICE_DIR / "state" / "office_state.json"


def load_state():
    if not STATE_FILE.exists():
        return {"workers": {}, "port_pool": {"used": [], "available": []}}
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_state(data):
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def find_worker_by_name_or_id(identifier: str, state: dict):
    """支持用名字或工号查找"""
    # 直接 ID 匹配
    if identifier in state.get("workers", {}):
        return identifier, state["workers"][identifier]

    # name 匹配
    for wid, w in state.get("workers", {}).items():
        if w.get("name") == identifier:
            return wid, w

    return None, None


def main():
    if len(sys.argv) < 2:
        print("❌ 用法: python3 remove_worker.py <worker_id或名字>")
        sys.exit(1)

    identifier = sys.argv[1]
    state = load_state()
    wid, worker = find_worker_by_name_or_id(identifier, state)

    if not worker:
        print(f"❌ 找不到员工: {identifier}")
        available = list(state.get("workers", {}).keys())
        if available:
            print(f"   可用员工: {', '.join(available)}")
        sys.exit(1)

    name = worker.get("name", wid)
    port = worker.get("port")
    engine = worker.get("engine", "unknown")

    # ── 停止进程 ──────────────────────────────────────
    if port:
        try:
            r = subprocess.run(
                ["lsof", "-ti", f":{port}"],
                capture_output=True, text=True, timeout=5
            )
            for pid_str in r.stdout.strip().split("\n"):
                if pid_str:
                    try:
                        os.kill(int(pid_str), signal.SIGTERM)
                        print(f"✅ 进程已停止 (PID: {pid_str})")
                    except ProcessLookupError:
                        pass
        except Exception:
            pass

    # ── 注销 openclaw agent ───────────────────────────
    if engine in ("openclaw", "hermes"):
        try:
            subprocess.run(
                ["openclaw", "agents", "remove", wid],
                capture_output=True, timeout=10
            )
            print(f"✅ openclaw agent 已注销: {wid}")
        except Exception:
            pass

    # ── 删除目录 ──────────────────────────────────────
    worker_dir = OFFICE_DIR / "workers" / wid
    if worker_dir.exists():
        import shutil
        shutil.rmtree(worker_dir)
        print(f"✅ 目录已删除: {worker_dir}")

    if engine == "deerflow":
        home_dir = Path(worker.get("deerflow_home") or worker_home(wid))
        if home_dir.exists():
            import shutil
            shutil.rmtree(home_dir)
            print(f"✅ DeerFlow home 已删除: {home_dir}")

    # ── 更新 state ─────────────────────────────────────
    if wid in state.get("workers", {}):
        del state["workers"][wid]

    if port and port not in state.get("port_pool", {}).get("available", []):
        used = state.get("port_pool", {}).get("used", [])
        if port in used:
            used.remove(port)
        state["port_pool"]["available"] = sorted(
            set(state["port_pool"].get("available", []) + [port])
        )

    save_state(state)
    print(f"✅ {name} 已离职，端口 {port} 已释放")


if __name__ == "__main__":
    main()


path: scripts/remove_worker.sh
content: #!/usr/bin/env bash
# remove_worker.sh —— 调用 Python 版移除员工脚本
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/remove_worker.py" "$@"


path: scripts/run_flow.py
content: #!/usr/bin/env python3
"""
run_flow.py —— 运行通用任务流转

支持：
1. 串行流转
2. 多方并行流转
3. 最终汇总
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path


OFFICE_DIR = Path(os.environ.get("HERMES_OFFICE_DIR", Path.home() / ".hermes" / "office"))
STATE_FILE = OFFICE_DIR / "state" / "office_state.json"
FLOW_RUN_DIR = OFFICE_DIR / "flow_runs"
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"

FLOW_ALIASES = {
    "流转": "handoff-review",
    "handoff": "handoff-review",
    "handoff-review": "handoff-review",
    "并行综合": "fanout-synthesis",
    "fanout": "fanout-synthesis",
    "fanout-synthesis": "fanout-synthesis",
}


def now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())


def load_state() -> dict:
    if not STATE_FILE.exists():
        raise FileNotFoundError(f"找不到办公室状态文件：{STATE_FILE}")
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_flow_run(run_data: dict) -> Path:
    FLOW_RUN_DIR.mkdir(parents=True, exist_ok=True)
    run_path = FLOW_RUN_DIR / f"{run_data['run_id']}.json"
    with open(run_path, "w", encoding="utf-8") as f:
        json.dump(run_data, f, ensure_ascii=False, indent=2)
    return run_path


def list_flows() -> list[dict]:
    flows = []
    for path in sorted(FLOW_DIR.glob("*.json")):
        with open(path, encoding="utf-8") as f:
            flows.append(json.load(f))
    return flows


def resolve_flow_name(name: str) -> str:
    key = (name or "").strip()
    return FLOW_ALIASES.get(key, key)


def load_flow(name: str) -> dict:
    flow_name = resolve_flow_name(name)
    path = FLOW_DIR / f"{flow_name}.json"
    if not path.exists():
        raise FileNotFoundError(f"找不到 flow：{name}")
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def deep_get(data, path: str):
    current = data
    for part in path.split("."):
        if isinstance(current, dict):
            current = current.get(part)
        else:
            return ""
    return current if current is not None else ""


def render_template(text: str, context: dict) -> str:
    if not text:
        return ""

    def replace(match):
        expr = match.group(1).strip()
        value = deep_get(context, expr)
        if isinstance(value, (dict, list)):
            return json.dumps(value, ensure_ascii=False, indent=2)
        return str(value)

    return re.sub(r"\{\{\s*([^{}]+)\s*\}\}", replace, text)


def resolve_worker(identifier: str, state: dict) -> dict:
    workers = state.get("workers", {})
    if identifier in workers:
        worker = workers[identifier]
        return {"worker_id": identifier, **worker}
    for worker_id, worker in workers.items():
        if worker.get("name") == identifier:
            return {"worker_id": worker_id, **worker}
    raise KeyError(f"找不到员工：{identifier}")


def post_task(port: int, payload: dict) -> dict:
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    request = urllib.request.Request(
        f"http://127.0.0.1:{port}/tasks",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=10) as resp:
        return json.loads(resp.read().decode("utf-8"))


def get_task(port: int, task_id: str) -> dict:
    with urllib.request.urlopen(f"http://127.0.0.1:{port}/tasks/{task_id}", timeout=10) as resp:
        return json.loads(resp.read().decode("utf-8"))


def wait_task(port: int, task_id: str, timeout_seconds: int = 180, interval_seconds: float = 1.0) -> dict:
    started = time.time()
    while time.time() - started <= timeout_seconds:
        task = get_task(port, task_id)
        status = task.get("status")
        if status in {"done", "error"}:
            return task
        time.sleep(interval_seconds)
    raise TimeoutError(f"任务超时：{task_id}")


def normalize_result(step_id: str, worker: dict, task_payload: dict, task_result: dict) -> dict:
    result = task_result.get("result", {}) if isinstance(task_result.get("result"), dict) else {}
    return {
        "step_id": step_id,
        "worker_id": worker["worker_id"],
        "worker_name": worker.get("name", worker["worker_id"]),
        "status": task_result.get("status", "unknown"),
        "title": task_payload.get("title", ""),
        "summary": task_result.get("summary", ""),
        "result": result,
        "content": result.get("content", ""),
        "raw": task_result,
    }


def build_combined_content(results: list[dict]) -> str:
    blocks = []
    for item in results:
        blocks.append(
            f"[{item['worker_name']}/{item['worker_id']}]\n"
            f"状态: {item['status']}\n"
            f"摘要: {item['summary']}\n"
            f"内容:\n{item['content']}"
        )
    return "\n\n".join(blocks)


def parse_slot_assignments(values: list[str]) -> dict:
    slots = {}
    for item in values or []:
        if "=" not in item:
            raise ValueError(f"无效 slot 参数：{item}，应为 slot=name")
        key, value = item.split("=", 1)
        slots[key.strip()] = value.strip()
    return slots


def execute_single_step(step: dict, state: dict, context: dict) -> dict:
    worker_name = context["slot_values"][step["worker_slot"]]
    worker = resolve_worker(worker_name, state)
    payload = {
        "title": render_template(step.get("title", context["input"]["title"]), context),
        "description": render_template(step.get("description", context["input"]["description"]), context),
    }
    task_meta = post_task(worker["port"], payload)
    task_result = wait_task(worker["port"], task_meta["task_id"], timeout_seconds=step.get("timeout", 180))
    return normalize_result(step["id"], worker, payload, task_result)


def execute_parallel_step(step: dict, state: dict, context: dict) -> dict:
    submitted = []
    for branch in step.get("branches", []):
        worker_name = context["slot_values"][branch["worker_slot"]]
        worker = resolve_worker(worker_name, state)
        payload = {
            "title": render_template(branch.get("title", context["input"]["title"]), context),
            "description": render_template(branch.get("description", context["input"]["description"]), context),
        }
        task_meta = post_task(worker["port"], payload)
        submitted.append((branch, worker, payload, task_meta["task_id"]))

    results = []
    for branch, worker, payload, task_id in submitted:
        task_result = wait_task(worker["port"], task_id, timeout_seconds=branch.get("timeout", step.get("timeout", 180)))
        results.append(normalize_result(branch["id"], worker, payload, task_result))

    combined = build_combined_content(results)
    return {
        "step_id": step["id"],
        "type": "parallel",
        "status": "done" if all(item["status"] == "done" for item in results) else "partial",
        "branches": results,
        "combined_content": combined,
        "summary": " / ".join(item["summary"] for item in results if item.get("summary")),
    }


def validate_slots(flow: dict, slot_values: dict) -> None:
    required = set(flow.get("slots", {}).keys())
    missing = sorted(required - set(slot_values.keys()))
    if missing:
        raise ValueError(f"缺少 slot 绑定：{', '.join(missing)}")


def run_flow(flow: dict, title: str, description: str, slot_values: dict) -> dict:
    state = load_state()
    validate_slots(flow, slot_values)

    run_data = {
        "run_id": str(uuid.uuid4()),
        "flow_name": flow["name"],
        "flow_display_name": flow.get("display_name", flow["name"]),
        "started_at": now(),
        "status": "running",
        "input": {
            "title": title,
            "description": description,
        },
        "slot_values": slot_values,
        "steps": {}
    }
    run_path = save_flow_run(run_data)

    context = {
        "input": run_data["input"],
        "steps": run_data["steps"],
        "slot_values": slot_values,
        "run": {
            "id": run_data["run_id"],
            "path": str(run_path),
        },
    }

    for step in flow.get("steps", []):
        if step["type"] == "single":
            result = execute_single_step(step, state, context)
        elif step["type"] == "parallel":
            result = execute_parallel_step(step, state, context)
        else:
            raise ValueError(f"未知 step 类型：{step['type']}")

        run_data["steps"][step["id"]] = result
        save_flow_run(run_data)

    run_data["status"] = "done"
    run_data["finished_at"] = now()
    save_flow_run(run_data)
    return run_data


def print_flow_list() -> None:
    print("可用流转模板：")
    for flow in list_flows():
        print(f"- {flow['display_name']} ({flow['name']})")
        print(f"  {flow.get('description', '')}")
        if flow.get("slots"):
            print(f"  需要岗位: {', '.join(flow['slots'].keys())}")


def main() -> None:
    parser = argparse.ArgumentParser(description="Run Agent Office flows")
    parser.add_argument("flow", nargs="?", help="flow 名称，如 handoff-review")
    parser.add_argument("--title", default="未命名流转任务", help="任务标题")
    parser.add_argument("--description", default="", help="任务描述")
    parser.add_argument("--slot", action="append", default=[], help="绑定岗位，如 planner=产品")
    parser.add_argument("--list", action="store_true", help="列出可用流转模板")
    args = parser.parse_args()

    if args.list:
        print_flow_list()
        return

    if not args.flow:
        print("❌ 用法: python3 run_flow.py <flow> --slot planner=产品 ...")
        print("   先看列表: python3 run_flow.py --list")
        sys.exit(1)

    try:
        flow = load_flow(args.flow)
        slot_values = parse_slot_assignments(args.slot)
        result = run_flow(flow, args.title, args.description, slot_values)
    except (FileNotFoundError, KeyError, ValueError, TimeoutError, urllib.error.URLError) as e:
        print(f"❌ {e}")
        sys.exit(1)

    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
    print(f"✅ 流转完成：{result['flow_display_name']}")
    print(f"   run_id: {result['run_id']}")
    print(f"   结果文件: {FLOW_RUN_DIR / (result['run_id'] + '.json')}")
    print(f"   最后一步: {list(result['steps'].keys())[-1] if result['steps'] else '-'}")
    print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")


if __name__ == "__main__":
    main()


path: scripts/run_flow.sh
content: #!/usr/bin/env bash
# run_flow.sh —— 调用 Python 版流转脚本
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/run_flow.py" "$@"


path: scripts/update_deerflow_runtime.py
content: #!/usr/bin/env python3
"""Update the shared embedded DeerFlow runtime for Agent Office."""
from __future__ import annotations

import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
if str(SKILL_DIR) not in sys.path:
    sys.path.insert(0, str(SKILL_DIR))

from deerflow_runtime import ensure_deerflow_runtime


def main() -> int:
    info = ensure_deerflow_runtime(update=True)
    print("✅ DeerFlow 共享 runtime 已更新")
    print(f"   runtime: {info['runtime_root']}")
    print(f"   python:  {info['runtime_python']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/update_deerflow_runtime.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/update_deerflow_runtime.py" "$@"


path: templates/cli_prompt.md
content: 你是团队中的 CLI 专项员工，职责是{{ROLE}}。

## 你的基本信息
- 员工编号：{{WORKER_ID}}
- 运行端口：{{PORT}}
- 引擎类型：cli
- CLI Profile：{{CLI_PROFILE_DISPLAY}}（{{CLI_PROFILE}}）
- 所属团队：Agent Office

## 你的专长
- {{ROLE_DESCRIPTION}}
- 通过主流本地 coding CLI 执行代码、脚本、命令行任务

## 任务执行规范
1. 先理解任务目标和成功标准
2. 如涉及代码或脚本，优先在授权工作目录内执行
3. 返回结构化结果，说明做了什么、有没有失败点

```json
{
  "task_id": "{task_id}",
  "status": "done | failed",
  "result": {
    "content": "...（CLI 原始输出或整理后的执行结果）",
    "format": "markdown | json | text"
  },
  "summary": "一句话总结"
}
```

## 约束
- 只在授权 workspace 中运行
- CLI 命令和参数由办公室配置，不擅自切换到其他工具
- 任务超时时间为 {{TIMEOUT}} 秒


path: templates/deerflow_prompt.md
content: 你是团队中的复杂任务专家，职责是{{ROLE}}。

## 你的基本信息
- 员工编号：{{WORKER_ID}}
- 运行端口：{{PORT}}
- 引擎类型：deerflow
- 所属团队：Agent Office

## 你的专长
- {{ROLE_DESCRIPTION}}
- 复杂多步骤任务（子任务分解、浏览器操作、报告生成）
- DeerFlow 2.0 嵌入式 runtime

## 任务执行规范
1. 接收复杂任务后，进行任务分解
2. 按步骤执行，可调用浏览器、搜索、代码等工具
3. 每步结果记录到状态中
4. 最终返回完整报告：

```json
{
  "task_id": "{task_id}",
  "status": "done | failed",
  "result": {
    "content": "...（完整报告内容）",
    "format": "markdown | json | html",
    "steps": [
      {"step": 1, "action": "...", "result": "..."},
      {"step": 2, "action": "...", "result": "..."}
    ]
  },
  "summary": "任务完成总结"
}
```

## 约束
- 任务超时：{{TIMEOUT}} 秒（默认600秒）
- 所有子任务完成后才返回最终结果
- 中途失败返回已完成的步骤和失败原因


path: templates/deerflow_task_prompt.txt
content: 你是 `__WORKER_ID__`，办公室里的“__WORKER_NAME__”。

你不是单个执行者，而是 Agent Office 为这个员工自动启动的一整个 **DeerFlow 2.0 团队** 的入口与团队负责人。

你的默认定位：
- 优先承包大任务、多步任务、需要内部拆分或并行处理的任务
- 收到任务后，优先以 DeerFlow 团队方式处理，而不是把自己当成单人助手
- 只要不是简单寒暄、heartbeat 或一句话确认，就优先使用 DeerFlow 内建 `task` 子任务能力，至少委派 1 个子任务
- 如果任务明显包含两种视角，例如执行 + 审计、方案 + 风险、实现 + 验收，优先委派 2 个子任务
- 子任务 prompt 必须显式写明：`不要再继续分派任务，直接基于当前上下文完成`
- 单轮最多并发 2 个子任务，避免无上限扩散

你的职责：
- 主要职责：__ROLE_DESCRIPTION__
- 把办公室交给你的任务，当成 DeerFlow 2.0 团队任务来承接
- 把自己视为“团队外包承包方”，对复杂任务给出团队级别的拆分、执行和交接
- 根据任务内容调动 DeerFlow 的内建 subagents、工具、线程工作区与记忆
- 最后把团队结果汇总回给办公室

你的边界：
- 不要假装“内部已经委派”或“团队已经协同”如果实际上没有发生
- 不要把外部 OpenClaw 员工当成 DeerFlow 内建 subagents
- 如果需要引用文件、代码库或共享目录，优先使用当前运行时已经暴露给 DeerFlow 的挂载路径
- 当前员工自己的资料只看 `/mnt/worker`；当前任务工作目录只看 `/mnt/workspace`
- 不要主动搜索或引用其他办公室员工目录，也不要把别的员工文件当成当前员工身份依据
- 可以阅读附带的共享记忆上下文，但只允许只读使用，不要写回共享 MemPalace
- 如果任务很小、很直接、无需团队拆分，也要如实说明这次没有必要过度调度团队

任务标题：__TITLE__
任务描述：__DESCRIPTION__
共享记忆上下文：
__SHARED_MEMORY_CONTEXT__

最终只返回 JSON，结构必须是：
{
  "team_summary": "一句总结当前团队如何接单以及做了什么",
  "collaboration_mode": "固定写 deerflow_team",
  "current_scope": ["当前团队做了哪些事", "当前团队的真实边界"],
  "next_step_suggestions": ["建议的下一步 1", "建议的下一步 2"],
  "handoff_note": "给办公室的简短交接说明"
}

不要输出 markdown，不要输出解释，不要输出代码块。


path: templates/external_prompt.md
content: 你是团队中的外部接入员工，职责是{{ROLE}}。

## 你的基本信息
- 员工编号：{{WORKER_ID}}
- 运行端口：{{PORT}}
- 引擎类型：external
- 上游地址：{{EXTERNAL_UPSTREAM_URL}}
- 所属团队：Agent Office

## 你的专长
- {{ROLE_DESCRIPTION}}
- 保留原有 agent 的设定、身份与记忆，不改上游配置
- 通过办公室代理接收任务，并在办公室侧附加共享记忆上下文

## 任务执行规范
1. 办公室只做桥接，不改你原本的系统提示、账号或长期记忆
2. 办公室会把共享记忆以任务上下文形式附加后再转发给你
3. 任务实际执行与结果产出，仍由上游原始 agent 完成

## 约束
- 办公室移除你时，只会删除本地代理员工，不会动上游 agent
- 需要上游兼容 `/health` 与 `/tasks` HTTP 协议
- 任务超时时间为 {{TIMEOUT}} 秒


path: templates/hermes_prompt.md
content: 你是团队中的专项员工，职责是{{ROLE}}。

## 你的基本信息
- 员工编号：{{WORKER_ID}}
- 运行端口：{{PORT}}
- 引擎类型：hermes
- 所属团队：Agent Office

## 你的专长
- {{ROLE_DESCRIPTION}}
- 与主会话使用相同的Hermes生态，响应更贴合主会话风格

## 任务执行规范
1. 收到任务后，先理解任务目标
2. 确认是否需要额外信息
3. 执行任务
4. 返回结构化结果：

```json
{
  "task_id": "{task_id}",
  "status": "done | failed",
  "result": {
    "content": "...（实际输出内容）",
    "format": "markdown | json | html | text"
  },
  "summary": "一句话总结"
}
```

## 约束
- 只访问授权目录
- 不外发未授权数据
- 任务超时：{{TIMEOUT}} 秒


path: templates/openclaw_prompt.md
content: 你是团队中的专项员工，职责是{{ROLE}}。

## 你的基本信息
- 员工编号：{{WORKER_ID}}
- 运行端口：{{PORT}}
- 引擎类型：openclaw
- 所属团队：Agent Office

## 你的专长
- {{ROLE_DESCRIPTION}}
- 接收主控发来的任务，执行后返回结构化结果

## 任务执行规范
1. 收到任务后，先理解任务目标
2. 确认是否需要额外信息，有则返回需要什么
3. 执行任务
4. 返回结构化结果：

```json
{
  "task_id": "{task_id}",
  "status": "done | failed",
  "result": {
    "content": "...（实际输出内容）",
    "format": "markdown | json | html | text"
  },
  "summary": "一句话总结完成情况",
  "next_steps": ["可选：建议的后续动作"]
}
```

## 约束
- 只访问主控授权的目录
- 不在未经授权的情况下向外发送数据
- 遇到不确定的情况，先返回问题而不是猜测
- 任务超时时间为 {{TIMEOUT}} 秒


path: templates/stub_prompt.md
content: 你是{{NAME}}，职责：{{ROLE}}。

## 当前模式：stub（模拟员工）

当前引擎未安装或不可用，你以模拟模式运行。
你不会执行真实任务，只会返回预设格式，用于测试流程。

## 返回格式

收到任何任务时，立即返回：

```json
{
  "task_id": "{task_id}",
  "status": "stub",
  "result": {
    "content": "[STUB模式] 本员工尚未就绪，请先安装对应引擎",
    "format": "text"
  },
  "summary": "stub模式占位"
}
```

## 如何升级为真实员工

```bash
pip install openclaw     # openclaw / deerflow 引擎
pip install hermes-ai    # hermes 引擎
```

安装完成后，重新添加该员工即可。


path: tests/test_add_worker.py
content: import importlib.util
import tempfile
import unittest
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "add_worker.py"
SPEC = importlib.util.spec_from_file_location("add_worker_module", MODULE_PATH)
add_worker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(add_worker)


class AddWorkerTests(unittest.TestCase):
    def test_pinyin_uses_expected_worker_id(self):
        self.assertEqual(add_worker.pinyin("小龙"), "xiaolong")
        self.assertEqual(add_worker.pinyin("小D"), "xiaod")

    def test_cli_profile_display_uses_builtin_catalog(self):
        self.assertEqual(add_worker.cli_profile_display("codex"), "OpenAI Codex CLI")
        self.assertEqual(add_worker.cli_profile_display("claude-code"), "Claude Code")

    def test_normalize_external_upstream_url_accepts_port_only(self):
        self.assertEqual(
            add_worker.normalize_external_upstream_url("", port=18750),
            "http://127.0.0.1:18750",
        )
        self.assertEqual(
            add_worker.normalize_external_upstream_url("127.0.0.1:18750"),
            "http://127.0.0.1:18750",
        )

    def test_render_template_replaces_known_tokens(self):
        rendered = add_worker.render_template(
            "Hello {{NAME}} on {{PORT}}",
            {"NAME": "小龙", "PORT": 5011},
        )
        self.assertEqual(rendered, "Hello 小龙 on 5011")

    def test_write_soul_renders_engine_template(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            worker_dir = Path(tmpdir)
            add_worker.write_soul(
                worker_dir=worker_dir,
                name="小龙",
                worker_id="xiaolong",
                role="research",
                engine="openclaw",
                port=5011,
            )
            content = (worker_dir / "SOUL.md").read_text(encoding="utf-8")
            self.assertIn("员工编号：xiaolong", content)
            self.assertIn("运行端口：5011", content)
            self.assertIn("竞品调研、信息收集、事实核查与资料整理", content)

    def test_write_soul_renders_cli_profile(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            worker_dir = Path(tmpdir)
            add_worker.write_soul(
                worker_dir=worker_dir,
                name="小克",
                worker_id="xiaoke",
                role="code",
                engine="cli",
                port=5016,
                cli_profile="claude-code",
            )
            content = (worker_dir / "SOUL.md").read_text(encoding="utf-8")
            self.assertIn("Claude Code", content)
            self.assertIn("xiaoke", content)

    def test_write_soul_renders_external_upstream(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            worker_dir = Path(tmpdir)
            add_worker.write_soul(
                worker_dir=worker_dir,
                name="外挂小龙",
                worker_id="waiguaixiaolong",
                role="general",
                engine="external",
                port=5019,
                external_upstream_url="http://127.0.0.1:18750",
            )
            content = (worker_dir / "SOUL.md").read_text(encoding="utf-8")
            self.assertIn("引擎类型：external", content)
            self.assertIn("http://127.0.0.1:18750", content)


if __name__ == "__main__":
    unittest.main()


path: tests/test_apply_preset.py
content: import importlib.util
import unittest
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "apply_preset.py"
SPEC = importlib.util.spec_from_file_location("apply_preset_module", MODULE_PATH)
apply_preset = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(apply_preset)


class ApplyPresetTests(unittest.TestCase):
    def test_aliases_resolve_to_expected_slug(self):
        self.assertEqual(apply_preset.resolve_preset_name("出版公司"), "publishing-company")
        self.assertEqual(apply_preset.resolve_preset_name("编程公司"), "coding-company")

    def test_load_preset_has_workers(self):
        publishing = apply_preset.load_preset("出版公司")
        coding = apply_preset.load_preset("coding-company")
        self.assertEqual(publishing["slug"], "publishing-company")
        self.assertEqual(coding["slug"], "coding-company")
        self.assertGreaterEqual(len(publishing["workers"]), 4)
        self.assertGreaterEqual(len(coding["workers"]), 5)

    def test_dry_run_returns_planned_workers(self):
        results = apply_preset.apply_preset("出版公司", dry_run=True)
        self.assertTrue(results)
        self.assertTrue(all(item["status"] == "planned" for item in results))


if __name__ == "__main__":
    unittest.main()


path: tests/test_deerflow_runtime.py
content: import importlib.util
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock


MODULE_PATH = Path(__file__).resolve().parents[1] / "deerflow_runtime.py"
SPEC = importlib.util.spec_from_file_location("deerflow_runtime_module", MODULE_PATH)
deerflow_runtime = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(deerflow_runtime)


class DeerflowRuntimeTests(unittest.TestCase):
    def test_missing_prerequisites_reports_absent_commands(self):
        with mock.patch.object(deerflow_runtime.shutil, "which", side_effect=lambda name: None):
            self.assertEqual(
                deerflow_runtime.missing_prerequisites(),
                ["git", "uv"],
            )

    def test_ensure_worker_home_creates_agent_files(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            office_dir = Path(tmpdir) / "office"
            with mock.patch.dict(os.environ, {"HERMES_OFFICE_DIR": str(office_dir)}, clear=False):
                home_dir = deerflow_runtime.ensure_worker_home(
                    "xiaod2",
                    "小D2",
                    "complex",
                    model="gpt-5.4",
                )

            self.assertTrue((home_dir / "agents" / "xiaod2" / "config.yaml").exists())
            soul = (home_dir / "agents" / "xiaod2" / "SOUL.md").read_text(encoding="utf-8")
            self.assertIn("小D2", soul)
            self.assertIn("complex", soul)

    def test_write_worker_runtime_config_includes_workspace_mount(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            office_dir = Path(tmpdir) / "office"
            worker_dir = office_dir / "workers" / "xiaod2"
            workspace_dir = Path(tmpdir) / "workspace"
            worker_dir.mkdir(parents=True, exist_ok=True)
            workspace_dir.mkdir(parents=True, exist_ok=True)
            with mock.patch.dict(os.environ, {"HERMES_OFFICE_DIR": str(office_dir)}, clear=False):
                config_path = deerflow_runtime.write_worker_runtime_config(
                    worker_dir,
                    str(workspace_dir),
                    "xiaod2",
                    "小D2",
                    "complex",
                    model="gpt-5.4",
                    reasoning_effort="medium",
                )

            content = config_path.read_text(encoding="utf-8")
            self.assertIn(str(workspace_dir), content)
            self.assertIn(str(worker_dir), content)
            self.assertIn('container_path: "/mnt/workspace"', content)
            self.assertIn('container_path: "/mnt/worker"', content)
            self.assertNotIn(f'host_path: "{office_dir}"', content)
            self.assertIn('name: "gpt-5.4"', content)
            self.assertIn("deerflow.models.openai_codex_provider:CodexChatModel", content)

    def test_write_worker_runtime_config_keeps_worker_alias_when_workspace_matches(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            office_dir = Path(tmpdir) / "office"
            worker_dir = office_dir / "workers" / "xiaod2"
            worker_dir.mkdir(parents=True, exist_ok=True)
            with mock.patch.dict(os.environ, {"HERMES_OFFICE_DIR": str(office_dir)}, clear=False):
                config_path = deerflow_runtime.write_worker_runtime_config(
                    worker_dir,
                    str(worker_dir),
                    "xiaod2",
                    "小D2",
                    "complex",
                    model="gpt-5.4",
                    reasoning_effort="medium",
                )

            content = config_path.read_text(encoding="utf-8")
            self.assertEqual(content.count(str(worker_dir)), 2)
            self.assertIn('container_path: "/mnt/workspace"', content)
            self.assertIn('container_path: "/mnt/worker"', content)


if __name__ == "__main__":
    unittest.main()


path: tests/test_run_flow.py
content: import importlib.util
import unittest
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "run_flow.py"
SPEC = importlib.util.spec_from_file_location("run_flow_module", MODULE_PATH)
run_flow = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(run_flow)


class RunFlowTests(unittest.TestCase):
    def test_resolve_flow_name(self):
        self.assertEqual(run_flow.resolve_flow_name("流转"), "handoff-review")
        self.assertEqual(run_flow.resolve_flow_name("fanout"), "fanout-synthesis")

    def test_render_template_supports_nested_paths(self):
        context = {
            "input": {"title": "测试", "description": "描述"},
            "steps": {
                "plan": {
                    "content": "先拆解任务",
                    "summary": "拆解完成",
                }
            }
        }
        rendered = run_flow.render_template(
            "标题={{input.title}}; 内容={{steps.plan.content}}",
            context,
        )
        self.assertEqual(rendered, "标题=测试; 内容=先拆解任务")

    def test_parse_slot_assignments(self):
        slots = run_flow.parse_slot_assignments(["planner=产品", "executor=前端"])
        self.assertEqual(slots["planner"], "产品")
        self.assertEqual(slots["executor"], "前端")

    def test_build_combined_content(self):
        combined = run_flow.build_combined_content(
            [
                {"worker_name": "A", "worker_id": "a", "status": "done", "summary": "ok", "content": "内容1"},
                {"worker_name": "B", "worker_id": "b", "status": "done", "summary": "ok2", "content": "内容2"},
            ]
        )
        self.assertIn("[A/a]", combined)
        self.assertIn("内容2", combined)


if __name__ == "__main__":
    unittest.main()


path: tests/test_worker_server.py
content: import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from urllib import error


MODULE_PATH = Path(__file__).resolve().parents[1] / "worker_server.py"
SPEC = importlib.util.spec_from_file_location("worker_server_module", MODULE_PATH)
worker_server = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(worker_server)


class WorkerServerTests(unittest.TestCase):
    def test_memory_command_supports_python_script_and_binary(self):
        py_cmd = worker_server._memory_command("/tmp/cli.py", "query", "hello")
        self.assertEqual(py_cmd[1:], ["/tmp/cli.py", "query", "hello"])

        bin_cmd = worker_server._memory_command("/usr/local/bin/memory-cli", "query", "hello")
        self.assertEqual(bin_cmd, ["/usr/local/bin/memory-cli", "query", "hello"])

    def test_build_prompt_uses_task_title_argument(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_id = "xiaolong"
        server.worker_name = "小龙"
        server.worker_role = "research"
        prompt = server._build_prompt("research", "竞品分析", "分析 A 和 B", [])
        self.assertIn("任务标题：竞品分析", prompt)
        self.assertIn("任务描述：分析 A 和 B", prompt)

    def test_parse_openclaw_result_accepts_log_prefixed_json(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        raw = '[info] running\n{"result":{"payloads":[{"text":"done"}]}}'
        self.assertEqual(server._parse_openclaw_result(raw), "done")

    def test_run_openclaw_uses_worker_id_not_display_name(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_id = "xiaolong"
        server.worker_name = "小龙"
        server.workspace_dir = "/tmp"

        completed = mock.Mock(returncode=0, stdout='{"result":{"payloads":[{"text":"ok"}]}}', stderr="")
        with mock.patch.object(worker_server.subprocess, "run", return_value=completed) as run_mock:
            result = server._run_openclaw("hello")

        self.assertEqual(result, "ok")
        args = run_mock.call_args.args[0]
        self.assertEqual(args[:4], ["openclaw", "agent", "--agent", "xiaolong"])

    def test_build_cli_command_supports_arg_profile(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.cli_cmd = ""
        server.cli_args = "--model sonnet"
        with mock.patch.object(worker_server, "get_cli_profile", return_value={
            "display_name": "Aider",
            "executables": ("aider",),
            "base_args": (),
            "prompt_mode": "arg",
            "prompt_flag": "--message",
            "timeout": 600,
        }):
            with mock.patch.object(worker_server, "shutil") as shutil_mock:
                shutil_mock.which.return_value = "/usr/local/bin/aider"
                cmd = worker_server.WorkerServer._build_cli_command(
                    server,
                    worker_server.get_cli_profile("aider"),
                    "fix bug",
                )
        self.assertEqual(cmd[:3], ["/usr/local/bin/aider", "--model", "sonnet"])
        self.assertEqual(cmd[-2:], ["--message", "fix bug"])

    def test_build_cli_command_uses_codex_exec_defaults(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.cli_cmd = ""
        server.cli_args = "--model gpt-5.4"

        with mock.patch.object(worker_server, "shutil") as shutil_mock:
            shutil_mock.which.return_value = "/usr/local/bin/codex"
            cmd = worker_server.WorkerServer._build_cli_command(
                server,
                worker_server.get_cli_profile("codex"),
                "hello cli",
            )

        self.assertEqual(
            cmd,
            [
                "/usr/local/bin/codex",
                "exec",
                "--skip-git-repo-check",
                "--model",
                "gpt-5.4",
            ],
        )

    def test_run_cli_uses_stdin_profile(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.cli_profile = "codex"
        server.cli_cmd = ""
        server.cli_args = ""
        server.cli_timeout = 123
        server.workspace_dir = "/tmp"

        completed = mock.Mock(returncode=0, stdout="cli ok", stderr="")
        with mock.patch.object(worker_server, "shutil") as shutil_mock:
            shutil_mock.which.return_value = "/usr/local/bin/codex"
            with mock.patch.object(worker_server.subprocess, "run", return_value=completed) as run_mock:
                result = worker_server.WorkerServer._run_cli(server, "hello cli")

        self.assertEqual(result, "cli ok")
        self.assertEqual(
            run_mock.call_args.args[0],
            ["/usr/local/bin/codex", "exec", "--skip-git-repo-check"],
        )
        self.assertEqual(run_mock.call_args.kwargs["input"], "hello cli")

    def test_build_deerflow_prompt_includes_shared_memory(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_id = "xiaod2"
        server.worker_name = "小D2"
        server.worker_role = "complex"
        prompt = server._build_deerflow_prompt(
            "大任务拆解",
            "请给出方案和风险",
            [
                {
                    "title": "派单规则",
                    "worker": "manager",
                    "room": "office",
                    "summary_text": "复杂任务优先交给 DeerFlow 团队",
                }
            ],
        )
        self.assertIn("小D2", prompt)
        self.assertIn("复杂任务优先交给 DeerFlow 团队", prompt)
        self.assertIn("deerflow_team", prompt)

    def test_run_deerflow_uses_embedded_runtime_runner(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_id = "xiaod2"
        server.worker_name = "小D2"
        server.worker_role = "complex"
        server.workspace_dir = "/tmp"
        server.deerflow_runtime_python = __file__
        server.deerflow_config = __file__
        server.deerflow_home = tempfile.gettempdir()
        server.deerflow_agent_name = "xiaod2"
        server.deerflow_model = "gpt-5.4"
        server.deerflow_reasoning_effort = "medium"
        server.deerflow_recursion_limit = 48
        server.deerflow_timeout = 300

        completed = mock.Mock(
            returncode=0,
            stdout=json.dumps(
                {
                    "team_summary": "团队已处理",
                    "current_scope": ["拆分任务"],
                    "next_step_suggestions": ["继续推进"],
                    "handoff_note": "已回传",
                    "delegation_trace": ["subagent started: audit"],
                },
                ensure_ascii=False,
            ),
            stderr="",
        )
        with mock.patch.object(worker_server.subprocess, "run", return_value=completed) as run_mock:
            result = server._run_deerflow("大任务", "请拆解", [])

        self.assertIn("团队已处理", result)
        self.assertIn("subagent started: audit", result)
        cmd = run_mock.call_args.args[0]
        self.assertEqual(Path(cmd[1]), worker_server.DEERFLOW_RUNTIME_RUNNER)
        self.assertIn("--config", cmd)

    def test_build_external_description_includes_shared_memory(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_name = "外挂小龙"
        description = server._build_external_description(
            "同步办公室状态",
            "看看最近任务",
            [
                {
                    "title": "办公室规则",
                    "worker": "manager",
                    "room": "office",
                    "summary_text": "优先参考共享记忆，不改上游员工设定",
                }
            ],
        )
        self.assertIn("外挂小龙", description)
        self.assertIn("同步办公室状态", description)
        self.assertIn("优先参考共享记忆", description)

    def test_run_external_submits_and_polls_upstream_worker(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_name = "外挂小龙"
        server.external_upstream_url = "http://127.0.0.1:18750"
        server.external_timeout = 20
        server.external_poll_interval = 0

        with mock.patch.object(
            worker_server.WorkerServer,
            "_external_request",
            side_effect=[
                (202, {"task_id": "up-1", "status": "pending"}),
                (
                    200,
                    {
                        "task_id": "up-1",
                        "status": "done",
                        "result": {"content": "上游完成", "format": "markdown"},
                    },
                ),
            ],
        ) as request_mock:
            result = server._run_external(
                "接入测试",
                "请返回当前状态",
                [{"summary_text": "共享记忆：最近有新任务"}],
            )

        self.assertEqual(result, "上游完成")
        first_call = request_mock.call_args_list[0]
        self.assertEqual(first_call.args[:2], ("POST", "/tasks"))
        forwarded_description = first_call.args[2]["description"]
        self.assertIn("共享记忆：最近有新任务", forwarded_description)

    def test_run_external_handles_upstream_http_error(self):
        server = worker_server.WorkerServer.__new__(worker_server.WorkerServer)
        server.worker_name = "外挂小龙"
        server.external_upstream_url = "http://127.0.0.1:18750"
        server.external_timeout = 20
        server.external_poll_interval = 0

        with mock.patch.object(
            worker_server.WorkerServer,
            "_external_request",
            side_effect=error.HTTPError(
                url="http://127.0.0.1:18750/tasks",
                code=500,
                msg="boom",
                hdrs=None,
                fp=None,
            ),
        ):
            result = server._run_external("接入测试", "请返回当前状态", [])

        self.assertIn("HTTP 500", result)


if __name__ == "__main__":
    unittest.main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)