diff --git a/.gitignore b/.gitignore
index 00741cb06..5faf2630e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,17 @@
.DS_Store
-.idea/
\ No newline at end of file
+.idea/
+firmware/main/apps/app_agent_tasks/agent_tasks_local_defaults.h
+firmware/build-host-tests/
+
+# 本机网关环境、运行记录和构建缓存
+.env
+.env.*
+!.env.example
+__pycache__/
+*.pyc
+.venv/
+.pytest_cache/
+.ruff_cache/
+*.egg-info/
+captures/
+/user-defaults.toml
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..db1569158
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,19 @@
+# StackChan 官方固件与 Agent 看板
+
+本项目以 `m5stack/StackChan` 为固件基础。`firmware/` 保持官方 ESP-IDF 6.1、Mooncake、HAL 与 Setup 架构;`gateway/` 是来自 `kisaragi-mochi/stackchan-mcp` 的独立 Python 进程,保留上游许可证。
+
+## 架构与检查
+
+- Codex/Claude 适配器经 MCP 写入网关快照;设备每秒通过 Wi-Fi HTTP 读取 `/agent-dashboard`。
+- 网关其它 WebSocket 设备控制功能依赖其上游替代固件,不代表本项目官方固件支持这些功能。
+- 网关检查:`cd gateway && uv run --locked pytest && uv run --locked ruff check .`。
+- 固件宿主检查:`cmake -S firmware/tests -B firmware/build-host-tests && cmake --build firmware/build-host-tests && ctest --test-dir firmware/build-host-tests --output-on-failure`。
+- 固件构建、设备配置及已验收范围见 `docs/agent-tasks-setup.zh-CN.md`、`docs/agent-tasks-validation.zh-CN.md`。
+- 面向单用户家庭局域网,优先验证实机主要路径;审查建议应友好,样式问题标为非阻断。
+- 不提交 `.env`、令牌、本机默认头文件、会话数据、设备备份或构建产物;日志不输出令牌或聊天正文。
+
+## StackChan 待办同步
+
+本机已安装 Agent 看板时,Codex 主会话在制定、调整、完成工作计划时同步短标题与状态。使用 `~/.local/share/stackchan-agent-dashboard/.venv/bin/python -m stackchan_mcp.agent_adapters.runner codex-plan`,通过 stdin 提交 JSON:`{"stage":"实现","plan":[{"step":"短任务标题","status":"in_progress"}]}`。会话 ID 使用 `CODEX_THREAD_ID`,最多 12 项,状态为 `pending`、`in_progress`、`completed`;缺少命令或会话 ID 时继续工作,不伪造 ID。
+
+监视器负责合并计划与生命周期后经 MCP 上报;不要同时用完整快照覆盖同栏。子 Agent 不提交主栏计划。只提交短标题和状态,不上传提示词、正文、路径、工具参数或推理内容。
diff --git a/README.md b/README.md
index 9cf3f7f8a..01f42bcc0 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,62 @@
+# StackChan · Agent 待办看板
+
+基于 M5Stack 官方固件扩展:在独立 `AGENT TASKS` 菜单中,左侧显示 Codex、右侧显示 Claude 的状态、阶段与待办,顶部独立显示完成百分比,支持进行中优先排序、转圈图标和动态省略号。
+
+- [中文安装与使用说明](docs/agent-tasks-setup.zh-CN.md)
+- [验证记录与已知限制](docs/agent-tasks-validation.zh-CN.md)
+- `firmware/`:官方 ESP-IDF / Mooncake 固件及看板 App。
+- `gateway/`:独立 Python MCP 网关与 Agent 适配器,读取本机会话状态,经 MCP 更新快照。
+- `scripts/`:macOS 常驻服务安装与本机固件默认配置脚本。
+
+设备通过 Wi-Fi HTTP 拉取快照;网关的其它 WebSocket 设备控制功能面向其上游替代固件,本项目官方固件不提供那些设备控制功能。公开源码不含个人令牌。网关源码及第三方许可见 [gateway/LICENSE](gateway/LICENSE) 与 [gateway/LICENSE-THIRD-PARTY](gateway/LICENSE-THIRD-PARTY)。
+
+
+
+## 让 Codex 和 Claude 在设备上显示进度
+
+### 1. 启动共享网关
+
+在 macOS 上安装 Python 3.12 和 `uv`,从仓库根目录运行以下命令,并将 IP 换成电脑当前的局域网 IPv4 地址:
+
+```bash
+python3 scripts/install-agent-dashboard-macos.py --host-ip 192.168.1.20
+```
+
+安装器会启动一个共享 MCP 网关,以及 Codex、Claude 的本机会话监视器。设备连接地址和令牌保存在 `~/.config/stackchan-agent-dashboard/设备连接信息.txt`。在 StackChan 上打开 `SETUP` → `Apps` → `Agent 看板`,填写其中的地址和令牌,测试并保存;设备需先连接同一局域网 Wi-Fi。详细的手动启动、网关地址变化和固件配置方法见[中文配置说明](docs/agent-tasks-setup.zh-CN.md)。
+
+### 2. 提交 Codex 待办
+
+Codex 监视器自动报告桌面主会话的工作状态;桌面目前没有可用的原生计划清单事件,因此 Codex 需要把当前计划显式提交给看板。使用主会话的 `CODEX_THREAD_ID`,通过仓库的 `AGENTS.md` 约定调用:
+
+用户可以先对 Codex 说:
+
+> 请把这项工作拆成不超过 12 条短待办,开始执行前同步计划;每当任务开始、完成或计划变化时,都更新状态并按本仓库 AGENTS.md 的 StackChan 待办同步约定提交完整清单。只同步任务标题和状态。
+
+```bash
+~/.local/share/stackchan-agent-dashboard/.venv/bin/python \
+ -m stackchan_mcp.agent_adapters.runner codex-plan <<'JSON'
+{"stage":"实现看板","plan":[{"step":"完成界面","status":"completed"},{"step":"验证设备显示","status":"in_progress"},{"step":"更新使用文档","status":"pending"}]}
+JSON
+```
+
+每次提交的是该会话的完整待办清单,最多 12 项;状态使用 `pending`、`in_progress`、`completed`。计划变化时提交更新后的清单。不要使用子 Agent 的会话 ID。
+
+### 3. 让 Claude 创建任务
+
+Claude 监视器读取本机 Claude Code 主会话记录。Claude 成功执行 `TaskCreate`、`TaskUpdate` 或 `TodoWrite` 后,监视器会同步任务标题和状态;结束回合后保留最后清单。用户可以对 Claude 说:
+
+> 请先用任务清单把工作拆成不超过 12 项;执行时使用任务工具创建待办,并在开始、完成或调整任务时及时更新状态,这样我可以在 StackChan 上查看进度。
+
+没有任务工具记录时,右栏仍会显示状态和阶段,但任务区为空。
+
+### 设备显示内容
+
+打开 Launcher 中的 `AGENT TASKS`:左栏为 Codex,右栏为 Claude。每栏展示 Agent 状态、当前阶段和最多 12 项任务,进行中的任务排在前面;栏标题右侧独立显示该 Agent 的整数完成百分比,计算方式为“已完成数 ÷ 当前清单总数”,无任务时显示 `--%`。工作时会显示转圈和动态省略号。网关或 Wi-Fi 暂时断开时,设备保留最近一次成功取得的快照。
+
+看板只接收短阶段、任务标题、状态和会话生命周期,不上传提示词、对话正文、文件路径或工具参数。Codex 的原生计划事件与 Claude 新会话任务采集限制,以及设备验证记录,见[看板配置与验证文档](docs/agent-tasks-setup.zh-CN.md)和[验证记录](docs/agent-tasks-validation.zh-CN.md)。
+
+以下保留官方项目介绍。
+
# StackChan Open-Source
diff --git a/docs/178-daemon-setup.md b/docs/178-daemon-setup.md
new file mode 100644
index 000000000..2a8209430
--- /dev/null
+++ b/docs/178-daemon-setup.md
@@ -0,0 +1,151 @@
+# Issue #178 Daemon Setup
+
+The gateway now has two supported MCP server modes:
+
+- **stdio mode**: `stackchan-mcp` with no subcommand. This is the original
+ single-client mode used by existing MCP client configs.
+- **daemon mode**: `stackchan-mcp serve --transport streamable-http`. This
+ starts one long-lived gateway process, keeps one ESP32 WebSocket connection,
+ and accepts multiple MCP clients over Streamable HTTP.
+
+Use daemon mode when more than one MCP client or automation needs to share the
+same StackChan device. Keep stdio mode for simple local setups, compatibility
+testing, and existing clients that do not yet support Streamable HTTP.
+
+## Start the daemon
+
+```bash
+cd gateway
+uv run stackchan-mcp serve --transport streamable-http
+```
+
+The daemon starts the same ESP32 WebSocket listener and capture server as stdio
+mode, then exposes the MCP endpoint at:
+
+```text
+http://127.0.0.1:8767/mcp
+```
+
+The zero-subcommand stdio entry point remains supported and unchanged:
+
+```bash
+stackchan-mcp
+```
+
+`stackchan-mcp serve --transport stdio` is equivalent to the zero-subcommand
+stdio path.
+
+## Environment variables
+
+| Variable | Default | Purpose |
+|---|---:|---|
+| `STACKCHAN_TOKEN` | unset | Bearer token shared with the ESP32 firmware and, when set, required by HTTP MCP clients. |
+| `BEARER_TOKEN` | unset | Legacy alias used only when `STACKCHAN_TOKEN` is unset. |
+| `MCP_HTTP_HOST` | `127.0.0.1` | Bind host for the Streamable HTTP MCP endpoint. |
+| `MCP_HTTP_PORT` | `8767` | Bind port for the Streamable HTTP MCP endpoint. |
+| `MCP_HTTP_ALLOWED_HOSTS` | unset | Comma-separated Host / Origin allowlist for non-loopback clients, for example `192.168.1.10,stackchan.local:8767`. |
+| `STACKCHAN_COMMAND_QUEUE_SIZE` | `32` | Maximum queued ESP32-bound tool calls before the daemon returns backpressure. |
+
+The existing gateway variables (`HOST`, `WS_PORT`, `CAPTURE_PORT`,
+`VISION_HOST`, `VISION_URL`, `VISION_TOKEN`, and audio hook settings) keep the
+same meaning in daemon mode.
+
+## Bind safety
+
+Loopback binds (`127.0.0.1`, `::1`, or `localhost`) may run without an HTTP
+bearer token for local development.
+
+Non-loopback binds, such as `0.0.0.0` or a LAN IP address, require
+`STACKCHAN_TOKEN` or `BEARER_TOKEN`. Startup is refused otherwise:
+
+```text
+stackchan-mcp: refusing non-loopback MCP_HTTP_HOST without STACKCHAN_TOKEN or BEARER_TOKEN
+```
+
+When a token is configured, every request to `/mcp` and `/status` must include:
+
+```text
+Authorization: Bearer
+```
+
+`/healthz` is intentionally unauthenticated so local process monitors can check
+whether the daemon is alive. Host and Origin headers are still validated on all
+daemon endpoints.
+
+For wildcard binds such as `MCP_HTTP_HOST=0.0.0.0`, loopback Host values
+(`127.0.0.1`, `localhost`, `::1`) are accepted by default. LAN clients usually
+send the machine's LAN IP or DNS name in the `Host` header, so add those values
+to `MCP_HTTP_ALLOWED_HOSTS`:
+
+```bash
+STACKCHAN_TOKEN=your-secret-token-here \
+MCP_HTTP_HOST=0.0.0.0 \
+MCP_HTTP_ALLOWED_HOSTS=192.168.1.10,stackchan.local:8767 \
+stackchan-mcp serve --transport streamable-http
+```
+
+## MCP client configuration
+
+Use a Streamable HTTP MCP client pointed at `/mcp`. Example
+`mcp.config.json`:
+
+```json
+{
+ "mcpServers": {
+ "stackchan-mcp": {
+ "type": "streamable-http",
+ "url": "http://127.0.0.1:8767/mcp",
+ "headers": {
+ "Authorization": "Bearer your-secret-token-here"
+ }
+ }
+ }
+}
+```
+
+If no `STACKCHAN_TOKEN` or `BEARER_TOKEN` is configured and the daemon is bound
+to loopback, omit the `headers` block.
+
+## Operational endpoints
+
+`GET /healthz` returns daemon-local health:
+
+```json
+{
+ "ok": true,
+ "esp32_connected": true,
+ "queue_depth": 0,
+ "queue_capacity": 32,
+ "owner_id": "stackchan-mcp-1234abcd"
+}
+```
+
+`GET /status` returns the same gateway status as the `get_status` MCP tool,
+plus `queue_depth`, `queue_capacity`, and `connected_clients`.
+
+## Migration notes
+
+Existing stdio configs do not need to change. The following remains valid:
+
+```json
+{
+ "mcpServers": {
+ "stackchan-mcp": {
+ "type": "stdio",
+ "command": "stackchan-mcp"
+ }
+ }
+}
+```
+
+To migrate, start the daemon as an independent process first, then point each
+Streamable HTTP-capable MCP client at `http://127.0.0.1:8767/mcp`. Once a
+client uses the daemon, it should not also spawn `stackchan-mcp` in stdio mode
+for the same device, because the daemon is the process that owns the ESP32
+WebSocket.
+
+The daemon keeps a single bounded FIFO command queue for ESP32-bound tool
+calls. `get_status` bypasses the queue because it reads gateway-local state.
+When the queue is full, the daemon returns a JSON-RPC error with code `-32000`
+and message `stackchan command queue is full`; it does not buffer commands
+without a limit.
diff --git a/docs/agent-tasks-setup.zh-CN.md b/docs/agent-tasks-setup.zh-CN.md
new file mode 100644
index 000000000..0eb94691a
--- /dev/null
+++ b/docs/agent-tasks-setup.zh-CN.md
@@ -0,0 +1,137 @@
+# Agent 待办看板配置说明
+
+看板左栏显示 Codex,右栏显示 Claude。StackChan 通过 HTTP 读取 `stackchan-mcp` 网关的状态快照;网关地址和 Bearer 令牌在设备设置页配置。界面显示状态、阶段和任务标题,不显示提示词、对话正文、文件路径或工具参数。
+
+## 启动共享网关
+
+本仓库 `gateway/` 包含已扩展的独立 Python MCP 网关、Codex/Claude 适配器与测试。上游为 [kisaragi-mochi/stackchan-mcp](https://github.com/kisaragi-mochi/stackchan-mcp);请从本仓库安装,PyPI 的上游版本未必包含看板接口。
+
+### macOS 自动启动
+
+先安装 Python 3.12 与 `uv`,在本仓库根目录执行(替换为电脑实际局域网 IPv4):
+
+```bash
+python3 scripts/install-agent-dashboard-macos.py --host-ip 192.168.1.20
+```
+
+安装器依据 `gateway/uv.lock` 安装到 `~/.local/share/stackchan-agent-dashboard/.venv`,注册网关、Codex、Claude 三个 LaunchAgent。随机令牌保存在 `~/.config/stackchan-agent-dashboard/设备连接信息.txt`(仅本人可读写);重新安装保留令牌。日志在 `~/Library/Logs/StackChanAgentDashboard/`。修改源码或电脑 IP 后重新运行安装器,更新设备配置。
+
+安装器不修改 Codex 或 Claude 的全局配置,也不发起模型请求。停止服务:
+
+```bash
+launchctl bootout gui/$(id -u)/io.stackchan.agent-dashboard.codex
+launchctl bootout gui/$(id -u)/io.stackchan.agent-dashboard.claude
+launchctl bootout gui/$(id -u)/io.stackchan.agent-dashboard.gateway
+```
+
+如需永久取消登录启动,再移除对应的 `~/Library/LaunchAgents/io.stackchan.agent-dashboard.*.plist`。
+
+### 手动启动
+
+在仓库 `gateway/` 中执行以下命令(适用于 zsh;令牌需提前生成并在设备与客户端保持一致):
+
+```bash
+cd gateway
+export MCP_HTTP_HOST=0.0.0.0 MCP_HTTP_PORT=8767
+export MCP_HTTP_ALLOWED_HOSTS='192.168.1.20'
+export HOST=127.0.0.1 WS_PORT=18765 CAPTURE_PORT=18766
+read -s 'STACKCHAN_TOKEN?输入网关令牌:'
+export STACKCHAN_TOKEN
+uv run --locked stackchan-mcp serve --transport streamable-http --no-mdns
+```
+
+Codex 和 Claude Code 应连接同一个网关的 `http://<电脑局域网地址>:8767/mcp`,并使用相同 Bearer 令牌。StackChan 使用 `http://<电脑局域网地址>:8767` 作为基础地址,读取受令牌保护的 `GET /agent-dashboard`。不要把网关暴露到公网,也不要将令牌提交到仓库。
+
+## 配置 StackChan
+
+公开源码不包含个人网关地址或令牌。设备连上 Wi-Fi 后,在 Launcher 的 `SETUP` → `Apps` → `Agent 看板` 中分别设置地址和访问令牌,点“测试”确认连接,再点“保存”。地址只填写基础 URL,例如 `http://192.168.1.20:8767`,不要附加 `/mcp` 或 `/agent-dashboard`。令牌必须与网关的 `STACKCHAN_TOKEN` 相同。已有个人定制固件也可能预置本机默认值;默认值不会随本仓库发布。
+
+
+设备应与网关处于可互相访问的网络。`localhost` 指设备本身,不能用来连接电脑。网关快照端点为 `GET /agent-dashboard`,须带相同 Bearer 令牌。
+
+### 可选:生成本机固件默认值
+
+macOS 安装器执行后,可在本仓库根目录运行:
+
+```bash
+python3 scripts/configure-agent-dashboard-firmware.py
+```
+
+脚本读取本机配置,生成权限为 `0600` 的 `firmware/main/apps/app_agent_tasks/agent_tasks_local_defaults.h`,不打印令牌。该文件被 Git 忽略;随后重新构建的个人固件会回填空配置,保留 NVS 中已有非空值。没有此文件也可正常构建,通过设备设置页配置即可。含默认令牌的个人固件也应仅供本人使用。
+
+## 状态和待办数据
+
+每栏顶部名称右侧以小号数字显示该 Agent 的完成百分比,例如 `67%`;按当前同步清单中的已完成任务数除以任务总数计算,四舍五入为整数,进行中不计为已完成。没有任务时显示 `--%`。Codex 与 Claude 独立计算,百分比不代表模型内部运行进度。
+
+每栏按“进行中 → 待办 → 已完成”稳定排序,同状态保持来源顺序。Claude 的任务超过 12 项时,先排序再选择可见任务,避免正在执行的任务被截掉。工作且在线时显示转圈、动态省略号与进行中标记轻微闪动;等待输入时显示动态省略号;完成、离线或过期后停止动画。
+
+
+Agent 通过 MCP 工具 `agent_update_snapshot` 更新自己的状态。任务按 pending、in_progress、completed 显示;Agent 状态包括 idle、working、waiting、completed、failed。网关在 45 秒未收到心跳后将来源标记为过期,仍保留最后任务。
+
+Codex Desktop rollout 监视器是可选的本机实验适配器。它仅处理 `originator` 明确为 `Codex Desktop` 的 rollout,读取回合开始、完成、中断事件,不读消息正文。启动方式:
+
+```bash
+cd /path/to/StackChan/gateway
+export STACKCHAN_MCP_URL=http://127.0.0.1:8767/mcp
+read -s 'STACKCHAN_TOKEN?输入与网关相同的令牌:'
+export STACKCHAN_TOKEN
+uv run python -m stackchan_mcp.agent_adapters.runner codex-watch
+```
+
+已检查的真实 Codex Desktop rollout 含 `task_started`、`task_complete`、`turn_aborted` 事件,但未包含计划清单。适配器自动汇报工作状态;待办使用下面的显式计划入口。`CODEX_SESSIONS_DIR` 可指定其他本机会话目录。使用 macOS 安装器后无需重复启动该监视器。
+
+### Codex 计划同步
+
+本仓库 AGENTS.md 已要求 Codex 主会话在计划变化时同步待办。其他项目可把同一段“StackChan 待办同步”说明加入自己的 AGENTS.md。以下命令应从需要展示的 Codex 主会话执行,自动采用该会话的 `CODEX_THREAD_ID`:
+
+```bash
+~/.local/share/stackchan-agent-dashboard/.venv/bin/python \
+ -m stackchan_mcp.agent_adapters.runner codex-plan <<'JSON'
+{"stage":"设备验证","plan":[{"step":"完成固件构建","status":"completed"},{"step":"验证实机显示","status":"in_progress"},{"step":"验证蓝牙连接","status":"pending"}]}
+JSON
+```
+
+每次提交替换此会话的完整待办,最多 12 项;空列表清空。也可显式传 `session_id`,必须是需要展示的真实 Codex 主会话 ID。计划在本机以仅用户可读写的文件原子保存,监视器约 0.5 秒内读取并经 MCP 推送;设备再按 1 秒周期拉取。计划与自动生命周期使用同一个上报来源,心跳不会清空任务;其他会话不继承此清单。已结束回合显示“已完成”,未完成的任务仍按原状态保留。
+
+这依赖 Codex 按项目说明主动同步计划,不是从聊天正文猜测任务,也不是已验证的桌面原生计划事件订阅。
+
+Claude Code 使用本地 transcript 增量监视器,无需安装 Hook:
+
+```bash
+cd /path/to/StackChan/gateway
+export STACKCHAN_MCP_URL=http://127.0.0.1:8767/mcp
+# STACKCHAN_TOKEN 与网关一致
+uv run python -m stackchan_mcp.agent_adapters.runner claude-watch
+```
+
+默认读取 `~/.claude/projects/*/*.jsonl`,可用 `CLAUDE_PROJECTS_DIR` 改目录。只展示最近活动的主会话,排除子 Agent;成功的 TaskCreate、TaskUpdate、TodoWrite 结果更新任务,结束回合保留清单。仅读取任务标题、状态和生命周期,不上传聊天正文、任务描述或工具参数。没有创建任务的会话显示“暂无待办任务”。`online` 表示监视器心跳正常,回放的已结束会话仍显示“已完成”,不代表模型正在执行。
+
+Claude 的既有会话回放已通过监视器 → MCP → HTTP 验证,包括 12 项任务。请使用自己已安装的 Claude Code 正常工作;新会话需实际调用任务工具后才会有待办。新 Claude 会话实时任务生成仍待单独验收。使用 macOS 安装器后无需重复启动 transcript 监视器。
+
+每栏采用完整快照;请为一个 Agent 只选择一个上报来源。若会话需手动调用 `agent_update_snapshot`,先停止对应监视器,以免监视器的下一次心跳覆盖手动待办。`claude-hook` 是保留的实验入口,当前推荐 transcript 方案。
+
+## 验证与限制
+
+- 网关需保持运行;Agent 和设备须能访问同一个服务。
+- Codex 状态自动采集;待办由 Codex 显式计划同步入口提供,桌面原生计划事件仍未观察到。
+- Claude 没有任务工具记录时,右栏清单为空;不会用旧会话任务填充新会话。
+- StackChan 蓝牙广播不用于此功能;设备使用 Wi-Fi HTTP 读取看板。
+- 网关或 Wi-Fi 暂时不可用时,设备保留最近一次成功取得的快照。
+
+## 官方固件构建
+
+使用本仓库 `firmware/` 的官方固件,目标为 ESP-IDF 6.1、BOARD_TYPE_M5STACK_STACK_CHAN、Quad PSRAM。先加载 ESP-IDF 6.1 环境,再执行:
+
+```bash
+cd firmware
+python3 fetch_repos.py
+idf.py build
+```
+
+生成 `firmware/build/stack-chan.bin`。不要使用 stackchan-mcp 上游仓库中的替代固件。
+
+应用镜像写入 `0x20000`,大小必须小于 `0x4f0000`;常规刷写保留 NVS、分区表和 otadata。设备配置使用独立 NVS 命名空间 agent_tasks。若复位日志仍显示 DOWNLOAD(USB/UART0),须松开下载按键并通过实体电源键关机再开机;镜像校验成功不代表已启动。
+
+看板使用 SIL OFL 思源黑体衍生的 14 px 常用中文字库,支持 GB2312 常用字符。罕见汉字和 Emoji 建议改写为常用中英文。
+
+界面布局预览(示例任务,非设备实拍):
diff --git a/docs/agent-tasks-source.zh-CN.md b/docs/agent-tasks-source.zh-CN.md
new file mode 100644
index 000000000..ebf0eb848
--- /dev/null
+++ b/docs/agent-tasks-source.zh-CN.md
@@ -0,0 +1,9 @@
+# 来源与整合说明
+
+此项目以 [M5Stack/StackChan](https://github.com/m5stack/StackChan) 的 `28b9f0c8bd6acd81cb3c70f1a93c3e373b39d400` 为基础,增加官方 Mooncake Agent Tasks 应用。
+
+`gateway/` 来自 [kisaragi-mochi/stackchan-mcp](https://github.com/kisaragi-mochi/stackchan-mcp),附带本项目的 Agent 快照 HTTP 接口、Codex/Claude 适配器及回归测试。网关保持独立 Python 进程,不与固件静态链接。上游许可证完整保留在 `gateway/LICENSE` 与 `gateway/LICENSE-THIRD-PARTY`。
+
+本次整合不包含上游替代固件的 SCServo_lib 文件,也不以替代固件覆盖官方固件。早期替代固件实验与原网关工作区保存在本机整合前备份中;当前发布只保留官方固件实施路径。
+
+`scripts/install-agent-dashboard-macos.py` 将当前源码安装到独立运行目录;`scripts/configure-agent-dashboard-firmware.py` 可从本机服务配置生成被 Git 忽略的个人默认头文件。仓库不包含个人令牌、会话记录、设备备份、编译产物或平台虚拟环境。
diff --git a/docs/agent-tasks-validation.zh-CN.md b/docs/agent-tasks-validation.zh-CN.md
new file mode 100644
index 000000000..caf00705c
--- /dev/null
+++ b/docs/agent-tasks-validation.zh-CN.md
@@ -0,0 +1,36 @@
+# Agent 待办看板验证记录
+
+## 已完成验证
+
+- 基于官方 m5stack/StackChan 1.6.2 新增 Mooncake Agent Tasks App,目标板为 M5STACK_STACK_CHAN、Quad PSRAM。
+- 官方 ESP-IDF 6.1 构建成功,应用镜像小于 0x4f0000 分区限制。
+- 网关测试 914 passed、5 skipped,Ruff 通过;官方固件宿主 CTest 2/2 通过。
+- MCP ClientSession、Codex Desktop 监视器和 Claude transcript 监视器均已验证能经 MCP 更新快照,并由 HTTP 接口按 Codex、Claude 顺序返回。
+- Codex 显式计划、Claude 最多 12 项任务、心跳续期和过期行为均已通过网关侧验证。
+- StackChan 已刷入新固件并正常启动。用户确认 AGENT TASKS 双栏任务和动画正常,默认连接配置已在本机固件完成实机验收。
+- 中文字体支持 ASCII 与 GB2312 常用字符;字体及许可随源码附带。
+
+## 实机剩余项
+
+用户已确认设备正常进入头像或菜单,之后在 AGENT TASKS 中看到双栏任务与动画。该确认覆盖基本实机路径;滚动、返回头像和网关断线恢复尚未单独验收。
+
+## 2026-11-12 默认配置、排序与动画更新
+
+- 最终本机镜像为 4,292,768 字节,应用分区余量 17%;刷写与独立校验通过,未改写 NVS、otadata 或分区表。
+- 默认配置通过 NVS 替身验证:缺少配置时使用本机默认值并持久化,已有非空值保留。本机默认配置文件由 Git 忽略,不包含在公开源码中。
+- 双栏快照稳定排序回归测试与任务更新后重新排序通过;Claude 第十三项任务变为进行中时能进入可见清单。生产快照确认两栏状态顺序正确。
+- 网关完整测试:914 passed、5 skipped;Ruff 通过。官方宿主 CTest 2/2 通过。
+- 用户在新固件打开 AGENT TASKS 后明确确认“双栏任务和动画正常”,默认连接与动画实机验收通过。动画预览 GIF 为设计示意,不是实机录像。
+- 实机验收通过双栏、任务排序、默认连接和动画;滚动、返回头像与网关断线恢复仍待单独检查。
+
+
+## 2026-11-12 首页上滑修复与顶部独立百分比
+
+- 定位 AI AGENT 任务在 LVGL 全局锁内休眠 100 ms,改为不阻塞触摸处理的头像更新节流;导航约每 20 ms 检测。
+- 公共底边手势起触范围从 20 px 扩到 40 px,向上位移至少 40 px 即可单次触发,清理首页按钮的过期隐藏标志。
+- 看板导航与数据更新分开(约 33 ms / 500 ms),只在实际改变时设置标记透明度。
+- 首次优化固件刷入后,用户明确确认“返回顺畅,百分比显示正常”。
+- 最终按用户要求将 Codex / Claude 完成度分别放到顶部名称右侧,仅显示小号整数百分比;取消整体底栏与进度条,恢复任务列表高度。无任务显示 `--%`,比例按各自当前同步清单计算。
+- 宿主 CTest 3/3 通过,覆盖比例、取整、空任务、上滑与误触过滤;ESP-IDF 6.1 构建通过。
+- 最终本机镜像 4,293,696 字节,应用分区余量 17%;SHA-256 为 `de4c4f70349cedbc5cc264e946d426b1c80cad80aa42b1363e7c3062dee0945c`。仅写入 `0x20000`,写入校验与独立 verify-flash digest matched 均通过。
+- 刷写前已备份 NVS / OTA 元数据到仓库外私有文件;未改写 NVS、OTA 启动元数据、分区表与 assets。顶部各 Agent 数字排版仍待用户单独确认。
diff --git a/docs/design/agent-tasks-animation-preview.gif b/docs/design/agent-tasks-animation-preview.gif
new file mode 100644
index 000000000..e495ac9e8
Binary files /dev/null and b/docs/design/agent-tasks-animation-preview.gif differ
diff --git a/docs/design/agent-tasks-official-preview.png b/docs/design/agent-tasks-official-preview.png
new file mode 100644
index 000000000..799bffdba
Binary files /dev/null and b/docs/design/agent-tasks-official-preview.png differ
diff --git a/docs/remote-access.md b/docs/remote-access.md
new file mode 100644
index 000000000..655fd5f78
--- /dev/null
+++ b/docs/remote-access.md
@@ -0,0 +1,204 @@
+# Remote Access with Tailscale Funnel
+
+This guide explains how to make a `stackchan-mcp` gateway reachable when the
+StackChan device and gateway host are on different networks.
+
+The recommended first path is Tailscale Funnel:
+
+- no firmware feature work is required
+- the ESP32 connects to a public `wss://...ts.net` gateway URL
+- the gateway still verifies the ESP32 bearer token with `STACKCHAN_TOKEN`
+- photo uploads can use a second Funnel URL through `VISION_URL` and are
+ protected with `VISION_TOKEN` or, by default, `STACKCHAN_TOKEN`
+
+Tailscale Funnel publishes local services on a device in your tailnet to the
+public internet over HTTPS. It requires Tailscale CLI support, MagicDNS, HTTPS
+certificates, and a Funnel node attribute in the tailnet policy. Funnel can
+listen only on `443`, `8443`, or `10000`, so this guide uses `443` for the
+WebSocket gateway and `8443` for the photo capture endpoint.
+
+Reference:
+
+- [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel)
+- [tailscale funnel command](https://tailscale.com/docs/reference/tailscale-cli/funnel)
+- [Tailscale Funnel examples](https://tailscale.com/docs/reference/examples/funnel)
+
+## Network Shape
+
+```
+ESP32 StackChan
+ │
+ │ wss://..ts.net/
+ ▼
+Tailscale Funnel :443
+ │
+ │ forwards to local TCP
+ ▼
+stackchan-mcp gateway WebSocket :8765
+
+ESP32 camera upload
+ │
+ │ https://..ts.net:8443/capture
+ ▼
+Tailscale Funnel :8443
+ │
+ │ forwards to local HTTP
+ ▼
+stackchan-mcp capture server :8766
+```
+
+## Security Notes
+
+Funnel makes the selected service public. Keep `STACKCHAN_TOKEN` set and use a
+strong token for the WebSocket gateway.
+
+The `/capture` endpoint is a separate HTTP upload endpoint. Keep
+`STACKCHAN_TOKEN` set so the gateway can pass a bearer token to the ESP32 for
+photo uploads, or set `VISION_TOKEN` if you want a separate capture token. If
+you expose capture with Funnel, still treat the URL as public while the Funnel
+listener is enabled, turn it off when it is not needed, and do not expose
+captured photos or other user data in git.
+
+Do not publish personal tokens, LAN IP addresses, WiFi credentials, `.env`
+files, captures, or local firmware override files.
+
+If you only need access from devices that already run Tailscale, use Tailscale
+Serve or a normal tailnet address instead of Funnel. The ESP32 firmware does
+not currently run a Tailscale client, so the StackChan device itself needs a
+public Funnel URL unless a separate network layer routes it into the tailnet.
+
+## Gateway Configuration
+
+In `gateway/.env`, keep the gateway listening locally and set both the ESP32
+auth token and the public capture URL:
+
+```bash
+STACKCHAN_TOKEN=
+
+HOST=0.0.0.0
+WS_PORT=8765
+CAPTURE_PORT=8766
+
+VISION_URL=https://..ts.net:8443/capture
+# Optional: leave empty to reuse STACKCHAN_TOKEN.
+VISION_TOKEN=
+```
+
+`VISION_URL` is the full URL sent to the ESP32 for `take_photo` uploads. Use it
+for remote tunnel setups. `VISION_TOKEN` is sent to the ESP32 as the capture
+upload bearer token; if it is empty, the gateway reuses `STACKCHAN_TOKEN`.
+`VISION_HOST` remains useful for LAN-only setups where the capture URL is
+`http://:8766/capture`.
+
+Start the gateway:
+
+```bash
+cd gateway
+uv run python -m stackchan_mcp
+```
+
+## Funnel Setup
+
+On the same host as the gateway, enable two Funnel listeners.
+
+Use port `443` for the WebSocket gateway:
+
+```bash
+tailscale funnel --bg --https=443 http://localhost:8765
+```
+
+Use port `8443` for the HTTP capture server:
+
+```bash
+tailscale funnel --bg --https=8443 http://localhost:8766
+```
+
+Check the published URLs:
+
+```bash
+tailscale funnel status
+```
+
+The expected public endpoints are:
+
+- WebSocket: `wss://..ts.net/`
+- Capture: `https://..ts.net:8443/capture`
+
+To stop the listeners:
+
+```bash
+tailscale funnel --https=443 off
+tailscale funnel --https=8443 http://localhost:8766 off
+```
+
+## Device Configuration
+
+Configure the firmware's WebSocket URL and bearer token:
+
+```text
+websocket.url = wss://..ts.net/
+websocket.token =
+```
+
+For developer builds, these can be provided through the existing Kconfig
+fallbacks:
+
+```text
+CONFIG_DEFAULT_WEBSOCKET_URL="wss://..ts.net/"
+CONFIG_DEFAULT_WEBSOCKET_TOKEN=""
+```
+
+To prefer a LAN gateway when the StackChan and gateway host are on the same
+network, while still keeping a remote Funnel path for travel, configure the
+local URL as primary and the Funnel URL as fallback:
+
+```text
+CONFIG_DEFAULT_WEBSOCKET_URL="ws://:8765/"
+CONFIG_DEFAULT_WEBSOCKET_FALLBACK_URL="wss://..ts.net/"
+CONFIG_DEFAULT_WEBSOCKET_TOKEN=""
+```
+
+At runtime the firmware tries the primary URL first. If the TCP/WebSocket
+connection fails, or if the stackchan-mcp server hello does not complete, it
+tries the fallback URL next. Leave the fallback empty for local-only or
+Tailscale-only setups.
+
+For existing devices with stale NVS values, use the documented
+`CONFIG_FORCE_DEFAULT_WEBSOCKET_URL=y` flow from the top-level README.
+
+## Validation
+
+After the gateway, Funnel, and device settings are in place:
+
+1. Start the gateway.
+2. Start both Funnel listeners.
+3. Reboot or reset the StackChan device.
+4. Press the StackChan main button to start a chat session. The firmware opens
+ the WebSocket connection when a session starts; an idle device may not appear
+ in `get_status` immediately after boot.
+5. Call `get_status` from the MCP client and confirm `connected=true`.
+6. Call `get_device_info`.
+7. Call `take_photo` and confirm the capture reaches the gateway.
+
+If the device and gateway are on the same physical LAN during validation, this
+still verifies that the firmware uses the public Funnel `wss://` URL, bearer
+auth, MCP initialization, and the authenticated capture callback. It does not
+prove behavior across every remote network path. For a stronger end-to-end
+remote proof, place the StackChan device on a different network, such as a phone
+hotspot, while keeping the gateway host behind Funnel.
+
+If `get_status` works but `take_photo` fails, the WebSocket path is working but
+the capture callback URL is wrong. Re-check `VISION_URL`, the `8443` Funnel
+listener, and whether the capture URL ends with `/capture`.
+
+If `take_photo` is not needed for a remote session, leave the `8443` Funnel
+listener off and use only the WebSocket listener.
+
+## Limitations
+
+- Funnel bandwidth limits are managed by Tailscale and are not configurable.
+- Latency depends on the device WiFi, the gateway network, and Funnel relay path.
+- The future Opus audio stream may need lower and more stable latency than
+ simple control and photo capture.
+- A firmware-native WireGuard or Tailscale-style client would be a separate
+ follow-up feature, not part of this guide.
diff --git a/docs/superpowers/plans/2026-11-11-official-stackchan-agent-dashboard.md b/docs/superpowers/plans/2026-11-11-official-stackchan-agent-dashboard.md
new file mode 100644
index 000000000..3a813e5b4
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-11-official-stackchan-agent-dashboard.md
@@ -0,0 +1,26 @@
+# 官方 StackChan Agent 看板实施清单
+
+本清单记录最终实施范围。架构详见同目录设计规范,实测证据见 `docs/agent-tasks-validation.zh-CN.md`。
+
+- [x] 网关提供受 Bearer 令牌保护的 `/agent-dashboard`,复用 MCP 状态存储。
+- [x] 新增官方 Mooncake App,在 Launcher 提供 AGENT TASKS 独立入口。
+- [x] 320×240 双栏:Codex 左、Claude 右,图形标识、中文状态、阶段和任务。
+- [x] 每栏独立滚动,任务按进行中、待办、已完成稳定排序。
+- [x] 工作转圈、动态省略号与进行中标记动画,完成和离线停止。
+- [x] Setup 配置地址和隐藏令牌,软键盘编辑、连接测试、保存与取消。
+- [x] 独立 NVS 命名空间与可选本机默认配置,保留已有非空字段。
+- [x] 后台 HTTP 轮询,超时与响应大小限制,失败保留最近有效快照。
+- [x] Codex Desktop 生命周期监视与显式计划同步;Claude transcript 任务监视。
+- [x] macOS 安装器、中文使用说明、字体许可与界面预览。
+- [x] 网关完整测试与 Ruff;固件宿主 CTest;官方 ESP-IDF 6.1 构建。
+- [x] USB 刷写、独立校验与正常启动,蓝牙只读连接验证。
+- [x] 用户确认实机双栏任务与动画正常。
+- [ ] 实机单独验收长清单滚动、返回头像与网关断线恢复。
+- [ ] 验收新 Claude 模型会话产生任务事件的实时更新。
+
+## 本次仓库整合
+
+- [x] 官方固件作为主项目,网关保持 `gateway/` 独立进程。
+- [x] 同仓保留网关测试、依赖锁、安装脚本及上游许可证。
+- [x] 排除个人凭据、会话、备份及构建产物。
+- [ ] 在整合目录重新运行检查并推送 `codex/agent-tasks-dashboard`。
diff --git a/docs/superpowers/specs/2026-11-11-agent-tasks-official-stackchan-design.md b/docs/superpowers/specs/2026-11-11-agent-tasks-official-stackchan-design.md
new file mode 100644
index 000000000..68ac06601
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-11-agent-tasks-official-stackchan-design.md
@@ -0,0 +1,68 @@
+# StackChan Agent 待办看板设计
+
+## 目标
+
+在 M5Stack 官方 StackChan 固件中新增一个 Mooncake 应用,在 320×240 屏幕上查看 Codex 和 Claude 的状态、阶段及待办。左栏固定为 Codex,右栏固定为 Claude。Agent 通过 `stackchan-mcp` 的 MCP 服务提交状态;设备离线时保留最近任务并显示离线状态。头像和原语音交互不变。
+
+固件以 [M5Stack/StackChan](https://github.com/m5stack/StackChan) 为基线。`stackchan-mcp` 继续作为独立网关和 MCP 状态服务,不把其 fork 固件作为设备固件。
+
+## 架构
+
+```text
+Codex / Claude adapters
+ │ MCP Streamable HTTP
+ ▼
+stackchan-mcp gateway ── AgentStatusStore
+ ▲ │
+ │ HTTP GET └── latest two-agent snapshot
+ │ (1 second polling)
+M5Stack official StackChan
+ Mooncake Agent Tasks app
+```
+
+固件通过新增的独立 Mooncake App 显示看板,并由官方 Launcher 展示其应用图标。Agent 不连接设备。设备读取状态使用网关的只读 HTTP 接口;MCP 仍是状态写入入口。该边界适配官方固件当前的应用及联网结构,也避免依赖 `stackchan-mcp` fork 内的设备 WebSocket MCP 协议。
+
+## 网关接口
+
+- `agent_update_snapshot` 接受单个 Agent 的完整快照,沿用 `AgentStatusStore` 的会话隔离、序号拒绝、文本长度限制及 45 秒租约。
+- `agent_get_dashboard` 返回两个 Agent 的最新完整视图,顺序固定为 Codex、Claude。
+- 新增受现有 Bearer token 保护的 `GET /agent-dashboard`,供固件轮询同一只读视图。响应不包含 prompt、文件路径、工具参数、消息正文或模型推理内容。
+- 不新增设备 WebSocket 推送、设备队列 publisher 或 MCP 导航工具。设备网络中断时保留缓存视图;恢复后下一次轮询自动取得最新快照。
+- JSON 响应字段为 `agents[]`,每项包含 `agent`、`online`、`stale`、`status`、`stage`、`tasks[]`。状态值为 `idle`、`working`、`waiting`、`completed`、`failed`;任务状态值为 `pending`、`in_progress`、`completed`。
+
+## 官方固件应用
+
+- 在 `firmware/main/apps` 新增 Agent Tasks Mooncake App,并在 `apps.h`、`main.cpp` 注册;Launcher 提供独立入口和 Agent 图标。
+- UI 采用深色、高对比、细分隔线的双栏布局。每栏显示图形 Agent 标识、名称、中文状态、短阶段和任务。每栏同时最多显示三条任务,任务较多时在本栏独立滚动。阶段和任务文字进行长度限制。
+- 页面提供返回 Launcher 的官方 HomeIndicator;不在头像 App 上拦截触摸,也不更改音频、头部触摸或现有页面手势。
+- LVGL 创建、更新和销毁遵循官方 `LvglLockGuard`。HTTP 请求运行在独立 worker,不在 UI 更新或持锁区间执行阻塞网络操作。网络恢复后由 worker 将最新完整快照交给 App,随后在 UI 生命周期中更新 LVGL。
+- 空清单显示“暂无待办任务”。来源离线或租约过期时保留最后任务,并标示“离线”或“已过期”。设备暂时无法连接网关时显示上一次数据,不伪造进度。
+
+## 设备配置
+
+在官方 Setup App 中增加 Agent 看板设置页,包含网关只读接口地址和 Bearer token。配置保存到设备 NVS;token 输入默认隐藏,日志不打印 token。设置页提供软键盘、连接测试和保存/取消操作。通用源码默认地址为空;可用本机脚本生成被 Git 忽略的默认地址和令牌,首次加载时回填空字段并持久化,已有非空值优先。未配置地址时看板显示配置提示,不发起外部请求。
+
+设备以约 1 秒间隔轮询 `GET /agent-dashboard`,单次 HTTP 操作超时为 5 秒,响应体上限为 8 KiB,并不跟随 HTTP 重定向,避免 Bearer token 被转发到其他地址。网关返回带 `Content-Length` 的 JSON 响应。地址支持家庭 LAN 的 HTTP 和 HTTPS。一次失败不清空缓存;重复失败显示连接异常状态。
+
+## 事件适配器边界
+
+- Codex Desktop 使用本机实验 rollout 监视器,仅接收 `originator` 明确为 `Codex Desktop` 的主会话,读取回合开始、完成和中断事件。已观察真实事件,但它不是稳定的官方桌面事件订阅 API。
+- Codex 待办使用主会话显式 `codex-plan` 入口,同会话计划与生命周期由同一个监视器合并。未观察到桌面原生计划事件时,不从消息正文猜测任务。
+- Claude Code 使用本地 transcript 增量监视器,成功的 TaskCreate、TaskUpdate、TodoWrite 更新清单,结束回合保留任务;不依赖全局 Hook。既有会话回放已验证,新模型会话实时验收待完成。
+- 只展示最近活动的主会话,不混入子 Agent、其它会话或聊天正文。一个 Agent 栏只使用一个上报来源。
+- 每栏稳定排序为“进行中 → 待办 → 已完成”;在线工作时显示转圈、省略号和任务指示动画,完成或离线时停止。
+
+## 验证和验收
+
+- 网关测试覆盖 MCP 更新、旧序号拒绝、双 Agent 输出顺序、45 秒过期、HTTP Bearer 授权、错误输入和不泄露额外字段。
+- 固件 host tests 覆盖两列映射、状态标签、UTF-8 截断、空清单、离线保留任务及非法快照拒绝。
+- 用官方仓库文档所列 ESP-IDF v6.1 流程构建;确认新增 App、Setup 配置和依赖不破坏官方 Launcher 与现有应用。
+- 在设备上检查 320×240 双栏排版、滚动、返回、网络断开/恢复、状态更新,以及 Avatar、语音触摸和头部手势未受影响。
+- 刷写只使用官方固件仓库的标准 CoreS3/StackChan 指引。先确认生成镜像和目标板型;下载模式由 USB 完成刷写,BLE 用于启动后广播发现,不作为刷写通道。
+
+## 实施顺序
+
+1. 在 `stackchan-mcp` 中完成并验证 MCP 状态工具及只读 HTTP 快照接口。
+2. 在官方固件中新增 App、纯数据模型、设置页配置持久化及异步轮询。
+3. 完成网关/固件契约测试、官方 ESP-IDF 构建和实体设备验收。
+4. 按真实生命周期与任务事件证据启用本机实验适配器,明确记录其限制与尚未验收的实机行为。
diff --git a/docs/superpowers/specs/2026-11-12-home-gesture-and-progress-design.md b/docs/superpowers/specs/2026-11-12-home-gesture-and-progress-design.md
new file mode 100644
index 000000000..056765a93
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-12-home-gesture-and-progress-design.md
@@ -0,0 +1,16 @@
+# 首页上滑响应与各 Agent 完成度
+
+用户反馈 AI AGENT 页需反复上滑才能唤出首页按钮。根因之一是 AI 工作任务持有 LVGL 全局锁休眠 100 ms,使触摸采样、绘制与手势处理一同延后。另有公共手势触发区窄、只在释放时判断,以及首页按钮隐藏动画结束标志未在重新显示时清理的问题。
+
+## 最终方案
+
+- AI 工作任务仍以 20 ms 周期处理导航,忙碌时只将头像工作限制为 120 ms 周期;锁内不休眠。
+- 公共首页手势从底部 40 px 起触,向上移动至少 40 px 且纵向位移大于横向位移时触发,一次触摸最多触发一次;支持释放时最后触点,排除中部滚动、横滑和短拖动。
+- 重新显示首页按钮时清除上一轮隐藏标志,避免动画完成时又隐藏。
+- 看板导航按约 33 ms 更新,任务数据按 500 ms 刷新;拷贝快照在 LVGL 锁外完成,标记透明度仅在实际值变化时更新。网络轮询仍为约 1 秒。
+- 按用户最终要求,Codex 和 Claude 名称右侧分别以 14 px 小号数字显示完成百分比,不显示底栏、进度条和计数,保留原任务列表高度。
+- 百分比按各自当前同步清单的已完成数 / 总数计算,四舍五入;进行中不计完成,空清单显示 `--%`,离线保留最后快照比例。
+
+## 验证
+
+宿主测试覆盖空清单、双 Agent 独立比例、取整、100%,以及底部上滑、横滑、短拖、中部滚动、触点无效和单次触发。ESP-IDF 6.1 构建,USB 仅写应用槽,保留 NVS、OTA 元数据和 assets。首次优化固件刷入后,用户确认“返回顺畅,百分比显示正常”;顶部各 Agent 百分比版本另行记录构建与刷写结果。
diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_config.cpp b/firmware/main/apps/app_agent_tasks/agent_tasks_config.cpp
new file mode 100644
index 000000000..11f7e6cde
--- /dev/null
+++ b/firmware/main/apps/app_agent_tasks/agent_tasks_config.cpp
@@ -0,0 +1,67 @@
+#include "agent_tasks_config.h"
+#include
+#if __has_include("agent_tasks_local_defaults.h")
+#include "agent_tasks_local_defaults.h"
+#else
+#define AGENT_TASKS_DEFAULT_URL ""
+#define AGENT_TASKS_DEFAULT_TOKEN ""
+#endif
+
+namespace agent_tasks {
+namespace {
+constexpr char kNamespace[] = "agent_tasks";
+constexpr size_t kUrlMax = 255;
+constexpr size_t kTokenMax = 256;
+
+bool read_string(nvs_handle_t handle, const char* key, size_t max, std::string& value) {
+ size_t size = 0;
+ esp_err_t err = nvs_get_str(handle, key, nullptr, &size);
+ if (err == ESP_ERR_NVS_NOT_FOUND) return true;
+ if (err != ESP_OK || size == 0 || size > max + 1) return false;
+ value.resize(size);
+ if (nvs_get_str(handle, key, value.data(), &size) != ESP_OK) return false;
+ value.pop_back();
+ return true;
+}
+}
+
+bool load_config(Config& config) {
+ nvs_handle_t handle;
+ auto err = nvs_open(kNamespace, NVS_READONLY, &handle);
+ if (err == ESP_ERR_NVS_NOT_FOUND) {
+ config = {AGENT_TASKS_DEFAULT_URL, AGENT_TASKS_DEFAULT_TOKEN};
+ return config.url.empty() || save_config(config);
+ }
+ if (err != ESP_OK) return false;
+ Config loaded;
+ const bool ok = read_string(handle, "url", kUrlMax, loaded.url) &&
+ read_string(handle, "token", kTokenMax, loaded.token);
+ nvs_close(handle);
+ if (!ok) return false;
+ bool use_defaults = false;
+ if (loaded.url.empty() && AGENT_TASKS_DEFAULT_URL[0]) {
+ loaded.url = AGENT_TASKS_DEFAULT_URL;
+ use_defaults = true;
+ }
+ if (loaded.token.empty() && AGENT_TASKS_DEFAULT_TOKEN[0]) {
+ loaded.token = AGENT_TASKS_DEFAULT_TOKEN;
+ use_defaults = true;
+ }
+ config = std::move(loaded);
+ return !use_defaults || save_config(config);
+}
+
+bool save_config(const Config& config) {
+ if (config.url.size() > kUrlMax || config.token.size() > kTokenMax ||
+ config.url.find_first_of("\r\n") != std::string::npos ||
+ config.token.find_first_of("\r\n") != std::string::npos) return false;
+ nvs_handle_t handle;
+ auto err = nvs_open(kNamespace, NVS_READWRITE, &handle);
+ if (err != ESP_OK) return false;
+ err = nvs_set_str(handle, "url", config.url.c_str());
+ if (err == ESP_OK) err = nvs_set_str(handle, "token", config.token.c_str());
+ if (err == ESP_OK) err = nvs_commit(handle);
+ nvs_close(handle);
+ return err == ESP_OK;
+}
+}
diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_config.h b/firmware/main/apps/app_agent_tasks/agent_tasks_config.h
new file mode 100644
index 000000000..bbb82fdb7
--- /dev/null
+++ b/firmware/main/apps/app_agent_tasks/agent_tasks_config.h
@@ -0,0 +1,8 @@
+#pragma once
+#include
+
+namespace agent_tasks {
+struct Config { std::string url; std::string token; };
+bool load_config(Config& config);
+bool save_config(const Config& config);
+}
diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_model.h b/firmware/main/apps/app_agent_tasks/agent_tasks_model.h
new file mode 100644
index 000000000..ceb9dde91
--- /dev/null
+++ b/firmware/main/apps/app_agent_tasks/agent_tasks_model.h
@@ -0,0 +1,111 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace agent_tasks {
+
+enum class AgentStatus { Idle, Working, Waiting, Completed, Failed };
+enum class TaskStatus { Pending, InProgress, Completed };
+constexpr size_t kMaxTasks = 12;
+constexpr size_t kMaxStageCodepoints = 24;
+constexpr size_t kMaxTaskCodepoints = 64;
+
+struct Task {
+ std::string id;
+ std::string text;
+ TaskStatus status = TaskStatus::Pending;
+};
+
+struct AgentView {
+ AgentStatus status = AgentStatus::Idle;
+ std::string stage;
+ std::vector tasks;
+ bool online = false;
+ bool stale = false;
+};
+
+struct Snapshot {
+ AgentView codex;
+ AgentView claude;
+};
+
+struct CompletionProgress {
+ size_t completed = 0;
+ size_t total = 0;
+ unsigned percent = 0;
+};
+
+inline CompletionProgress completion_progress(const AgentView& view) {
+ CompletionProgress progress;
+ progress.total = view.tasks.size();
+ for (const auto& task : view.tasks)
+ if (task.status == TaskStatus::Completed) ++progress.completed;
+ if (progress.total)
+ progress.percent = static_cast((progress.completed * 100 + progress.total / 2) / progress.total);
+ return progress;
+}
+
+inline bool valid_utf8(const std::string& value) {
+ for (size_t i = 0; i < value.size();) {
+ const auto a = static_cast(value[i]);
+ const size_t n = a < 0x80 ? 1 : a >= 0xC2 && a <= 0xDF ? 2 :
+ a >= 0xE0 && a <= 0xEF ? 3 : a >= 0xF0 && a <= 0xF4 ? 4 : 0;
+ if (!n || i + n > value.size()) return false;
+ for (size_t j = 1; j < n; ++j) {
+ const auto b = static_cast(value[i + j]);
+ if ((b & 0xC0) != 0x80) return false;
+ if (j == 1 && ((a == 0xE0 && b < 0xA0) || (a == 0xED && b >= 0xA0) ||
+ (a == 0xF0 && b < 0x90) || (a == 0xF4 && b >= 0x90))) return false;
+ }
+ i += n;
+ }
+ return true;
+}
+
+inline size_t codepoints(const std::string& value) {
+ size_t result = 0;
+ for (unsigned char c : value) if ((c & 0xC0) != 0x80) ++result;
+ return result;
+}
+
+inline const char* status_label(const AgentView& view) {
+ if (view.stale) return "已过期";
+ if (!view.online) return "离线";
+ switch (view.status) {
+ case AgentStatus::Idle: return "空闲";
+ case AgentStatus::Working: return "工作中";
+ case AgentStatus::Waiting: return "等待中";
+ case AgentStatus::Completed: return "已完成";
+ case AgentStatus::Failed: return "失败";
+ }
+ return "未知";
+}
+
+class Model {
+public:
+ bool replace(const Snapshot& next) {
+ if (!valid(next.codex) || !valid(next.claude)) return false;
+ snapshot_ = next;
+ return true;
+ }
+ const Snapshot& snapshot() const { return snapshot_; }
+
+private:
+ static bool valid(const AgentView& view) {
+ if (static_cast(view.status) < 0 || static_cast(view.status) > 4 ||
+ view.tasks.size() > kMaxTasks || !valid_utf8(view.stage) ||
+ codepoints(view.stage) > kMaxStageCodepoints) return false;
+ for (const auto& task : view.tasks) {
+ if (task.id.empty() || task.id.size() > 128 || !valid_utf8(task.text) ||
+ codepoints(task.text) > kMaxTaskCodepoints ||
+ static_cast(task.status) < 0 || static_cast(task.status) > 2) return false;
+ }
+ return true;
+ }
+ Snapshot snapshot_;
+};
+
+} // namespace agent_tasks
diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_poller.cpp b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.cpp
new file mode 100644
index 000000000..822ea9a20
--- /dev/null
+++ b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.cpp
@@ -0,0 +1,162 @@
+#include "agent_tasks_poller.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace agent_tasks {
+namespace {
+constexpr int kTimeoutMs = 5000;
+constexpr size_t kMaxResponse = 8192;
+
+bool parse_agent(cJSON* object, const char* expected, AgentView& view) {
+ auto* agent = cJSON_GetObjectItemCaseSensitive(object, "agent");
+ auto* status = cJSON_GetObjectItemCaseSensitive(object, "status");
+ auto* stage = cJSON_GetObjectItemCaseSensitive(object, "stage");
+ auto* online = cJSON_GetObjectItemCaseSensitive(object, "online");
+ auto* stale = cJSON_GetObjectItemCaseSensitive(object, "stale");
+ auto* tasks = cJSON_GetObjectItemCaseSensitive(object, "tasks");
+ if (!cJSON_IsString(agent) || strcmp(agent->valuestring, expected) || !cJSON_IsString(status) ||
+ !cJSON_IsString(stage) || !cJSON_IsBool(online) || !cJSON_IsBool(stale) || !cJSON_IsArray(tasks) ||
+ cJSON_GetArraySize(tasks) > static_cast(kMaxTasks)) return false;
+ const char* statuses[] = {"idle", "working", "waiting", "completed", "failed"};
+ bool found = false;
+ for (int i = 0; i < 5; ++i) if (!strcmp(status->valuestring, statuses[i])) {
+ view.status = static_cast(i); found = true; break;
+ }
+ if (!found) return false;
+ view.stage = stage->valuestring;
+ view.online = cJSON_IsTrue(online);
+ view.stale = cJSON_IsTrue(stale);
+ for (auto* item = tasks->child; item; item = item->next) {
+ auto* id = cJSON_GetObjectItemCaseSensitive(item, "id");
+ auto* text = cJSON_GetObjectItemCaseSensitive(item, "text");
+ auto* task_status = cJSON_GetObjectItemCaseSensitive(item, "status");
+ if (!cJSON_IsString(id) || !cJSON_IsString(text) || !cJSON_IsString(task_status)) return false;
+ Task task;
+ task.id = id->valuestring;
+ task.text = text->valuestring;
+ if (!strcmp(task_status->valuestring, "pending")) task.status = TaskStatus::Pending;
+ else if (!strcmp(task_status->valuestring, "in_progress")) task.status = TaskStatus::InProgress;
+ else if (!strcmp(task_status->valuestring, "completed")) task.status = TaskStatus::Completed;
+ else return false;
+ view.tasks.push_back(std::move(task));
+ }
+ return true;
+}
+
+bool fetch(const Config& config, Snapshot& result, const std::atomic_bool& stop) {
+ if (config.url.compare(0, 7, "http://") && config.url.compare(0, 8, "https://")) return false;
+ // Mooncake does not start Wi-Fi at boot. Reuse the official network
+ // manager and saved credentials before making the first HTTP request.
+ auto& wifi = WifiManager::GetInstance();
+ if (!wifi.IsConnected()) {
+ if (SsidManager::GetInstance().GetSsidList().empty() || wifi.IsConfigMode()) return false;
+ if (!wifi.IsInitialized()) Board::GetInstance().StartNetwork();
+ else wifi.StartStation();
+ const int64_t deadline = esp_timer_get_time() + int64_t(kTimeoutMs) * 1000;
+ while (!wifi.IsConnected() && !stop && esp_timer_get_time() < deadline)
+ vTaskDelay(pdMS_TO_TICKS(100));
+ if (!wifi.IsConnected() || stop) return false;
+ }
+ std::string url = config.url;
+ while (!url.empty() && url.back() == '/') url.pop_back();
+ url += "/agent-dashboard";
+ auto network = Board::GetInstance().GetNetwork();
+ if (!network) return false;
+ esp_log_level_set("HttpClient", ESP_LOG_WARN);
+ auto client = network->CreateHttp(0);
+ if (!client) return false;
+ const int64_t started = esp_timer_get_time();
+ auto set_remaining_timeout = [&]() {
+ const int remaining = kTimeoutMs - static_cast((esp_timer_get_time() - started) / 1000);
+ if (remaining <= 0 || stop) return false;
+ client->SetTimeout(remaining);
+ return true;
+ };
+ if (!set_remaining_timeout()) return false;
+ client->SetKeepAlive(false);
+ client->SetHeader("Authorization", "Bearer " + config.token);
+ client->SetHeader("Accept", "application/json");
+ client->SetHeader("Connection", "close");
+ bool ok = client->Open("GET", url) && set_remaining_timeout() && client->GetStatusCode() == 200;
+ std::string body;
+ char buffer[512];
+ const size_t content_length = ok ? client->GetBodyLength() : 0;
+ if (content_length == 0 || content_length > kMaxResponse) ok = false;
+ while (ok && !stop && body.size() < content_length) {
+ if (!set_remaining_timeout()) { ok = false; break; }
+ const size_t remaining = std::min(sizeof(buffer), content_length - body.size());
+ const auto read = client->Read(buffer, remaining);
+ if (!read) { ok = false; break; }
+ const int n = *read;
+ if (n < 0 || body.size() + static_cast(n) > kMaxResponse) { ok = false; break; }
+ if (n == 0) { ok = false; break; }
+ body.append(buffer, n);
+ }
+ client->Close();
+ if (body.size() != content_length || esp_timer_get_time() - started >= int64_t(kTimeoutMs) * 1000) ok = false;
+ if (!ok || stop) return false;
+ auto* root = cJSON_ParseWithLength(body.c_str(), body.size());
+ if (!root) return false;
+ auto* agents = cJSON_GetObjectItemCaseSensitive(root, "agents");
+ ok = cJSON_IsArray(agents) && cJSON_GetArraySize(agents) == 2;
+ if (ok) ok = parse_agent(cJSON_GetArrayItem(agents, 0), "codex", result.codex) &&
+ parse_agent(cJSON_GetArrayItem(agents, 1), "claude", result.claude);
+ cJSON_Delete(root);
+ Model model;
+ return ok && model.replace(result);
+}
+}
+
+bool Poller::start(const Config& config) {
+ stop_ = false; configured_ = !config.url.empty(); connected_ = false;
+ if (!configured_) return true;
+ struct StartArgs { Poller* self; Config config; };
+ auto* args = new StartArgs{this, config};
+ task_running_ = true;
+ const auto created = xTaskCreate([](void* opaque) {
+ std::unique_ptr args(static_cast(opaque));
+ auto* self = args->self;
+ self->run(std::move(args->config));
+ args.reset();
+ self->task_running_ = false;
+ vTaskDelete(nullptr);
+ }, "agent_tasks", 6144, args, 2, nullptr);
+ if (created != pdPASS) { delete args; task_running_ = false; return false; }
+ return true;
+}
+
+void Poller::test_once(const Config& config) {
+ Snapshot next;
+ const bool ok = fetch(config, next, stop_);
+ if (ok) {
+ std::lock_guard lock(mutex_);
+ snapshot_ = std::move(next);
+ connected_ = true;
+ } else connected_ = false;
+}
+
+void Poller::run(Config config) {
+ while (!stop_) {
+ Snapshot next;
+ if (fetch(config, next, stop_)) {
+ std::lock_guard lock(mutex_); snapshot_ = std::move(next); connected_ = true;
+ } else connected_ = false;
+ for (int i = 0; i < 10 && !stop_; ++i) vTaskDelay(pdMS_TO_TICKS(100));
+ }
+}
+
+void Poller::stop() {
+ stop_ = true;
+ while (task_running_) vTaskDelay(pdMS_TO_TICKS(10));
+}
+Snapshot Poller::latest() const { std::lock_guard lock(mutex_); return snapshot_; }
+}
diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_poller.h b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.h
new file mode 100644
index 000000000..d44fb48ab
--- /dev/null
+++ b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.h
@@ -0,0 +1,25 @@
+#pragma once
+#include "agent_tasks_model.h"
+#include "agent_tasks_config.h"
+#include
+#include
+
+namespace agent_tasks {
+class Poller {
+public:
+ bool start(const Config& config);
+ void stop();
+ void test_once(const Config& config);
+ Snapshot latest() const;
+ bool configured() const { return configured_; }
+ bool connected() const { return connected_; }
+private:
+ mutable std::mutex mutex_;
+ Snapshot snapshot_;
+ std::atomic_bool stop_{false};
+ std::atomic_bool connected_{false};
+ std::atomic_bool task_running_{false};
+ bool configured_ = false;
+ void run(Config config);
+};
+}
diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_setup_worker.cpp b/firmware/main/apps/app_agent_tasks/agent_tasks_setup_worker.cpp
new file mode 100644
index 000000000..e127b7d86
--- /dev/null
+++ b/firmware/main/apps/app_agent_tasks/agent_tasks_setup_worker.cpp
@@ -0,0 +1,122 @@
+#include "agent_tasks_setup_worker.h"
+#include "agent_tasks_poller.h"
+#include
+#include
+#include
+
+using namespace smooth_ui_toolkit::lvgl_cpp;
+LV_FONT_DECLARE(agent_tasks_cjk_14);
+
+namespace agent_tasks {
+void SetupWorker::edit(bool token)
+{
+ if (editor_) return;
+ editing_token_ = token;
+ editor_ = lv_obj_create(lv_screen_active());
+ lv_obj_set_size(editor_, 320, 240);
+ lv_obj_set_style_pad_all(editor_, 0, 0);
+ lv_obj_set_style_border_width(editor_, 0, 0);
+ lv_obj_set_style_radius(editor_, 0, 0);
+ lv_obj_remove_flag(editor_, LV_OBJ_FLAG_SCROLLABLE);
+ auto* heading = lv_label_create(editor_);
+ lv_obj_set_style_text_font(heading, &agent_tasks_cjk_14, 0);
+ lv_label_set_text(heading, token ? "访问令牌" : "网关地址");
+ lv_obj_set_pos(heading, 10, 4);
+ input_ = lv_textarea_create(editor_);
+ lv_obj_set_size(input_, 300, 40);
+ lv_obj_set_pos(input_, 10, 29);
+ lv_textarea_set_one_line(input_, true);
+ lv_textarea_set_max_length(input_, token ? 256 : 255);
+ lv_textarea_set_password_mode(input_, token);
+ lv_textarea_set_text(input_, (token ? config_.token : config_.url).c_str());
+ auto* keyboard = lv_keyboard_create(editor_);
+ lv_obj_set_size(keyboard, 320, 165);
+ lv_obj_align(keyboard, LV_ALIGN_BOTTOM_MID, 0, 0);
+ lv_keyboard_set_mode(keyboard, LV_KEYBOARD_MODE_TEXT_LOWER);
+ lv_keyboard_set_textarea(keyboard, input_);
+ lv_obj_add_event_cb(keyboard, [](lv_event_t* event) {
+ auto* self = static_cast(lv_event_get_user_data(event));
+ const auto code = lv_event_get_code(event);
+ if (code != LV_EVENT_READY && code != LV_EVENT_CANCEL) return;
+ if (code == LV_EVENT_READY) {
+ (self->editing_token_ ? self->config_.token : self->config_.url) =
+ lv_textarea_get_text(self->input_);
+ self->test_result_->result = 0;
+ self->save_failed_ = false;
+ }
+ lv_obj_delete_async(self->editor_);
+ self->editor_ = nullptr;
+ self->input_ = nullptr;
+ }, LV_EVENT_ALL, this);
+}
+
+SetupWorker::SetupWorker()
+{
+ save_failed_ = !load_config(config_);
+ title_ = std::make_unique