# @epoch-agent/server 中文文档

规范地址：https://npmcn.com/package/%40epoch-agent/server/

版本：6\.2\.0
npm 元数据更新时间：2026-11-13T08:32:16.542Z
中文译文更新时间：2026-11-13T19:51:20.685Z
软件包许可：Apache-2\.0

本文由上游 README 翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40epoch-agent/server
原文对照：https://npmcn.com/package/%40epoch-agent/server/?lang=original
项目主页：https://github.com/Ddbor/epoch-agent#readme
源码仓库：https://github.com/Ddbor/epoch-agent

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：2.9 MB，15 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=22\.0\.0
- 运行依赖：2 个
- 首次发布：2026-08-19T10:32:02.986Z
- 最新版本发布：2026-11-13T08:32:16.287Z
- 近 30 天下载量：4092（2026-10-14 至 2026-11-12，npm 官方下载统计）
- 已发布版本数：57（近 12 个月 57 个）

## 中文简介

epoch-agent 本地 HTTP + SSE 服务端：给浏览器的那一层，嵌入方不需要它

## 安装

```sh
npm install @epoch-agent/server
```

也可以使用：`pnpm add @epoch-agent/server`、`yarn add @epoch-agent/server`、`bun add @epoch-agent/server`

## README

# @epoch-agent/server

本地 HTTP + SSE 服务端。`epoch web` 的后半截——给浏览器的那一层。

-   ✅ **做**：`node:http` 手写路由、SessionHub 扇出、SSE 续传、cookie 鉴权 + Origin 校验、静态产物托管
-   ❌ **不做**：
    -   **不许 import [core](https://npmcn.com/package/%40epoch-agent/core)**。与 `examples/electron-minimal` 同规矩：需要什么能力 就让 [runtime](https://npmcn.com/package/%40epoch-agent/runtime) 转出来，这里出现 core 就是接口设计漏了一块（`check:layers` 会红）
    -   **不许写 `console`**。地址打给谁看是宿主的事（同样 `check:layers` 会红）
    -   不引第三方 HTTP 框架
-   **依赖**：[protocol](https://npmcn.com/package/%40epoch-agent/protocol) + [runtime](https://npmcn.com/package/%40epoch-agent/runtime)

## 谁会用它

两类宿主，接法完全一样：

-   **[cli](https://npmcn.com/package/%40epoch-agent/cli)**：`epoch web`——起服务、打印地址、开浏览器、接 Ctrl+C
-   **嵌入宿主**：Electron / VS Code 扩展里**要现成界面**的那一类（不想自己把 `AgentEvent` 翻译成组件树）。约定见 [docs/EMBEDDING.md §9](https://npmcn.com/package/docs/EMBEDDING.md)

自己画 UI 的同进程宿主**不需要**这一层：跨一层 HTTP 是白付的序列化成本，直接依赖 [runtime](https://npmcn.com/package/%40epoch-agent/runtime) 就好。

```ts
const created = await createWebServer({ runtime, version, port: 0 });
if (!created.ok) throw new Error(created.error); // 失败是返回值，不是异常
created.server.url; // 含一次性 ?token=，首屏换 HttpOnly cookie
created.server.port; // `port: 0` 时内核分配的真实端口——**别写死 4400**
created.server.token; // 宿主自己发请求时用，不必去正则 url
created.server.webRoot; // undefined = 这次发的是占位页（没构建过前端）
created.server.observe(fn); // 同进程旁听**广播层**的 wire 帧，**不算一条连接**
created.server.snapshot(); // 首屏那张状态快照（SSE 上的 session-state 只发变化）
await created.server.close(); // 幂等
```

⚠️ **`observe()` 只看得见「由 hub 驱动」的会话。** 它挂在广播层上（wire 帧、 序列化之后），所以宿主自己 for-await `session.run()` 的那半边它一帧都收不到。 那一半走 [runtime](https://npmcn.com/package/%40epoch-agent/runtime) 的 `EpochRuntime.events`（方案 61）—— 两者的取舍表在 [EMBEDDING §13.5](https://npmcn.com/package/docs/EMBEDDING.md)。

**主题和语言给 `ui`，别去写浏览器的 `localStorage`**（2026-08-15，方案 54 §七）：

```ts
const created = await createWebServer({
  runtime,
  version,
  port: 0,
  ui: { theme: 'dark', lang: 'zh' },
});
created.server.url; // http://127.0.0.1:52341/?token=…&theme=dark&lang=zh
```

界面在**第一帧之前**读这两个参数（`index.html` 文档头里那段内联脚本，它存在的理由 就是深色下不先闪一帧白），认出来就写进自己的 `localStorage` 再按现有逻辑走。 在它之前，宿主唯一能落地的做法是给 webview 挂一个 preload 手写 `localStorage` —— 而那两个 key 是 `@epoch-agent/web` 的导出常量，那个包 `private: true`、 **宿主 import 不到**，只能抄一个我们随时能改的字符串。

三条边界：**是初值不是锁**（用户在设置页照样改得动，刻意没有「强制主题」的开关）、 **一次性**（界面吃掉之后把这两个参数从地址栏抹掉，否则每次刷新都会覆盖用户的选择）、 **认不出来的值当没给**。参数名和值域在 [bind.ts](https://npmcn.com/package/%40epoch-agent/server/src/bind.ts)， 和 web 那两份的一致性由 `packages/web/__tests__/prefs.test.ts` 钉着。

**`artifactsRoot` 不用传**（2026-08-15 起可选）：缺省取 `runtime.artifactsRoot`， 也就是引擎真往里写产物的那个目录。以前它是必填的，而宿主没有任何办法验证自己算的 和引擎算的是不是同一个 —— 分叉的表现是产物栏点开 404，而服务、日志、状态码、诊断 全部正常，一个字不报（方案 54 §二）。真想让界面从别处读才传它，那时对不上是 宿主自己的选择。`server` 不许 import `infra`，所以这个字符串只能由 runtime 报。

## 前端产物随本包发

`dist/web` 是 [web](https://npmcn.com/package/%40epoch-agent/web) 的构建产物，由 [scripts/copy-web-assets.mjs](https://npmcn.com/package/%40epoch-agent/server/scripts/copy-web-assets.mjs) 在构建期拷进来，`GET /*` 托管的就是它。**不传 `webRoot` 就是它**——想换成自己那套 前端才传。

产物落在这个包而不是 [cli](https://npmcn.com/package/%40epoch-agent/cli)（2026-08-14 改的）：嵌入宿主依赖的是本包， 产物留在 cli 里的时候，它们不额外装一个 CLI 包并猜出 `dist/web` 这个内部路径就只能 拿到占位页，**而且服务、日志、状态码全都正常**。`verify:pack` 有一条断言守着 tarball 里真的有那些字节。

> ⚠️ 必须走**根目录**的 `pnpm build`。`pnpm --filter @epoch-agent/server build` 绕过 turbo，`@epoch-agent/server#build → @epoch-agent/web#build` 这条依赖不生效， 拷贝脚本会报「`packages/web/dist` 里没有 index.html」。
> 
> ⚠️ **构建期的 `clean` 不许删 `dist/web`**（`tsup.config.ts` 的 `clean: ['!web', …]`）。 它是拷进来的，不是 tsup 的产物；而那次删除发生在构建**开头**、拷回来是**最后一步**， 中间那几十秒里 `pnpm epoch:dev web` 起不来真界面、刷新也 404。配套的另一半是： 服务端**按请求**重算静态根（`routes.ts` 的 `handleStatic`），所以构建完了刷新一下就 接上，不用重启。两半各有一条用例：`build-clean-keeps-web` / `web-root-late`。

## 端点

| 端点 | 说明 |
| --- | --- |
| `POST /api/appearance` | `{theme?, lang?, sidebarWidth?}` → 记下界面外观（**进程级、无会话**，见下） |
| `GET /api/appearance` | 盘上此刻那份外观偏好（宿主画自己那层壳用；**纯读**，见下） |
| `GET /api/health` | **不鉴权**，只回 `{ok, version}` |
| `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外（`?lang=` 见下） |
| `GET /api/events` | SSE，负载是 `WireEnvelope` |
| `POST /api/host/compose` | 宿主投递：建/取会话 + 记一笔「该往输入框里填什么」+ 广播一帧（见下） |
| `GET /api/host/compose` | 此刻还没被认领的那一笔（页面**每次连上流**打一发，见下） |
| `POST /api/host/compose/:id/take` | 认领那一发自动发送（**一次性**，回 `{ok:false, reason}`，见下） |
| `POST /api/host/badges` | 宿主未读点：`{id, dot}` 开 / 关一项，回整张表（见下） |
| `GET /api/host/badges` | 宿主未读点的整张表 `{dots}` |
| `POST /api/host/tray` | 托盘两张运行期表：`{id, active}` 选中 / 取消一项，回整份表（见下） |
| `POST /api/host/tray/items` | 覆盖一项的文案 / 图标 / 关闭键（`{id, label?, …}`），回整份表（见下） |
| `DELETE /api/host/tray/items[?id=…]` | 撤掉一项（不带 `id` = 整份）覆盖，回落到配置那份（见下） |
| `GET /api/host/tray` | 托盘那两张表 `{active, items?}` |
| `PUT /api/host/topbar` | 顶栏左侧那排按钮：`{items}` **整份替换**，回整份（见下） |
| `DELETE /api/host/topbar` | 撤掉运行期那份，回落配置的那一份（见下） |
| `GET /api/host/topbar` | 那份运行期清单 `{items?}`（没下过是 `{}`） |
| `GET /api/host/slots` | 宿主自己那一块配置（已生效的那一份 + 盘上指纹），见 `docs/HOST_CAPABILITIES.md` §七 |
| `PUT /api/host/slots` | `{slots, revision?}` → 整份替换（**回环绑定才给**；成功推 `host-slots-changed`） |
| `POST /api/engine/restart` | `{mode?}` → 就地换一台引擎。**端口 / token / 页面 URL 都不变**；`idle`（缺省）等回合跑完、不打断（见下） |
| `GET /api/settings` | 进程级设置（不按会话算 ③④） |
| `POST /api/settings` | `{key, value, layer?}` → 写用户级（`layer` 只收 `user`） |
| `GET /api/team` | **进程里**现在有谁 + 共享任务板（跨会话；`app.tsx` 的轮询和嵌入宿主用它，见下） |
| `GET /api/team/subagents[?root=<sessionId>]` | **这一段会话**派出去过的子 agent 整棵子树（库 ∪ 内存名册；`root` 必填，见下） |
| `GET /api/sessions[?q=…]` | 会话列表；带 `q` 是检索：标题子串（不分大小写）+ 正文 FTS5，标题命中排前面 |
| `POST /api/sessions` | 幂等确保会话，可带 `{workspace?}`（见下） |
| `GET /api/sessions/:id` | **这一段**自己那一行；**不受上一条那 50 行封顶**（见下） |
| `DELETE /api/sessions/:id` | 真删：Hub + SQLite + 检查点目录 + 它名下的后台作业 |
| `PATCH /api/sessions/:id` | `{title}` → 重命名，回改完之后的整行 |
| `GET /api/sessions/:id/messages` | 历史回放（**冷却 / 上个进程留下的也回放**）；`?limit=N` / `?before=<id>` 分页（见下） |
| `GET /api/sessions/:id/user-messages` | 这段会话里**人说过的话**（id + 短正文，给右侧刻度条；**不阻塞首屏**，见下） |
| `GET /api/sessions/:id/message-search?q=…` | 这段会话里**哪几条消息含这几个字**（id + role，界面 ⌘F 用；`?before=<id>&limit=N`，见下） |
| `POST /api/sessions/:id/messages` | 发消息，立即 202；这一轮在跑就**排队**；带 `@:` 时 202 上有 `references` 收据（见下） |
| `POST /api/sessions/:id/revive` | 打开会话那一刻把它接回本进程：200 `{revived}`；本来就活着是 `false`（2026-10-19） |
| `GET /api/sessions/:id/reference-candidates` | 打 `@:` 那一刻面板该列哪几段会话；`?q=` 只碰 id / cwd / 标题（**不搜转录文本**，见下） |
| `GET /api/sessions/:id/file-stat?path=…&path=…` | 这几条路径还在不在、是什么、打不打得开（「路径可点」的底座，一发最多 32 条，见下） |
| `GET /api/sessions/:id/file?path=…` | 一个**文本**文件的正文（图片 / PDF / 媒体走下一条） |
| `GET /api/sessions/:id/file-bytes?path=…` | 字节流。图片 / PDF / 媒体内联，**其余一律 `attachment`**（安全判断，见下） |
| `POST /api/sessions/:id/file-open` | `{path}` → 交给系统默认应用（**同机 + 工作区内 + 扩展名白名单**，见下） |
| `DELETE /api/sessions/:id/messages/:clientMessageId` | 撤回一条还在排队的消息；已经被这一轮取走时回 `withdrawn: false`（不是错） |
| `POST /api/sessions/:id/abort` | 中止本轮；排在后面、还没被取走的消息在 `returned` 里交还 |
| `GET /api/sessions/:id/approvals` | 重连后补拉挂起的审批 |
| `POST /api/approvals/:requestId` | `{outcome}` → 接回引擎 |
| `GET /api/sessions/:id/questions` | 重连后补拉挂起的提问 |
| `POST /api/questions/:requestId` | `{answers, skipped?}` → 接回引擎 |
| `GET /api/sessions/:id/capabilities` | 专家 / 技能 / 连接器三栏（方案 42 PR-1） |
| `GET /api/sessions/:id/skills/:name` | 一个技能的正文（方案 56 §1.2，**不计数**，见下） |
| `GET /api/mcp` | MCP 服务器清单（进程级，**不要 sessionId**；**不带 `env`**，见下） |
| `POST /api/mcp` | 加一台 MCP，写进 `~/.epoch/mcp.json`（**进程级 + 落盘，回环绑定才给**，见下） |
| `POST /api/mcp/remove` | `{name}` → 删掉 `mcp.json` 里那一台（**进程级 + 落盘，回环绑定才给**，见下） |
| `POST /api/mcp/:name/reconnect` | 重连一台 MCP（方案 56 §1.1，**进程级**，见下） |
| `POST /api/mcp/:name/login` | OAuth 登录：**202 + 异步**，结果走 `mcp-auth-changed`（**回环绑定才给**） |
| `POST /api/mcp/:name/logout` | 删掉本地 OAuth 凭据（**回环绑定才给**） |
| `GET /api/mcp/config` | `mcp.json` 的**原文** + 指纹（**回环绑定才给，含这条 GET**，见下） |
| `PUT /api/mcp/config` | `{text, revision}` → 整份覆盖（**只写盘，不生效**，见下） |
| `POST /api/mcp/config/apply` | `{revision}` → 把盘上那份搬进这个进程（**立刻生效**，见下） |
| `POST /api/skills/import/preview` | 「将导入什么」，不写字节（方案 42 §六，**进程级**，见下） |
| `POST /api/skills/import` | 从本机目录导入技能（**收路径不收字节**，见下） |
| `POST /api/skills/stage` | 把手里那一份字节交上来 → 一条本机路径（**回环绑定才给**，见下） |
| `POST /api/skills/remove` | `{name}` → 删掉一份用户级技能（**不可撤销；只有用户级删得掉**，见下） |
| `GET /api/skills` | 进程级技能清单（带 `version` / `scope`，不挂会话） |
| `GET /api/skills/:name/files` | 一条技能的全部文件（base64；**回环绑定才给**） |
| `POST /api/roles` | 新建一个身份，写进 `~/.epoch/agents/`（**回环绑定才给**，见下） |
| `POST /api/roles/remove` | `{name}` → 删掉一份用户级身份（**不可撤销；回环绑定才给**，见下） |
| `GET /api/plugins` | 装着的 + `pendingRestart`（**进程级**，2026-11-06 起不再带市场那一半） |
| `POST /api/plugins/install/local/preview` | `{path}` → 「将安装什么」，不写字节（**本机一条绝对路径**，见下） |
| `POST /api/plugins/install/local` | `{path, token, replace?}` → 真装（**回环绑定才给；装完不生效**，见下） |
| `POST /api/plugins/export` | `{name}` → 打成一个 zip（**zip 字节；回环绑定才给**） |
| `POST /api/plugins/stage` | `{path}` 或原始字节 → 落地 + 解包 + 验清单 → 一条目录路径（**回环绑定才给**） |
| `POST /api/plugins/update/preview` | `{name}` → 更新会带来什么（**更新也要过这一道**，见下） |
| `POST /api/plugins/update` | `{name, token}` → 真更新（**回环绑定才给**） |
| `POST /api/plugins/view` | `{name}` → **已经装着的**那个是什么（只读盘，**回环之外也放行**，见 `docs/PLUGINS.md`） |
| `POST /api/plugins/uninstall` | `{name}` → 卸载（**回环绑定才给**；卸完那些扩展物还活着，见下） |
| `POST /api/plugins/toggle` | `{name, enabled}` → 启用 / 停用（**回环绑定才给**；重启才生效，2026-10-23） |
| `GET /api/sessions/:id/security` | 权限 / 沙箱 / 策略 / 工作区 / 审计流水五块（42 PR-3） |
| `GET /api/sessions/:id/tools` | 有哪些工具、此刻谁在挡它们（43 §七，**不是上一条的子集**） |
| `GET /api/sessions/:id/settings` | 每一项设置赢在哪一层 + 能写进哪几层（43 §七，见下） |
| `POST /api/sessions/:id/settings` | `{key, layer, value}` → 把一个键写进一层（见下） |
| `GET /api/sessions/:id/model` | 这个会话**此刻**用哪个模型（方案 26，见下） |
| `POST /api/sessions/:id/model` | `{model}` → 立刻换；给 `null` = 回到配置里那个；`@<id>` = 换到「我的模型」里那一条 |
| `POST /api/sessions/:id/model/effort` | `{effort}` → 这段会话的推理强度（不写盘）；`null` = 回到那一条的默认档 |
| `GET /api/models` | 「我的模型」列表 + 供应商目录 + 主 / 副 / 降级的指向和实际落点（**进程级**，见下） |
| `POST /api/models` | `{entry, apiKey?}` → 新建或更新一条（key 只进不出） |
| `POST /api/models/:id/delete` | 删一条（正被主 / 副 / 降级引用着回 409） |
| `POST /api/models/refs` | `{slot, id}` → 主 / 副 / 降级改指向哪条；`id: null` = 跟随主模型 |
| `POST /api/models/test` · `POST /api/models/discover` | 用**还没保存的草稿**测试连接 / 列模型（不落缓存；连不上是 200 + 码） |
| `GET /api/sessions/:id/commands` | 自定义斜杠命令表（**不含正文**，见下） |
| `GET /api/sessions/:id/tasks` | 后台任务 + 输出尾巴（**进程级**，见下） |
| `GET /api/sessions/:id/plan` | 已批准的计划（**单数**）+ plan 模式的状态 |
| `POST /api/sessions/:id/plan` | `{action:'enter'|'exit'}` → 进 / 出 plan 模式 |
| `GET /api/sessions/:id/permission` | 此刻是哪一档 + 哪几档改不成（决定 20 ③，见下） |
| `POST /api/sessions/:id/permission` | `{level}` → 立刻换档（见下） |
| `POST /api/sessions/:id/approval-cache` | `{id}` → 撤销一条**缓存的审批决定**（**不是 `/approvals`**，见下） |
| `GET /api/sessions/:id/checkpoints` | 这个进程现在能退哪几轮（方案 27，见下） |
| `GET /api/sessions/:id/checkpoints/:turn` | 按下去之前那份清单：会动哪些文件 |
| `POST /api/sessions/:id/rewind` | `{turnIndex, scope, overwrite?}` → 真的退 |
| `GET /api/sessions/:id/artifacts` | 这个会话改过哪些**文件**（42 PR-4） |
| `GET /api/sessions/:id/workspace` | **这个会话**绑在哪儿（见下） |
| `GET /api/workspaces/dirs[?path=][&at=home][&hidden=1]` | 这台机器上有哪些目录（**进程级**，见下） |
| `POST /api/sessions/:id/workspace` | `{root}`|`{none:true}` → 给还没选地盘的那一档选（方案 55 PR-1，见下） |
| `POST /api/workspaces` | `{parent, name}` → 新建一个目录（一次 `mkdir`，见下） |
| `POST /api/workspaces/pick` | 在**服务进程那台机器**上弹一个系统目录框（**同机那一档**，2026-09-01，见下） |
| `POST /api/workspaces/reveal` | `{root, file?}` → 在文件管理器里打开这个目录（或 `file` 所在那一层；**同机 + 已知工作区**，2026-11-04，见下） |
| `GET /api/sessions/:id/diff` | **这个会话**的工作区 diff（见下） |
| `GET /api/sessions/:id/diff/file?path=…` | 清单里那**一个**文件的两侧内容（2026-10-21，按需取，见下） |
| `GET /api/sessions/:id/branch` | 这个会话的地盘此刻在哪个分支（2026-10-28，**轮询用**，见下） |
| `GET /api/schedules` | 定时任务两个 tab 一次取齐（**进程级**，方案 45 PR-3，见下） |
| `GET /api/schedules/pending` | 还欠着的那几张欠条（dock 第四档 / 侧栏那个点，PR-4）。⚠️ `pending` 是**保留段**不是 id |
| `POST /api/schedules` | 建一条定时任务，**同时注册进 OS**（见下） |
| `GET /api/schedules/:id` | 一条的全部字段 + 下次运行 + 清单适不适用 |
| `PATCH /api/schedules/:id` | 改一条；`{enabled}` 就是那个启用开关 |
| `DELETE /api/schedules/:id` | 删一条，**同时撤掉 OS 注册**；录像留着 |
| `GET /api/schedules/:id/runs` | 这条任务的运行记录（最近 20 次，最新在前） |
| `POST /api/schedules/:id/run` | 「先跑一次」——**真的起一次 agent 运行**（见下） |
| `POST /api/schedules/:id/cancel` | 终止正在跑的那一次（2026-11-04）。没在跑回 `no-active-run`，不假装成功 |
| `POST /api/schedules/:id/fix` | `{runId}` → 把那一轮的欠条加进授权清单（见下） |
| `GET /api/schedules/:id/runs/:runId/recording` | 那一次的 `WireEnvelope` 录像（见下） |
| `GET /api/sessions/:id/goal` | **这段会话**的目标 + 表单那四把尺子（方案 52 PR-4，见下） |
| `POST /api/sessions/:id/goal` | `{objective, maxRounds?}` → 建一个（**人专用**） |
| `PATCH /api/sessions/:id/goal` | `{action}` → edit / budget / pause / resume / complete。**`blocked` 不在这条路上** |
| `DELETE /api/sessions/:id/goal` | 清掉这个目标 |
| `GET /api/goals/blocked` | 哪几段会话此刻卡着（dock 第四档，**进程级**，见下） |
| `GET /api/artifacts/:sid/:name` | 工具产出的大块内容 |
| `GET /*` | 静态产物 + SPA 回退（`assets.ts`） |

载荷契约在 [protocol/src/wire-rest.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-rest.ts)，能力页那个端点 的在 [wire-capability.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-capability.ts)，SSE 的信封在 [wire.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire.ts)。浏览器 import 的是同一份 —— 两边都不许再本地 声明一份。**第三方要接这套 API 自己做前端**（跨进程 / 跨语言 / 跨设备）的话， 清单以外还要知道的六件事（鉴权两段式、`Origin` 校验、`seq` 与 `Last-Event-ID` 续传、 断连不等于人走了）写在 [docs/EMBEDDING.md §10](https://npmcn.com/package/docs/EMBEDDING.md)。

### 分页与提问索引：`GET .../messages` + `GET .../user-messages`（2026-11-11）

**分页**（`?limit=N` / `?before=<id>`）2026-11-10 起就有，当时是手机端专用 （真机量到过 171 MB 的会话）；2026-11-11 起桌面端也走它。

四条口径：

-   只给 `limit`：最后 N 条；再给 `before`：这条**之前**的 N 条（游标分页，每页固定大小）；
-   **截了就回 `truncated: true` + `total`**，没截两格都不写（不写 `false`）；
-   `limit` 拼错当没给；`before` 拼错是 **400 `bad-cursor`** —— 「当没给」会静默吐全量， 翻页永远不前进。
-   **`?turnMinMessages=&turnMinTurns=` 让切点优先落在轮的开头**（2026-11-12，口径同 deepseek-harness 的 `turnWindow`）。两个必须成对给；`turnMinMessages` 必须 **小于** `limit`（缺省那一页也算），否则 **400 `bad-turn-window`** —— 那一档不报错， 只会让对齐静默失效。不给这两个参数时行为与之前逐字相同（嵌入宿主 / 老客户端）。

⚠️ 为什么「小于」是硬校验：两条判据在同一个循环里赛跑，`minMessages === limit` 时 条数判据总是先到，轮对齐一次都不会发生 —— 页还是切出来了，只是切在一轮中间。

实现上多了一条可选快路：`WebRuntimeView.sessionStore.loadMessagePage()` （runtime 的 `SessionStore` 实现了），它**先按游标/水位线切行、再投影**。没有这条快路 的宿主（嵌入方自己拼的 runtime）走「全量再切」那条老路，两条的答案逐字相同 —— `__tests__/messages-page.test.ts` 里有一条专门比它们。

**`user-messages`** 只回 `role: 'user'` 的行（`id` + 截到 200 字的正文），不解析 `parts`、不带 tool 行、不带账。界面右侧那条提问导航要「整段会话里有哪些提问」， 而历史是分页拿的 —— 让它跟着已加载那几页走，导航就会缩水；反过来为它拉全量历史， 分页就白做了。两万条那种会话的全量历史是 171 MB，而这一份是它的千分之一。

⚠️ **挑 `role: 'user'` 就完事，这里不判「哪些不算提问」**：系统留痕（换人那条）也是 `role: 'user'`，而认它的函数在 web 的 `system-note.ts` 上。在这里再写一份判定就是 给同一个问题第二个答案。

### 字面检索：`GET .../message-search`（2026-11-12）

界面那条 ⌘F 搜的是 **DOM**（判据在 web 的 `thread/find.ts` 文件头），而历史是分页 拿的 —— 「已加载那几页以外还有没有这几个字」浏览器手里没有材料答，于是没有这一条 之前，它在一段长会话里说出的「无结果」只说了「屏幕上这几十轮里没有」。

**它只回答「更早还有没有」，不回答「在哪一处」**：载荷只有 `id` + `role`，界面拿 `id` 当 `m<id>` 锚，调 `thread/reveal.ts` 那套一页页往前拉，落地之后重新扫 DOM 才亮得起来。所以**计数和高亮永远来自 DOM** —— 这一条说错一个字的代价只能是多跳 一页，不会凭空点亮一段屏幕上看不见的字。

三条口径：

-   **字面、连续**（`LIKE`，不是 FTS 的分词 OR）：两字中文（「模型」）在两张 FTS 索引里 都落不到（unicode61 把一整串中文切成一个 token、trigram 门槛是三字符），拿 FTS 预筛就是「两字中文永远回更早没有」，而且不报错；
-   **顺序最新在前**（`id` 递减）：调用方通常 `limit: 1`，要的就是「离我最近的那条 更早的」；
-   `before` 拼错是 **400 `bad-cursor`**（同 `messages`）；空 `q` 是**空数组不是 400** （还没打完字的那一发不该变成红横幅）。

`WebRuntimeView.sessionStore.searchMessageText()` 是可选快路（直接查库，不只看 本进程手里有没有这段会话）；没实现的宿主由 `api.ts` 从 `loadMessages()` 里折一份， 两条的答卷逐字相同（`__tests__/message-search.test.ts` 里有一条专门比它们）。

### 两张名册：`GET /api/team` 与 `GET /api/team/subagents`（2026-11-06）

它们答的是**两个问题**，别合并：

| 端点 | 问的是 | 数据源 | 谁读 |
| --- | --- | --- | --- |
| `GET /api/team` | 「**这个进程**里现在有谁」 | `runtime.team`（进程内存） | `app.tsx` 那条「子会话还在跑吗」的轮询、嵌入宿主 |
| `GET /api/team/subagents?root=` | 「**这段会话**派出去过谁」 | 会话库 ∪ 内存名册 | `/agents` 那个 picker |

`root` **必填**（缺了 400 `bad-request`），而且它可以是一段**子会话** —— 根由服务端 沿 `parent_id` 走到顶再列，所以坐在子 agent 的会话上打开 `/agents`，看到的是 同一棵树，不是一段空。

两边的合并规则（载荷是 `WireSubagentListResponse`）：

```
  会话库（派出去过谁）        内存名册（此刻谁还在）
      库有册无 → live:false, status 'unknown'（上个进程留下的）
      两边都有 → 状态取册里（running / finalized / …）
      册有库无 → 追加（刚派出、行还没落的那一小段不会闪一下不见）
```

`closed` 是第三档：本进程里被 `close_agent` 关掉的。它只活在进程里 （tombstone 有界 200 条），重启之后同一条会读成 `unknown` —— 那是一件真事， 不假装成「已收工」。归档的子会话由库那一步滤掉（`SessionDB.subagentTree`）。

⚠️ **`sessionStore` 为 null（SQLite 起不来）时回落内存子树**，不是 503：那是这个 界面在这一轮之前的行为，而「只看得到本进程派出去的」并不是一次失败。 ⚠️ **别把 `/api/team` 改形状去合并**：嵌入宿主（`examples/electron-minimal` 一类） 读的就是它，而这个 picker 从来没读过它以外的东西。

### 子 agent 的会话：`GET .../messages` 不报「这一轮断了」（2026-11-06）

`listMessages` 里那个 `interruptedTurnFrom` 的判据原来是 `flight === null && openTurnFrom !== null` —— 而子 agent 的会话（`source = 'delegate'`） **不在 Hub 里**，`hub.persistedThrough()` 对它恒为 `null`。子任务现在从开跑就按步 落盘（runtime 的 `delegate-recorder.ts`），于是正在跑的那一段会命中这一档，被画成 「中断了」。

对子会话这一格本来就没有意义：它是一次派活的执行记录，不能接着聊，也没有「续上」 这个动作。所以按 `source === 'delegate'` 直接不报。

⚠️ **别把它改成「去问一遍成员表」**：成员表是进程内存，只答得出本进程派的；而这一格 要的是「这段会话是不是子会话」—— 两个问题，两个来源。

### `POST` / `GET /api/appearance` —— 界面外观偏好（2026-09-29，GET 2026-10-19）

```
POST /api/appearance    {theme?: 'system'|'light'|'dark', lang?: 'system'|'zh'|'en',
                         sidebarWidth?: 220..420 的整数, accent?, fontSize?, …}
                     →  200 {ok: true, theme?, lang?, sidebarWidth?, …}   ← 盘上现在那一份

GET  /api/appearance →  200 同一个形状（只有**用户真的选过**的键）
                     →  503 {error: {code: 'no-appearance-store'}}       ← 宿主没接这条路
```

`sidebarWidth`（2026-10-14）是用户拖过的宽屏侧栏宽度，同一个文件、同一条路：首屏 URL 上 拼成 `?sidebarWidth=`，值域是 protocol 的 `APPEARANCE_SIDEBAR_WIDTH`，越界 / 非整数一律 400、 不替它夹。宿主也可以在 `createWebServer({ ui: { sidebarWidth } })` 给初值，口径同主题。

**它治的是「用户选的外观活不过一次进程重启」**：主题和语言原来只写在浏览器的 `localStorage` 里，而它**按 origin 隔离** —— 嵌入宿主用 `port: 0` 时每次启动都是 一个新的 `http://127.0.0.1:<新端口>`，上次那份读不到（升级必然重启一次， 所以它在升级那里最显眼）。这一条把偏好落进 `~/.epoch/appearance.json`， 下一次启动由 `createWebServer()` 拼进首屏 URL（`?theme=&lang=`）。

四条边界：

-   **无会话；写只能是 POST，读有两条路。** 路径上没有 `:sessionId`（判据同 `PUT /api/last-session`：落点是**进程级**文件）；写那一条非 GET 是硬要求 —— `auth.ts` 的 Origin 校验只在非安全方法上要求，而它会往用户磁盘写文件。
-   **载荷是补丁，所以不是 PUT。** 缺席的键 = **那一格别动**，不是「设成跟随系统」； 同一个 URL 上提交 `{theme}` 和 `{lang}` 得到两种结果，那不是幂等的全量替换。
-   **两条读路，两个读者。** **第一方界面**读的是 `server.url` 上那几个 query 参数 —— 它必须发生在第一帧之前，而一次网络往返赶不上；服务端拼的是「盘上那份压过 `ui` 初值」之后的结果。⚠️ 每次首屏导航（那次换 cookie 的 303）都会按**当下**盘上那份 重盖一遍 —— `server.url` 是启动那一刻的常量，而盘上那份会变。 **嵌入宿主**读的是 `GET /api/appearance`（它够不到 iframe 里那条 URL）， 答的是「盘上此刻那份」；两条路共用同一份载荷形状和同一道值域过滤，GET 是纯读 （不查会话、不落盘）。判据在 protocol 的 `WireAppearanceRequest` 上 「读回来有两条路」那一节。
-   **认不出来的值回 400，一个字节都不落盘。** 值域的判定在消费侧 （写入口 = 这里，读出口 = `storedUiPreset` 和内联脚本），存储那一层只做持久化 —— 手改出来的坏值读得出来、用不上、也不报错。第一方界面**不看看回执** （它那一发是 fire-and-forget，写不进去就静默降级），那两档状态码是给排查的人 和自己画前端的宿主的：503 = 宿主没接这条路，500 = 写盘失败（带原话）。

宿主那一侧的完整语义（`ui` 的准确含义、那张三档对照表、为什么偏好不落在 `config.yaml`）在 [docs/EMBEDDING.md §9](https://npmcn.com/package/docs/EMBEDDING.md)； 存储本体和四条「为什么不塞进 `display.*`」的判据在 [core/src/appearance.ts](https://npmcn.com/package/%40epoch-agent/core/src/appearance.ts)。

### `/api/host/*` —— 宿主把一句话送进输入框（2026-09-28）

```
POST /api/host/compose            建/取一段会话，记一笔「该往输入框里填什么」，广播一帧
GET  /api/host/compose            此刻还没被认领的那一笔（没有就是 null）
POST /api/host/compose/:id/take   认领那一发自动发送（一次性）
```

**这一组治的是「宿主够不着那张页」**：宿主能用 REST 建会话、发消息，但改不动这张 页的状态 —— 草稿只在内存里（`web/composer/draft.ts` 明写不落盘），而入站那条路 刻意关着（「我们不监听 `message`」，判据在 `embedding` §9 末尾）。于是「在我自己的 界面上点一个动作 → epoch 窗口里开一段会话、输入框里已经写好那句话」说不出口。 宿主侧的完整接法在 [EMBEDDING.md §10.7](https://npmcn.com/package/docs/EMBEDDING.md)。

#### 载荷与回执

```jsonc
// POST /api/host/compose —— 目标会话二选一（都不给 = 引导会话那条老路）
{
  "sessionId": "ses_…",                                   // 已经存在的那一段
  "workspace": "/abs/path", "create": true, "role": "…",   // 或者照 POST /api/sessions 那一套
  "text": "把这段字填进输入框",                              // 可选；不给 = 只把界面切过去
  "autoSend": false                                       // 可选；true 而没给 text → 400
}
→ 200 { sessionId, created, composeId, expiresAt }
```

建会话那一支走的是 `POST /api/sessions` **同一个函数**（`ensureSession`），所以 `bad-workspace` / `unknown-role` / 503 `no-provider`（没有可用的模型）那几档一个字都不缺。 `sessionId` 不认识 → 404 `unknown-session`（判据逐字同 `PUT /api/last-session`： **Hub 里没有不算不存在** —— 上个进程留下的会话正是「往已存在的那段里填」的主场景）。

#### 三条投递路径，客户端按 `id` 去重

| 页面此刻在哪 | 走哪条 |
| --- | --- |
| 已经开着、流连着 | SSE 帧 `host-compose`（信封上那个 `sessionId` 是**哪一段**，不是收件人） |
| 刚启动，`/config` 已经回来、流还没接上 | 连上那一刻自己打的 `GET /api/host/compose` |
| 比这一笔还晚开 | `GET /api/config` 的 `hostCompose`（首屏一次落到位，不闪） |

中间那一格是**必须有的**：前两条各管一头，而页面启动的那几十毫秒**两条都不在**， 那一笔谁也接不住 —— 宿主拿到 200，屏幕上什么都没有。治法不是给帧再加特例，而是 这个仓库既有的规矩（`hub.subscribe()` 那个 `Last-Event-ID` 参数的注释）： **新连接自己拉一次状态，流上只走增量**。所以 `GET` 这一条是**读**、不消费， 几个标签页各拉一次都拿得到。

#### 一笔待办：进程内存、按会话一格、十分钟

它是**一笔此刻的投递**，不是配置 —— 进程重启就没了，也不该有：一个三天前的 「帮我填这句话」在启动时自己冒出来，比丢掉它坏得多。同一段会话先后记两笔时**新的 盖掉旧的**（认领过的那些不盖：它们是回执，不是待办）。

#### ⚠️ 兑现它的是**页面**，不是这里

服务端只记、只发帧；把字填进框、以及按下发送，都是页面的活（乐观气泡、排队回执、 输入框那两道闸全在 `composer` 那一侧）。三条代价如实写在这儿：

-   **页面没开着、或者用户一直不开，自动发送不会发生** —— 字在待办里等着，过期为止。 宿主想确证那一发到底出去没有，认事件流上的 `run-start`（它本来就有这条路）；
-   **认领不是「一定发得出去」的保证**，只是「不会重复发」：认领之后 `submit()` 照旧 可能因为只读 / 输入框还锁着而早退，那时字留在框里，由人来按；
-   **不额外加回环闸**：它给的能力不比 `POST /api/sessions/:id/messages` 多一个字 （那条端点也没有那道闸），而那道闸守的是「这一发会往用户盘上写东西 / 起一个 子进程」那一类（`POST /api/mcp` / `POST /api/roles` / 插件那五条）。

#### 为什么 `take` 是一次性的

多开两个标签页时两页都会切、都会填，但只有认领到的那一页会按下发送 —— 两个窗口 各发一次会让模型白跑一轮，而那是钱和一段看不懂的记录。它**还挡住了第二条重放 路径**：这一帧和其它 Hub 事件一样进环形缓冲，断线重连带 `Last-Event-ID` 补帧时会 **再投一次**，而那种重复在屏幕上是「用户手快发了两次」的形状，查不出来。

四档答案（`ok` / `taken` / `expired` / `unknown`）**互斥且都说得出话**，而认领过的 那一笔**留着不删、只标 `takenAt`**：删掉的话第二次认领会答 `unknown`，而那是一句 假话（它确实存在过、也确实被认领过），下一个查这件事的人会顺着去查一个 「不存在的 id」。

### `/api/host/badges` —— 宿主在运行中开关的未读点（2026-10-15）

```
POST /api/host/badges   { "id": "inbox", "dot": true }   → 200 { "dots": ["inbox"] }
GET  /api/host/badges                                     → 200 { "dots": [...] }
```

`sidebarHeadMenu`（侧栏头那几枚宿主图标）画不画是配置；某一项**此刻有没有未读**是宿主 运行中才知道的事，所以它只在 Hub 的内存里，**进程重启即清空**。`id` 非空字符串、`dot` 只认布尔，别的一律 400 `bad-request`；不认得的 `id` 照收（服务端不拿它去对配置）。

-   **真变了才广播**一帧 `host-badges`（**整张表**、进程级，信封上没有 `sessionId`）， 同一个值写两遍不发第二帧；
-   **新连接补一帧此刻的整张表**（连接级，`seq: 0`），排在 `connected` 和续传补帧**之后** —— 补回来的旧表先落、此刻这份最后落。**不走 compose 那条「连上之后自己 GET 一次」**： 帧载荷是整张表，一发 GET 和流上的帧走两条连接、谁先到不定，晚到的旧表会盖掉新表。 这个进程里表一次都没变过时不补（从没用过这一格的流一帧都不多）；
-   首屏 `GET /api/config` 带 `hostBadges: { dots }`（有亮着的才带），只管流接上之前那一小段；
-   没有回环闸，判据同 compose 那条（不写盘、不起子进程），POST 照旧过 `Origin` 闸。

形状和判据的真源在 protocol 的 `wire-host-badges.ts` 文件头。

### `/api/host/tray` + `/api/host/tray/items` —— 宿主决定托盘项是哪一态、长什么样（2026-11-04）

```
POST   /api/host/tray        { "id": "cloud-sync", "active": true }
                              → 200 { "active": ["cloud-sync"] }
POST   /api/host/tray/items  { "id": "cloud-sync", "label": "云同步 3/10", "icon": "gauge" }
                              → 200 { "active": [], "items": { "cloud-sync": { … } } }
DELETE /api/host/tray/items?id=cloud-sync    撤这一项 → 回落配置那份
DELETE /api/host/tray/items                  撤整份
GET    /api/host/tray                        → 200 { "active": [...], "items": { … } }
```

`trayMenuLeft` / `trayMenuRight` 里**这一项长什么样**是配置（两副文案和图标）；它**此刻 选没选中**、以及**此刻被覆盖成什么样**是宿主运行中才知道的事，所以和未读点同一族： 两张表都只在 Hub 的内存里，**进程重启即清空**（覆盖清空后回落配置那份）。

-   **`items` 是逐字段覆盖**：给出来才盖，没给的用配置那份；可覆盖的五格就是配置那五格 （`label` / `activeLabel` / `icon` / `activeIcon` / `closable`）；**`closable: false` 是一句有效的话**，不是「没给」；
-   ⚠️ **覆盖改不了「有哪些项、什么顺序」**：配置仍是这一件事的唯一真源，运行期加不了一项， 但**不认得的 `id` 照收**（同 `active`：先覆盖再改配置也走得通）；
-   `id` 非空字符串、`active` / `closable` 只认布尔、两个数组字段里的字符串**空串 400**、 `items` **一个覆盖字段都不给也 400**，别的一律 400 `bad-request`；
-   **各项互相独立**：选了第二项不会把第一项挤掉（互斥是宿主的业务决定，不是这张表的约束）；
-   **真变了才广播**一帧 `host-tray`（两张表一起、进程级、没有 `sessionId`）；
-   **新连接补一帧**（`seq: 0`），排在 `connected` 和续传补帧之后；全取消了 / 整份覆盖撤了 之后照补空表；一次都没变过时不补；
-   首屏 `GET /api/config` 带 `hostTray: { active, items? }`（**两张都空才不带** —— 只有覆盖、 一项都没选中时也得带，否则页面先画配置那份、等流接上才变，闪一下）；
-   没有回环闸，判据同 badges；写端点照旧过 `Origin` 闸（`DELETE` 也过）。

⚠️ **页面不自己切状态、也不缓存这份覆盖**：点一下只发一条宿主事件，屏幕上变不变看 宿主有没有调这两个端点。⚠️ **这两个端点治的是「不重启改文案」**：配置是装配期预置的 （`runtime.config` 启动时读一次，全仓没有热重载），改 `trayMenuLeft` 里的一个字只能重启 引擎才生效；随状态变的文案（「同步中 3/10」）走 `items` 那条。

形状和判据的真源在 protocol 的 `wire-host-tray.ts` 文件头。

### `/api/host/topbar` —— 顶栏左侧那排按钮的运行期清单（2026-11-13）

```
PUT    /api/host/topbar  { "items": [{ "id": "sync", "label": "云同步 3/10", "icon": "cloud" }] }
                          → 200 { "items": [...] }       整份替换
DELETE /api/host/topbar   → 200 {}                        撤掉运行期那份，回落配置
GET    /api/host/topbar   → 200 { "items": [...] }        （没下过是 {}）
```

`topbarMenuLeft` 里**这一排长什么样**是配置（常态、首屏就有）；宿主想随业务状态实时改字 或增删按钮时走这条，**不写盘、已开的页面当场重画**。

-   **生效规则一句话**：运行期那份存在就整份顶掉配置（**`items: []` 是一句有效的话** = 宿主把左侧清空了）；`DELETE` 掉才回落配置。这两件事在网线上是两个形状，别混；
-   **整份替换，不是补丁**：请求体里没有的项会从运行期那份里消失（同 `PUT /api/host/slots`）；
-   校验：`items` 必须是数组，每项 `id` / `label` 非空、给了 `icon` 就不能是空串、 `id` 不许重复；`items` 缺了也是 400（拼错的键名不能被读成「清空」）；
-   **真变了才广播**一帧 `host-topbar`（逐项逐格连顺序一起比）；**新连接补一帧**（`seq: 0`）， 清空之后照补；一次都没下过时不补；
-   首屏 `GET /api/config` 带 `hostTopbar`（**没下过才不带** —— `items: []` 也带， 否则页面先画配置那份再闪一下）；
-   `GET` 不收 `lang`（响应体里没有一个我们渲染的字）；`PUT` 收（400 那句是我们的）； `DELETE` 不收（不读请求体）。三条都过鉴权，`PUT` / `DELETE` 过 `Origin` 闸。

形状和判据的真源在 protocol 的 `wire-host-topbar.ts` 文件头。

### `/api/sessions/:id/goal` + `/api/goals/blocked` —— 目标那一组（方案 52 PR-4）

**四条 session-scoped + 一条进程级**，而那条分界是硬的：前四条问「**这一段**会话的 目标」，第五条问「这台机器上哪几段卡着」—— 后者挂到 `/sessions/:id/` 底下的话， dock 得先知道有哪几段会话才问得出来，而它要的正是那个还不知道的东西 （判据同 `GET /api/schedules/pending`）。

#### ⚠️ 借的是 `runtime.goalCatalog`，**不是** `runtime.goals`

后者是**引导会话**那一份（`GoalControl`，收窄到一段会话）。拿它去答 URL 上那个 sessionId 就是方案 30 §9.5 那条 bug 的形状 —— 编译得过、跑得通、只是答错会话 （`WebRuntimeView` 上那个进程级的 `checkpoints` 就是因为这个被删掉的）。 全文在 [src/goal/view.ts](https://npmcn.com/package/%40epoch-agent/server/src/goal/view.ts) 的文件头。

#### ⚠️ 它**不像检查点 / 计划那样按「这个进程在不在跑这段会话」分支**

目标**没有内存态** —— `GoalService` 每次现读 SQLite，所以一段冷却掉的会话在这一格 上和活着的一样准：

| 那一格 | 真源 | 冷却掉的会话答得出吗 |
| --- | --- | --- |
| 检查点 | 这个进程的 `CheckpointManager` | ❌ 只能回空列表 |
| 计划 | 内存那份优先，回退读盘 | ⚠️ 读盘那份可能旧 |
| 目标 | **只有 SQLite 一份** | ✅ 一样准 |

#### 两种「没有」，两个状态码

```
这个进程没有目标存储（会话库起不来）  → 503 no-goal-store   「问不出来」
这段会话没有目标                      → 200 {goal: null}     「真的没有」
```

混成一个 `goal: null` 的代价很具体：界面会对着一台没有会话库的机器画出 「这个会话还没有目标，来定一条」，而按下去必然失败。 ⚠️ **`GET /api/goals/blocked` 在同一档上回空数组而不是 503** —— 那是一条通知条的 素材，问不出来该少一条通知，不该让首屏那一发变成一条红字。

#### ⚠️ 被拒是 200 + `ok:false`，`blocked` 那个态**在契约上说不出口**

十个拒绝码（`busy` / `wrong-phase` / `bad-budget` …）走 200，判据同 `WireScheduleSaveResponse`：请求本身完全合法，是这个值 / 这个态不接受， 而那句话要出现在用户刚敲字的那一格旁边。400 只留给「这个 JSON 读不懂」。

而 `PATCH` 的 `action` **只有五个值，`blocked` 不在其中**：那个 `code` 是给程序 路由用的键（dock 第四档按它决定给「加进授权清单」还是「去那段会话」）， 收一个浏览器编出来的 `needs-approval`，方向是提权。两道闸各自独立 —— 契约那一道在 `WireGoalUpdateRequest`，运行时那一道在 [src/goal/body.ts](https://npmcn.com/package/%40epoch-agent/server/src/goal/body.ts)。

### `/api/schedules*` —— 定时任务那一组（方案 45 PR-3）

**这九条上一个 `:sessionId` 都没有，而那不是漏了。** 一条定时任务不属于任何一段 会话 —— 它反过来**产出**会话（每次运行落一条 `source='schedule'` 的）。挂到 `/sessions/:id/` 下面会得到一组名字是会话级、行为是进程级的端点，比不一致更坏 （判据同 `POST /api/mcp/:name/reconnect` 和 `GET /api/workspaces/dirs`）。

载荷契约在 [protocol/src/wire-schedule.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-schedule.ts)， 而**任务本身的形状**（`ScheduleDefinition` / `ScheduleRun`）在 [protocol/src/schedule.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/schedule.ts) —— 网线上原样发它们， 不做投影。

四件在这一组上和别处不一样的事：

1.  **校验不过是 `200` + `issues` 非空，不是 400。** 请求本身完全合法，是表单里 某几格填错了，而那几格要在表单上逐格标红。这一组的 400 只留给「这个 JSON 读不懂」（`schedule/body.ts` 那一层）。发下去的是**码**不是句子 —— 同一条校验在 CLI 和表单上的措辞本来就该不一样（CLI 说「`--max-turns` 要是 正整数」，而表单里没有这个东西）；
2.  **定时任务只剩轮次和超时两把尺子**（2026-10-29 撤掉了预算那一格 `maxBudgetUsd`）。所以 `GET /api/schedules` 回的 `defaults` 里只有那两格 加一份清单，`POST` 也不再要求、不再接受预算；
3.  **`POST .../run` 会真的起一次 agent 运行**，按那条任务自己的清单不经确认地跑 命令，而且可能跑到墙钟超时（缺省 15 分钟）才回来。它是这一组里最该守住 CSRF 的一条 —— 写成 GET 等于任何一个页面都能替用户点那个按钮。**跑失败也是 200**： 一次真的跑过、真的失败了的运行不是「请求发坏了」，而回来那份 `pendingApprovals` （欠条）正是界面接下来要画的东西；
4.  **`POST .../fix` 只收 `runId`，不收规则。** 欠条已经在库里那条 run 上了， 让浏览器回传等于允许它编一条 —— 而这条路的后果是往授权清单里加规则。

**录像那一条不再脱敏一次**：脱敏在落盘那一刻就做完了（core 的 `redactLine`， 跑在已经序列化过的一整行上）。在这一层补一道会让下一个人以为盘上那一份是干净的 —— 而盘上那一份才是真正会被 `cat` 的那份。

发出去之前**先把流式增量折回消息**（core 的 `coalesceRecording`）：盘上是一个 `text-delta` 一帧，几千个输出 token 就是几千帧，而这些帧在运行结束之后没有独立 含义（渲染那侧只是把它们拼成一段正文）。折完之后一帧 ≈ 一段正文 / 一次工具调用， `MAX_RECORDING_FRAMES`（4000）这把尺子才量得到真东西 —— **它量的是折叠之后的帧数**。

超了就截断 + `truncated: true`，界面必须把它画出来。⚠️ **截掉的是开头，留的是 尾巴**（`slice(-N)`）：一次运行里人要看的正是那句结论和 `finish` 那一帧上的账， 而留头去尾会让正文断在半句话上 —— 屏幕上那半截自己不会说「我是半截的」 （判据同 protocol 的 `NoticeCode: 'response-truncated'`）。

⚠️ 这一组借的是 `WebRuntimeView.schedules`（`ScheduleControlView`， 结构镜像在 [src/schedule/view.ts](https://npmcn.com/package/%40epoch-agent/server/src/schedule/view.ts)）。判定、注册进 OS、 跑一次、算下一次触发时刻**一样都不在这一层** —— 全在 core / runtime， 这个目录只做「HTTP ↔ 那几个方法」的翻译。

### `?lang=` —— 这一次响应用哪个语言（方案 58）

API 上有一个通用的可选查询参数 `lang`（常量在 protocol 的 `WIRE_LANG_PARAM`）。 **上面那张表里 `/api/` 下的端点都读它**（2026-08-18 起，PR-2 之前只有 `GET /api/config`），**两条例外，而且都是刻意的**：`GET /api/health`（响应体只有 `{ok, version}`，一句人话都没有）和 `GET /api/events`（见下面那条 SSE）。

⚠️ **它其实比「那张表里的端点」管得宽**（2026-08-21 起）：`guard` 拒了那一发的 四条理由、500 那句、`GET /*` 静态兜底那句 404 **也跟着它走** —— 那三处不属于表里 任何一条端点。判据见下面那张逐层对照表。

```
GET  /api/config?lang=en
POST /api/sessions/abc/permission?lang=zh    → 错误消息也跟着这个语言
```

-   **值是已解析的 `Lang`（`zh` / `en`）**，不是首屏 URL 上那个 `?lang=` 的偏好档 （`system | zh | en`）—— 两者拼法相同、值域不同，判据在 `WIRE_LANG_PARAM` 的 JSDoc 上
    
-   **没带 / 认不出来 → 沿用进程语言**（`EPOCH_LANGUAGE` / `config.yaml` / 系统 locale）， 不报错。这是这个参数出现之前的全部行为，逐字节保留
    
-   跟着它变的有四类：`GET /api/config` 的 `diagnostics`、**`{error:{code,message}}` 里那个 `message`**、`POST /api/skills/import[/preview]` 回的 `issues[].message` / `skipped[].message` / `detail`（那一批走 200 响应体，不是错误体）， 以及 **`guard` 拒了那一发 / 500 / 静态 404 那三句**（2026-08-21 起，见下）
    
-   ⚠️ **`code` 一个字都不跟。** 那是契约（前端只认它做分支），同 `Diagnostic.module`。 跟语言走的只有给人看的那句话
    
-   ⚠️ **那三条消息已经抽干净了**：`0f5bc0ac`（2026-08-22，方案 40 PR-6） 把 server 那 22 条清零，`web.unknown_session` / `web.no_endpoint` / `web.method_not_allowed` 就是最后三句。**server 的 i18n 基线今天是 0** （`i18n-baseline.json`），上一版这里写的「剩下那 22 条中的三条」是当时的形状。 ⚠️ 抽完必须**把 `lang` 递进去**（上面那段说的是同一件事）： `__tests__/wire-lang-threading.test.ts` 盯着这一条。
    
-   ⚠️ **「抽取完顺手把 `lang` 递进去」现在对它们全都成立** —— 2026-08-21 起 `langOf()` 的解析点在 `createRequestHandler`（在 `guard` 之前，全流程只此一处）， 所以每一句话的每一个调用点手边都有 `lang`：
    
    | 那句话 | 在哪一层 | 今天怎么样 |
    | --- | --- | --- |
    | 那四条鉴权拒绝理由（`DENY_KEY`） | `dispatch`，在 `guard` 拒绝那一支 | ✅ 已抽取，**跟着 `?lang=` 走** |
    | `服务端内部错误`（500） | `createRequestHandler` 的 `catch` | ✅ 已抽取，**跟着走** |
    | 静态那句 `找不到 X` | `handleStatic`（`dispatch` 调它） | ✅ 已抽取，**跟着走** |
    | `没有会话 X` / `没有这个端点` | `handleApi` 及其分组分发 | ⬜ 还没抽取；`lang` 在作用域里 |
    | `只支持 …`（`methodNotAllowed`） | `handleApi` 及其分组分发，外加 `dispatch` 里静态那条 `GET, HEAD` | ⬜ 还没抽取；两处 `lang` 都在手边 |
    
    上面前三格 2026-08-21 之前是 ❌（`langOf()` 那时在 `handleApi` 里解析， 它们三个都不在那条路上）。**挪解析点是一次决定，不是一次重构** —— 要答的是 「一条连凭据都没过的请求该不该拿到译文」。答案是「该」，三条判据在 [`src/routes.ts`](https://npmcn.com/package/%40epoch-agent/server/src/routes.ts) 文件头「三」第 4 条：`langOf()` 是纯函数、 值域闭合成三个值（注入不了一个字节）；请求本来就自己决定拿到四条理由里的哪一条 和什么状态码（选语言是严格更少的能力）；而 `GET /api/health` 排在 `guard` 之前， 鉴权之前本来就在渲染。落地证明在 [`__tests__/wire-lang.test.ts`](https://npmcn.com/package/%40epoch-agent/server/__tests__/wire-lang.test.ts) 那三个 describe， **决定本身**由仓库根那条门禁钉着（`langOf(` 只许一处，且行号小于 `guard(`）
    
-   **SSE（`/api/events`）上没有这个参数**，也不该加：那条流上一句我们渲染的文案都没有， 钉一个语言在一条长期活着的连接上只会造一个静默过期的值（方案 58 §2.2）
    
-   ⚠️ **它绝不改进程状态。** 两个标签页各切一种语言互不影响，用例钉在 [`__tests__/wire-lang.test.ts`](https://npmcn.com/package/%40epoch-agent/server/__tests__/wire-lang.test.ts)（诊断那一半）和 [`__tests__/wire-lang-handlers.test.ts`](https://npmcn.com/package/%40epoch-agent/server/__tests__/wire-lang-handlers.test.ts)（错误消息那一半）
    

> ⚠️ **往这张表里加端点的人注意：新 handler 必须收 `lang` 并往下递。** 这不是一句叮嘱 —— 仓库根的 [`__tests__/wire-lang-threading.test.ts`](https://npmcn.com/package/__tests__/wire-lang-threading.test.ts) 扫 `routes.ts` 里每一条分发调用，实参里没有 `lang` 就点名报错；同一份还查 「server 里每个 `t()` 都收了第三个参数」，以及「`langOf(` 只出现一次、而且排在 `guard(` 前面」。它防的是一类**只在合并点才存在的缺陷**： 一条并行分支加的 handler，在它自己那条分支上什么都不缺，合进来之后成为唯一一个 不认 `?lang=` 的口子，而它编译得过、跑得通、用例全绿。
> 
> ⚠️ 还有一条**否定性**的姊妹门禁 （[`__tests__/wire-lang-out-of-scope.test.ts`](https://npmcn.com/package/__tests__/wire-lang-out-of-scope.test.ts)）： 方案 58 盘完刻意**不接**的那两片（`core/src/config/keybindings.ts` 打在终端上、 `core/src/skill/scan.ts` 会落盘）不许收第三个参数。那两片的风险方向是反的 —— 「顺手接上」看起来完全正确，而接上之后终端 / 磁盘上那句话开始跟着某个浏览器 标签页跑，在任何一屏上都看不出来。

`GET /api/sessions/:id/settings` 回的不是值，是**值加上它赢在哪一层**：epoch 的 设置有六层来源（内置默认 → 用户级 → 项目级 → 项目本地 → 命令行 → 企业托管，见 [docs/SETTINGS.md](https://npmcn.com/package/docs/SETTINGS.md)），只显示「当前值」的设置界面会在 「用户改了一个开关、被上面某一层压回去」时撒谎。三个能被项目级覆盖的键 （`permissions` / `model` / `maxTurns`）另带一条六层链条，其余键的链条是**空数组** ——它们只可能有 ① 和 ②，画一条六层链等于暗示一件假事。

`POST` 同路径是写那一半（2026-08-15）。保存语义那三件（即时生效还是按保存 / 写哪一层 / 写完要不要重启会话）各有一个答案，判据全文在 [core/config/write.ts](https://npmcn.com/package/%40epoch-agent/core/src/config/write.ts) 的文件头，接这套 API 的人要知道 的是这三条：

-   **一次请求写一个键**，只能写 ② 用户级（`~/.epoch/config.yaml`）或 ④ 项目本地 （`.epoch/settings.local.json`），能写的键只有 `model` 和 `maxTurns`。 ③ 项目级不开：那份文件要进 git，一次保存改的是整个团队的配置
-   **「写进去会不会真的生效」在 `GET` 那份响应里就带着**（每行的 `writes[].futile`）， 不是写完之后的通知。写进的层压不过当前赢的那一层时，那个改动写了等于没写 —— 而这一句必须在用户按下保存**之前**说出来
-   **写完这个进程不会重读配置**（回执里的 `apply: 'restart'`）。落盘之后 `GET` 回来的值一个字都不会变，界面照旧印旧值、另说一句「重启后生效」。 把那一行乐观地改成新值，会显示一个这台机器上哪儿都不存在的状态

**停着的会话也答得出**（2026-10-15）：Hub 里没有这段会话时，服务端把会话库里记着的 工作区决定递给 runtime，③④ 和每一行的写目标按**那段会话自己的项目**算，读写两半 用同一份决定。只有说不出按哪个目录算的（会话库 v7 之前没记过决定的老行、记着的 目录已经被删了、不存在的 id）回 404 `unknown-session`。判据在 [api.ts](https://npmcn.com/package/%40epoch-agent/server/src/api.ts) 的 `settingsTarget`。

> ⚠️ **POST，而且只能是非 GET。** `Origin` 校验只在非安全方法上要求（见下面 「鉴权」那一节），而这条路会改用户机器上的文件。它也不是 `PATCH`：`GET` 那份 响应是一张来源报告，不是一份可以被局部替换的文档，而请求体里那个 `layer` 恰恰说明这一下写的是那份报告**背后六个文件中的一个**。

> ⚠️ ~**没有任何模型 / 权限相关的写端点。**~ **这句话 2026-08-15 分两次作废了**： `POST .../model` 那一条（方案 26 的 web 那一半）和 `POST .../permission` 那一条 （决定 20 ③）先后落地。它当初的判据（「模型和 key 由宿主在装配期预置， 用户不该换」）**只对 key 成立**，而两条端点都不碰 key：
> 
> -   `POST .../model` 换的是这个进程里正在跑的那个选择；
> -   `POST .../permission` 换的是这个进程此刻的权限档。
> 
> **两条都不写盘。** `GET /api/config` 那两个字段照旧只读，而且**故意不跟着变** —— 它们答的是「装配时读到的配置值」，也就是「下次启动是哪一档 / 哪个模型」。 拿它当「现在是什么」正是这个仓库反复在治的那种撒谎。要改盘上那份走 `POST .../settings`，那是另一条路、要重启才生效。

`GET /api/config` 还捎一个可选的 `brand`（宿主品牌词标，决定 19）：来源是配置项 `EpochConfig.brand`，宿主装配期预置，**没配就不带这个键**（同 `maxCostUsd` 的口径 ——那个键在不在**就是**界面上那一格显不显示的开关，回一个 `null` 等于把这个判断 推给每个客户端各自解读）。同样只读，没有写端点。写法和边界在 [EMBEDDING.md §9](https://npmcn.com/package/docs/EMBEDDING.md)。 同样的口径还有一个 `brandWhenSidebarCollapsed`（2026-10-14，`hide` / `show`，侧栏不在 屏幕上时品牌那一格的去留）：没配就不带这个键（读作 `hide`），这一层不替它补缺省值。 以及 `brandMenuId`（2026-10-15，点品牌那一格回给宿主的 `id`）：同样原样转发、没配就不带。

2026-09-02 起同一条端点上还有第二个这样的键：`sidebarMenu`（宿主自定义的**侧栏底部 那枚分体按钮**，来源 `EpochConfig.sidebarMenu`）。它的形状、口径、「没配就不带这个键」 都逐字同 `brand` —— **这一层原样转发，一个字都不改**： `label` 不翻译（它是宿主的话，不同于同一份响应里的 `diagnostics.detail`）， `icon` 认不认得出来也不在这儿判，那张白名单是第一方 Web UI 的私有词表， 而这条端点也服务着自己画前端的宿主。`sidebarMenu` 每项上的 `primary`（2026-10-13， 哪一项露在外面、其余收进「⋯」）同样原样带下去，没写就没有这个字段。 2026-10-15 起有第二个菜单键 `sidebarHeadMenu`（**侧栏头那几枚图标**，来源 `EpochConfig.sidebarHeadMenu`），口径逐字同 `sidebarMenu`。2026-10-19 起有第三个 `topbarMenu`（**顶栏右上那一枚图标按钮**，来源 `EpochConfig.topbarMenu`），口径同上。 同一天响应里还可能多一个 `hostBadges: { dots }` —— 那不是配置，是宿主运行中开关的未读点在首屏那一刻的样子 （有亮着的才带，见上面 `/api/host/badges` 那一节）。 2026-10-16 起多一个 `userMenuHost`（**「用户」那一格整张菜单归宿主自己画**， 来源 `EpochConfig.userMenuHost`；`true` 时那一格点下去只发一条 `user-menu` 事件，宿主那条入站命令 `open-settings` 的收端在 `app.tsx` 那一侧，**不在这条端点上**）。它是这一组里唯一的**布尔**键：「配了 `false`」和 「没配」在这一层照带、界面那一侧按 `=== true` 判。 2026-10-28 起多一个 `scheduleRemoveConfirm`（**定时任务那张卡上「删除」问的那一句**， 来源 `EpochConfig.scheduleRemoveConfirm`）：文案是 `{ zh, en }`，**原样转发、不挑语言**， 连 `{name}` 也不在这一层填 —— 填它是界面那一刻的事（这条端点不认识哪一张卡在删）。 没配就没有这个键，那时屏幕上是我们那一整句。它和上面几个菜单键**互不兜底**： 只配招呼（`blankGreeting`）的宿主拿到的是招呼，不多出一句删除确认 （`__tests__/schedule-remove-confirm.test.ts` 钉着这一条）。

⚠️ **这几个菜单键在这一层也互不兜底**：一个没配就是一个没有，**不许拿另一份顶上**。 一句 `?? runtime.config.sidebarMenu` 写下来，浏览器收到的是一份「宿主配了两个键」 的响应 —— 而 web 那边每一条用例都还是绿的，因为它们看到的确实是两个键。 钉这件事的是 `__tests__/sidebar-menu.test.ts` 里「只配一个，响应里就只有那一个」。

`/api/health` 在鉴权**之前**，其余全部在之后：不带 cookie 时 `/api/sessions` 是 401， 而 `/api/health` 仍然 200。`DELETE` / `PATCH` / `POST` 和别的写请求一样要过 Origin 校验， 少给 `Origin` 头就是 403 —— **`POST /api/sessions` 那个新的 `workspace` 字段没有例外**。

### `GET /api/sessions/:id` —— 按 id 问一行（2026-08-17）

**不是 `GET /api/sessions` 的一个特例，两者答的是两个问题。** 列表那一发答的是 「侧栏该列哪几段」，所以服务端按那个问题**过滤**（`?q=` 走 FTS5）、**封顶** （`DEFAULT_SESSION_LIST_LIMIT`，50 行）、**排序** —— 「调用方正看着的那一段在不在 结果里」从来不是它的约束。于是浏览器直达一段排在 50 名之后的旧会话（书签 / 历史 / 手敲 `/s/<id>`）时，那一发带不回它那一行，顶栏就把「我手上没有这一行」印成了 「这段会话没有名字」。这条端点是那第二条路：**按 id 问一行，无条件回。**

-   回**整行**（`WireSessionResponse`），拼法和 `PATCH` 那条走**同一个投影** （`toWireSummary`）—— 同一个名词只能有一份投影
-   **两个真源都不认识才是 404 `unknown-session`。** 口径同 `GET .../messages`： 被冷却的、上一个进程留下的会话 Hub 里没有但 DB 里有，那时照答，`live: false` 说的就是「Hub 手里没有它」
-   ⚠️ **不回一个 `{session: null}` 冒充「这段会话没有名字」** —— 那是这条端点要治的 那句假话的另一种写法

### 能力页那一屏上的两个动作（方案 56）

三栏那一发是读，这两条是**动作**，各带一个最容易做错的地方。

#### `GET /api/sessions/:id/skills/:name` —— **不计数**的那条读

引擎那条读正文的路（`skill_view` 工具 → core 的 `SkillSystem.view()`） **有副作用**：`stats.matchCount++` 加上 `lastUsedAt`。那两个数喂着 `SkillLearner` 的有效性评分，而评分反过来决定**模型看得见哪些技能**（`maintain()` 会把评分低的 自动技能直接删掉）。所以人在界面上点开看一眼**不能**走那条 —— 一次浏览会被算成 一次命中。这条端点走的是 core 那个新的、不计数的 `body()`。

-   正文是 `SKILL.md` 的**全文，含 frontmatter**：那几行正是用户点开要核对的东西
-   有体积上限（`MAX_SKILL_BODY_BYTES`，256 KiB）。**超了要说出来**， 载荷里的 `truncated` / `bytes` 就是给这句话用的，界面不许静默截断
-   认不出的技能名是 404；空正文是 200 + 空串（只有 frontmatter 的 SKILL.md 完全正常）

#### `POST /api/skills/import[/preview]` —— **收路径，不收上传的字节**

⚠️ **这不是「第一版先这样」，是这条路上的安全性质本体。**

用户级技能目录**没有信任闸门**（core 的 `skill/sources.ts` 文件头：「用户级， 无闸门（是用户自己的机器）」），所以往 `~/.epoch/skills/` 落一份文件 = 让那段文字 **无条件**进往后每一轮的 system prompt。而 `epoch web --host 0.0.0.0` 是一等公民 —— 那一档下「是用户自己的机器」这个前提不成立。

同形状的事这个仓库判过一次：信任的写入口**刻意不上网线**（方案 42 PR-3）。 所以这两条端点的请求体里只有 `path` 和 `token`，**没有 `content`** —— 浏览器只能选，不能造。要加上传，先读 `src/skill-import.ts` 的文件头。

两件事顺带记着：

-   **进程级，URL 上没有 `:id`** —— 落点是 `~/.epoch/skills/`，一个进程一份 （判据同下面那条 MCP 重连）；
-   **失败也回 200 + `ok:false`**，和 `GET /api/workspaces/dirs` 那条分 400/403/404 **刻意相反**：`empty` 那一档带着 `issues`，而那串诊断就是用户唯一 看得懂「为什么没导进来」的东西，`{error:{code,message}}` 塞不下它。

#### `POST /api/skills/stage` —— 上面那句话的**反面**（2026-09-13）

上面那条说「收路径不收字节」，而那句话把用户堵在原地：他手里的技能就在**他这台**上。 这一条就是那条路 —— 字节走网线过去落地、解开，回来一条**服务端本机路径**， 接着走上面那两条。

⚠️ **它没有推翻上面那节判据**，三条缺一不可：

1.  **落点在扫描范围之外。** 字节落进 `~/.epoch/skill-drops/`，而 `scanSkillDirs` 扫的是 `~/.epoch/skills/`。**传上来 ≠ 装上了** —— 这一发结束时 system prompt 里一个字都没变；
2.  **给人的那道闸门一格没动。** 出口是一条路径，下游仍是 `import/preview` → `import`，连 `token` 那条防 TOCTOU 的缝都照旧；
3.  **它吃一道 403。** `--host 0.0.0.0` 那一档下直接拒，而且**在读 body 之前** （body 上限 50 MiB，漏一格就是让 LAN 上的人一发一发地灌内存）。

⚠️ 那句 403 的措辞**不能抄身份 / 插件那两条**：技能不起子进程（插件那句是假话）， 而且**这一档下导入本身没关，只是不收上传** —— 用户把文件放到那台机器上、 把路径填进同一屏的输入框照样导得进来。那半句出路必须说出来。

⚠️ **这一层一个技能都不装，也一个都不验。**「里面有没有技能」由紧接着那一次 `import/preview` 答（它说得出「找到三份、其中一份名字不合法」）。所以拖一个空文件夹 进来**这一发会成功**，然后下一屏说「一份 SKILL.md 都没找到」—— 那是对的分工。

#### `POST /api/skills/remove` —— **能加就得能删**（2026-09-01）

起因是一句话：这一栏原来只有「导入技能」，拿掉一份得让用户自己去 `~/.epoch/skills/` 里翻目录 —— 而那一栏里的每一行都是**每一轮都在花钱**的常驻开销 （行尾那个 tokens 数就是它）。

⚠️ **没有 `remove/preview`，那不是没做完。** 导入那个 token 防的是「用户看过的 那一份」和「真装上的那一份」不是一份（预览和导入之间目录可能被改过）。删除这边 没有这条缝：要删掉的东西由**名字**唯一确定。删除的风险不是「删错一份」，是 **不可撤销**（`rmSync(recursive)`，没有回收站），而挡它的是另外两样东西 —— 界面上那一步「再按一次确认」，和回执里那个 `path`（用户得看得见自己刚销毁了盘上 的哪一处）。判据全文在 [src/skill-remove.ts](https://npmcn.com/package/%40epoch-agent/server/src/skill-remove.ts) 文件头。

⚠️ **这条和导入一样，不吃 `refusedByLan`，而那是一个判断。** 只给删除加一道闸的 结果，是 `--host 0.0.0.0` 那一档下技能变成「只能加不能删」—— 那正是这次要修的病， 换个地方原样长回来。两条一起加闸不行（那是把已经在用的导入功能收回去），所以这一格 的口径是「和导入同档」。真要收，得两条一起收，而那是一次独立的决定。

失败也是 200 + `ok:false`，四个 `reason` 各对**一个不同的下一步**：

| `reason` | 什么情况 | 用户该做什么 |
| --- | --- | --- |
| `not-found` | 这个名字这台服务端不认识 | 手上那份清单旧了，刷新 |
| `readonly` | 项目级 / 插件 / 宿主那三档 | 去它自己那个目录里删（话里带路径） |
| `failed` | 真的没删掉（盘上那一处出了事） | 看 `detail` 里那句系统错 |
| `missing` | 这台服务端压根没起技能系统 | 去看配置 |

-   **只有用户级删得掉**，而那道闸在 core（`requireWritable()`）不在这一层 —— 判两遍会有一天判得不一样，而只有 core 那一遍碰得到磁盘。界面上那颗按钮只画在 用户级那一组的行上，但那是**体贴，不是安全性质**：手搓一条 `POST /api/skills/remove {"name":"项目里那条"}` 会拿到 200 + `readonly`
-   **没删成时 `name` 和 `path` 是空的**，不是「请求里那个名字」：那一格答的是 「谁真的没了」，回一个其实还在盘上的名字是这条端点最坏的说谎方式
-   名字空着也走 `ok:false` + `not-found`，**不是 400**（判据同导入那两条： `{error:{code,message}}` 在界面上会走到「请求坏了」那一支去）
-   `detail` 那句话在 core / runtime 里**现渲染**，所以 `?lang=` 要一路递下去

#### `POST /api/mcp/:name/reconnect` —— **进程级**的那个动作

⚠️ **这条 URL 上没有 `:id`，而且不许有。** MCP 连接是装配期起的那几个 client， 一个进程一份 —— 挂到 `/sessions/:id/` 下面会得到一条名字是会话级、行为是进程级的 端点，比不一致更坏。这个不一致由**界面在按下去之前说清**（决定 20 ①），不由 URL 假装。能力页读的确实是会话作用域的那一发，两者的作用域就是不同的。

三档结局，三种下一步：

| 结局 | 回什么 | 用户该做什么 |
| --- | --- | --- |
| 连上了 | 200 `{ok:true, server}` | 没有下一步 |
| 连不上 | 200 `{ok:false, server}` | 看 `lastError` |
| 这个名字不认识 | **404** | 刷新页面 |

-   第三档是 404 而不是 `ok:false`：它说明客户端手上那份清单和这个进程走散了， 和「握手失败」共用一个界面分支的话，用户会对着一个永远重试不好的按钮点下去
-   `ok` 和 `server.state` **不是一个值**：连上了、但认证过不去时 `ok:true` 而 `state` 是 `needs-login`（该说的是去登录，不是重连失败）
-   连不上时 `lastError` 是**底层原样那句话**，这一层不改写、也不在它缺席时现编一句
-   **重连用的是装配时那份配置**（client 手里那个 `McpServerConfig`）。所以改完 `mcp.json` 照旧要重启 —— 这一下治的是「对面重启过 / 网线抖了一下」

#### `POST /api/mcp` —— 加一台，**而且真的写你的磁盘**（2026-08-18）

> 这两条 MCP 动作的 handler 同一轮从 `capability.ts` 搬进了 [src/mcp.ts](https://npmcn.com/package/%40epoch-agent/server/src/mcp.ts)（刀口按题目下：那边是「三栏取什么数」， 这两条是「怎么动 MCP」）。技能正文那条留在 `capability.ts`。

方案 44 §2.3 第 3 问那块拦路石（「`mcp-admin.ts` 一个写配置的函数都没有」）的答案。 请求体是 `{name, transport, command?, args?, url?, env?}`，**刻意比 `mcp.json` 窄** （那上面十几格的正确改法是去编辑那个文件，这条路解决的是「第一台加不进来」）。

⚠️ **同样没有 `:id`，而且它比上面那条还要远一格。** 重连改的是这个进程里的一个 连接（影响这个进程里所有会话）；这一条改的是 `~/.epoch/mcp.json` —— 影响这个进程， **外加这台机器上以后每一个 epoch 进程**（CLI / TUI / 别的宿主）。界面上那句话 不能照抄重连那一句，它少说了一半。

##### ⚠️ 闸门：回环之外一律 403，**而上面那条重连不受这道管**

`ctx.lanExposed` 为真时 **403 `mcp-add-lan-exposed`，一个字节都不写**。 判据全文在 protocol 的 [wire-agent-role.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-agent-role.ts) 文件头那五节 + `WireMcpAddRequest` 上 —— **同一套论证，三条写口共用** （另两条是 `POST /api/roles` 和下面那三条原文端点），这里不另起一套。 这一条自己那半句是：**`command` 那一格是这台机器上任意一个可执行文件，而加进来 的那台当场就被 spawn 起来** —— 三条写口里它是唯一一条直接起进程的。

两条分界线记着：

-   **只挡写，不挡读**（这条路上压根没有读）。「连 GET 都挡」是下面那三条独有的， 理由是那份文件里的 `env` 是明文密钥 —— 两条判据别混
-   **重连那条没有这道闸门，那不是不一致**：它动的是这个进程里已经存在的一个连接， 一个字节都不落盘。顺手把它一起关掉，会让一台绑在 `0.0.0.0` 上的服务连 「对面重启过、点一下重连」都做不到

⚠️ **闸门在读请求体之前**（同 `POST /api/roles`），所以用例断的是**写口的调用 次数为 0**，不是状态码：一个「先调写口、再看 `lanExposed` 决定回什么码」的实现 照样能让「回了 403」那条绿，而它已经写下去、还起了一个进程。

界面那一侧另有一道，但它是**给人的**（`GET /api/config` 的 `lanExposed`， 决定 20 ①）：那一档下「添加 server」那枚按钮整个不画，取而代之是一段带出口的 说明。⚠️ 那段说明**不许照抄** role-add 或「看它的配置」那两句 —— 三句话的 「为什么」各不相同，判据在 `web/src/capability/mcp-add.tsx` 文件头。

**写回是「往原文里插一段」，不是 round-trip。** 用户手写的注释、键序、缩进、 以及他写的 `connect_timeout` / `type` 这类别名**一个字节都不动** —— 判据全文在 [runtime/src/mcp-config-write.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/mcp-config-write.ts) 的文件头上（它和 `infra/src/config-yaml.ts` 是同一条判据）。

五档拒绝，五个不同的下一步：

| 情况 | 结果 | 用户该做什么 |
| --- | --- | --- |
| **服务绑在回环之外** | **403** `mcp-add-lan-exposed` | 去那台机器上改那个文件 |
| 名字不合法（会变成一个坏工具名） | 400 `bad-mcp-name` | 换个名字 |
| 缺 `command` / 缺 `url` / url 不合法 | 400 `bad-mcp-config` | 补上那一格 |
| **名字已经被占了** | **409** `mcp-name-taken` | 换个名字 |
| `mcp.json` 插不进去 / 写不下去 | **409** `mcp-config-unwritable` | 去那个文件那儿看一眼 |

-   **第一档是 403 而不是 400**：凭据完全正确，是这条路在这一档下不开（判据同 `POST /api/roles` 那一档，全文见上一段）
-   **撞名既不覆盖也不自动改名**：覆盖要删掉用户手写的那一段（连同他写在旁边的 注释），而这条路的立身之本是「只插入，不动他已有的字节」；自动改名会让界面上 那台的名字和他敲的不一样，而**工具名里带着这个名字**
-   ⚠️ 撞名查的是**两处**：`mcp.json` 里已有的（含读不出来的那几台）和这个进程里 宿主 / 插件带的那两批。漏查后者不是「多一台重名」而是**一次静默的能力丢失**
-   **连不上是 200 不是失败**：一台配置正确、只是对面暂时没起来的 server 必须加得 进去，否则用户唯一的出路是回去手写 `mcp.json` —— 而那正是这条路存在的理由。 那一档下 `server.state` 是 `failed`、`lastError` 是底层原话，配置已经在盘上了
-   **回执上没有 `ok`**（重连那条有）：「加进去了没有」由状态码答（200 = 落盘了 / 4xx = 一个字节都没写），「连上了没有」由 `server.state` 答。再加一个布尔就是 同一个问题的第三个答案
-   **加完当场连一次**，不用重启。这和上一条「重连不重读 `mcp.json`」不矛盾： 一个是「已经在跑的那台按老配置重连」，一个是「新来的这台按新配置起一次」
-   ⚠️ **`enabled` 那个开关不在这条路上**，方案 44 §2.3 那三问这一轮仍然不答

#### `mcp.json` 的**原文**那三条 —— 「看它的配置」（2026-08-18）

> handler 在 [src/mcp-config.ts](https://npmcn.com/package/%40epoch-agent/server/src/mcp-config.ts)（又单独成文件，刀口按题目下： `mcp.ts` 答的是「怎么动这个进程里的连接 / 怎么加一台」，这三条答的是「怎么把 **整份原文**发出去、收回来、再搬进这个进程」）。

`web/src/capability/connectors.tsx` 文件头那句「**「添加 server」/「看它的配置」**： 没有读写 `mcp.json` 的端点」，前一半那天由 `POST /api/mcp` 到期，**后一半就是这三条**。

##### ⚠️ 和「只插一段」那条判据不冲突，因为主语不同

[runtime/src/mcp-config-write.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/mcp-config-write.ts) 逐字判掉了 「读进来 → 改 → 整份写回」：我们读进来的不是原文（`parseMcpConfig` 吐的是归一化 之后的结构），整份写回会把 `connect_timeout` 改写成 `connectTimeout`、把用户没写的 `streamable` 物化成一行、把未知键删掉 —— 那是**改写他配置的语义**。

这三条上**没有那一步**：上来的字节就是用户在编辑器里敲的那些，服务端除了换行符 归一（CRLF 的文件不许被整份换成 LF）之外一个字节都不动。**那条判据的主语是我们， 这条路的主语是用户** —— 所以 `POST /api/mcp` 那条插入路一个字都不用改。

##### ⚠️ 闸门：回环之外一律 403，**连那条 GET 都挡**

这是它和另外两道**不一样**的地方（`POST /api/roles` 和 `POST /api/mcp` **只挡写**， 它们那条路上压根没有读），两条理由各自成立：

-   **读**：这份文件里的 `env` 是**明文密钥**。把原文发给一个我们无法确认在本机的 浏览器 = 把那几个 token 发出去，而 agent 自己那条读文件的路够不到它（工作区 边界之外）。所以这**不是**「反正他本来就读得到」；
-   **写**：这条路写得下 `command` 那一格 = 这台机器上任意一个可执行文件加它的参数， 而下一发 `apply` 会把它 spawn 起来。

界面那一侧另有一道，但它是**给人的**（`GET /api/config` 的 `lanExposed`，决定 20 ①）。

##### 保存和应用是**两发**，这件事必须写在界面上

| 端点 | 改的是 | 影响 |
| --- | --- | --- |
| `PUT /api/mcp/config` | **盘上那份文件** | 这台机器上**以后每一个** epoch 进程 |
| `POST /api/mcp/config/apply` | **这个进程的连接** | 这个进程里所有会话，**立刻** |

合成一发（「保存即生效」）判掉了，三条理由：一次编辑可能同时新增两台、删掉一台、 改掉一台，而**一发只有一个状态码**；写盘是毫秒、握手不是（一台没起来的 stdio 要等它自己的 `connectTimeout`）；「删掉一台」会静默拿掉别的会话正依赖的能力， 它该由用户显式按下，而不是他改了一行注释的副产品。判据全文在 [protocol/src/wire-mcp-config.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-mcp-config.ts) 文件头第二节。

##### 十档结局

| 情况 | 结果 | 用户的下一步 |
| --- | --- | --- |
| 绑在回环之外（三条都是） | **403** `mcp-config-lan-exposed` | 去那台机器上改那个文件 |
| **文件不存在**（GET） | **200** `exists:false` | 照着模板写第一台 |
| 读不下去（权限 / 它是个目录） | 409 `mcp-config-unreadable` | 去看那个文件 |
| 请求体不对 | 400 `bad-mcp-config-body` | （客户端 bug） |
| **指纹对不上** | **409** `mcp-config-stale` | 重新读一遍再改 |
| 不是合法 JSON | 400 `mcp-config-invalid-json` | 看那句话指的位置 |
| 写不下去 | 409 `mcp-config-unwritable` | 去看那个文件的权限 |
| **读出来一台都没有、而且有诊断** | **409** `mcp-config-no-servers` | 多半是写成了 `mcpServers` |
| 应用成了（哪怕有几台连不上） | **200** + 逐台 `changes` | 看连不上那台的原因 |
| 一台都没变 | 200 + 全 `unchanged` | 没有下一步 |

-   **指纹（`revision`）必传**：这份文件另一个编辑器一直开着（用户自己的 vim、 还有 `POST /api/mcp` 那条插入路）。整份覆盖的默认结局是「后写的赢」，而输掉的 那一方是他刚在别处写下的东西。指纹是**内容哈希**不是 mtime（后者在「写回同样 的内容」和「同一毫秒两次写」两档下都会骗人）
-   **有诊断照样存得下去**（缺 `command`、拼错的键名、未知字段）：那是他的文件， 他可能正改到一半。唯一会拦下保存的内容问题是「不是合法 JSON」
-   **`no-servers` 那一档拦的是「一次手滑把所有 MCP 全断掉」。** ⚠️ 而「0 台且 **没有**诊断」（`"servers": {}`）是**照办**的 —— 那是一句清楚的「我要清空」
-   **应用只动 `source === 'user'` 那批**：宿主注入的和插件带来的两批不在这份文件 里，把它们算进「盘上没了 = 该断开」等于用户存一次自己的文件就把宿主那几台全断了

#### `/api/models*` 那一组 —— 「我的模型」（2026-10-15），**进程级**

一条 = 端点 + key + 模型名 + 这个模型的能力，落在 `<homeDir>/models.json`（key 进钥匙串，不进文件）。 `model` / `models.utility` / `fallback.model` 写成 `@<id>` 就是指向它 —— **2.0.0 起这是唯一的写法** （老写法 `provider` + 裸模型名删了，读到就忽略并报一条诊断）。

-   **不带会话**：一个模型条目都没有的用户，一段会话也建不出来，而他正需要这一组去添加第一条；
-   **写的全是 POST**，包括 `test` / `discover` —— 它们拿着用户的 key 去打请求体里给的地址， 写成 GET 就绕开了 Origin 校验；
-   **key 只进不出**：响应里只有 `hasKey` + 脱敏的 `keyHint`。`hasKey` 说的是**条目自己那一把**： 2026-10-15 起条目不再借主配置 / 环境变量里那家的 key（判据在 core router 的 `registerEntries` 上）， 没自己那把的内置供应商条目就是「缺 API Key」；
-   **宿主预置的那一组只读**（`ModelEntry.host`，2026-10-15）：界面上没有编辑 / 删除，走 API 也是 403 `entry-host-readonly`（`store` / `delete` 两条都拦）。响应里 `hostLabel` 是那一组的组名 （`hostPreset.label`，缺省「宿主」），界面拿它当分组头；
-   **`refs` 与 `effective` 都是条目 id**：副模型没配 = 主模型那一条；降级没配 = `null`（不降级）。 `effective` 是「此刻实际落在哪一条」，`refs` 是「配置里指着哪一条」。算法在 runtime 的 `effectiveOf()`。

契约在 protocol 的 `wire-model-entry.ts`，逻辑在 runtime 的 `model-entries.ts`， HTTP 那一层在 [src/model-entries.ts](https://npmcn.com/package/%40epoch-agent/server/src/model-entries.ts)。

#### 2.0.0 删掉了 `/api/providers*` 那三条

`GET /api/providers`、`POST /api/providers/:type/models`、`POST /api/providers/:type/key` 是 **老写法的入口**（按 provider 的环境变量 key 探这一家有哪些模型 / 给这一家配一把 key），随老写法 一起删了。今天「这台机器上有哪些模型」由 `GET /api/models` 回答，而条目自己的「列模型 / 测试连接」 （`POST /api/models/discover` / `test`）带着草稿里的地址和 key 去打 —— 自建端点也走得通。

`POST /api/config/model` 同样删了（它写的是老写法那一格）。

#### `POST /api/roles` —— 新建一个身份，**而且这一条比别的都多一道闸**（2026-08-18）

> handler 在 [src/role-add.ts](https://npmcn.com/package/%40epoch-agent/server/src/role-add.ts)（单独成文件，刀口按题目下： `capability.ts` 答的是「那三栏取什么数」，这一条答的是「怎么建一个身份」）。

能力页三栏里最后一个补上的写口 —— 在这条之前，全仓没有任何一条写角色的路。 请求体是 `{name, description, displayName?, prompt?, tools?, skills?, maxTurns?}`， **和角色文件的 frontmatter 逐格对上**（`mcp.json` 那条刻意收窄，这一条刻意不收： 角色文件认得的键总共就那几个，砍掉任何一个都会让「从界面建的」和「手写的」变成 两种东西）。⚠️ `displayName` 是 2026-10-08 跟着 `AgentRole.displayName` 一起加的 （「要么连表单一起加，要么别加」——`skills` 刚还过这笔账），空白串在 core 归一成 「没写」。

##### ⚠️ 闸门：绑在回环之外（`--host 0.0.0.0`）时**一律 403，一个字节都不写**

这是这条端点的正题，判据全文在 [protocol/src/wire-agent-role.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-agent-role.ts) 的文件头（五节）。 一句话：往 `~/.epoch/agents/` 落一份 md = 让那段文字**无条件**进往后每一轮的上下文 （`description` 那一格进 `delegate_task` 的工具描述，**不需要任何人挑它**）， 而角色目录那张闸门表上用户级那一格逐字写着「无（**是用户自己的机器**）」—— 「是用户自己的机器」正是 `--host 0.0.0.0` 那一档下不成立的那条。

-   **不能拿「反正鉴权过了的人本来就能让 agent 跑命令」开绿灯**（本仓库别处有这句 现成的话）。`plan` 档和只读会话下 agent 一个字节都落不了盘，而这条端点落得了 —— 那是一次**跨权限轴的提权**，不是「本来就能做的事换个写法」
-   **skill-import 那个答案抄不了**：那条判成「收路径不收字节」，而「新建一个身份」 的本体就是用户敲一段 prompt 正文 —— 收路径 = 这条路整个不存在
-   **按 socket 的 `remoteAddress` 逐请求判**没有采纳：那是第四道闸门机制， 而 `--host` 那一格已经在了（`bind.ts` 的 `decideBinding().lanExposed`， `auth.ts` 一直拿它放宽 `Host` 白名单）
-   ⚠️ **界面上还有一道，但它是给人的**：`GET /api/config` 上带着 `lanExposed`， 能力页据此在**按下去之前**就把「这一档下建不了、去 `~/.epoch/agents/` 放一个 .md」说出来。**别因为界面画了就把服务端这道摘掉** —— 那一格是客户端自己改得动 的布尔

六档拒绝，六个不同的下一步：

| 情况 | 结果 | 用户该做什么 |
| --- | --- | --- |
| **服务绑在回环之外** | **403** `role-add-lan-exposed` | 去那台机器上手写一份 |
| 名字不合法 | 400 `bad-role-name` | 换个名字 |
| 描述空着 / `maxTurns` 越界 | 400 `bad-role-field` | 补上那一格 |
| 工具名不存在 / 是硬底线 | 400 `bad-role-tools` | 改工具那一格 |
| **这个名字已经被占了** | **409** `role-name-taken` | 换个名字 |
| 文件写不下去 / 写完自己读不回来 | **409** `role-unwritable` | 去那个目录那儿看一眼 |

-   第一档是 **403 而不是 400**：凭据完全正确，是这条路在这一档下不开（同 `auth.ts` 里 `bad-origin` 为什么是 403 而不是 401）
-   **撞名既不覆盖也不自动改名**：覆盖要删掉他写在那份文件里的**一段散文** （几十行 prompt），而自动改名会让派活时用的那个词（`delegate_task` 的 `role` 入参、`--agent` 的取值）和他敲进去的对不上
-   ⚠️ 撞名查的是**合并之后那张表**，不只是那个目录。漏了这一道不是「多一个重名」： 一份叫 `general` 的用户级身份会**静默换掉 `delegate_task` 不带 `role` 时的默认 人选**（`mergeRoles` 的同名语义是 prompt / description 整份替换）
-   **工具名当场校验**，这比 loader 严一格（那边是加载时记一条警告然后静默忽略）： 用户此刻正在敲这几个名字，而服务端此刻手上就有那张工具表
-   **没有「建进去了但还不能用」这一档**（`POST /api/mcp` 有）：那边「配置落盘」和 「连得上」是两件真会分家的事，这边落了盘就是能用了 —— 回执里那个 `role` 是服务端 **重载角色表之后**那一行的样子，不是把请求体回显一遍
-   **建完当场重载角色表**，不用重启：能力页刷新出来当场多一行，`delegate_task` 的 枚举里也有它了。不重载的话这一下只做了一半（判据在 [runtime/src/role-write.ts](https://npmcn.com/package/%40epoch-agent/runtime/src/role-write.ts) 的文件头第二节）
-   ⚠️ **这个文件里只有「建」**：`RoleWriteControlView` 上只有一个 `add`，所以 [src/role-add.ts](https://npmcn.com/package/%40epoch-agent/server/src/role-add.ts) **写不出**别的动作。删那一条 2026-09-16 开了，镜像和 handler 一起住在另一个文件里（下面那一节）；**「改」照旧没有**， 而那条叮嘱对它仍然成立 —— 先回去读 protocol 那份文件头第三节

#### `POST /api/roles/remove` —— **能加就得能删**（2026-09-16）

> handler 在 [src/role-remove.ts](https://npmcn.com/package/%40epoch-agent/server/src/role-remove.ts)。**和上面那条分两个文件**， 判据同 `skill-remove.ts` 从 `skill-import.ts` 拆出来那次：镜像跟着 handler 走， 于是这个文件写不出 `add`、那个文件写不出 `remove`，而 `RuntimeCapabilities.roleWrite` 是两张镜像的交集。

起因是一句话，和技能那条逐字相同：这一栏原来只有「新建」，拿掉一个建错的身份 得让用户自己去 `~/.epoch/agents/` 里翻目录 —— 而那一份里的 `description` 是**每一轮都在进** `delegate_task` 工具描述的。

##### ⚠️ 它吃**和「建」同一道**闸，而理由和 `POST /api/skills/remove` 刻意相反

技能删除那条**不吃**闸门，判据逐字是「导入那条也不吃」（只给删加闸 = `--host 0.0.0.0` 下技能变成「只能加不能删」）。身份这边照抄那条推理会得出 **相反**的结论，而那正是对的：**建那一侧本来就要回环**。于是删也要回环， 一点不对称都不制造 —— 那一档下用户既建不出也删不掉，fail closed。

⚠️ 哪天上面那道闸松了，这一道要跟着松，**两条一起动别只动半边**。判据全文在 [protocol/src/wire-agent-role.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-agent-role.ts) 文件头第六节。

五档拒绝各对**一个不同的下一步**（**403 之外全是 200 + `ok:false`**）：

| `reason` | 什么情况 | 用户该做什么 |
| --- | --- | --- |
| `missing` | 请求里没给名字 | （界面不该发出这一发） |
| `not-found` | 这个名字不在角色表里 | 手上那份清单旧了，刷新 |
| `not-user` | 它来自内置 / 项目 / 插件 / 宿主 | 那句话里点名了是哪一层，各有各的删法 |
| `ambiguous` | 目录里两份以上都叫这个名字 | 去那个目录里留下要的那一份 |
| `failed` | 真的删不动（权限 / 只读盘） | 去回执那条路径那儿看一眼 |

-   **失败是 200 而不是 `addRole` 那样的 4xx**，分野在**界面形状**：那边是一张 表单（失败落在表单底下，改一格再提交），这边是卡片上一枚按钮（失败落在那张卡 自己那一行小字上）。`{error:{code,message}}` 那个形状在界面上会走到「请求坏了」 那一支去。⚠️ **那道 403 是例外**：它答的不是「这一下没删成」，是「这条路在这一档 下不开」
-   **只有用户级删得掉**，而那道闸在 runtime（它才看得见合并后那张表）不在这一层。 界面上那颗按钮只画在「我的」那一组的卡上，但那是**体贴，不是安全性质**： 手搓一条 `POST /api/roles/remove {"name":"general"}` 会拿到 200 + `not-user`
-   **没删成时 `name` / `path` / `takenOverBy` 是空的**，判据逐字同技能那条
-   ⚠️ **`takenOverBy` 是这条路比技能那条多出来的那一格**：删掉的要是一个**同名 覆盖**，下层那一份会浮上来 —— 能力页刷新出来那一行**还在**（只是换了组）。 不下发这一格的话，界面对用户说「已经删掉 general」而列表里 general 还在， 也就是一次成功的删除长成了一次失败的删除。算它要查**重载之后**那张合并表， 所以这一层原样转发、一个字都不判
-   ⚠️ **「谁正用着它」这条路不查**，那是一个判断不是遗漏：服务端只数得到活着的 会话，数不到磁盘上休眠的那些，而报一个半真的数字比不报更糟。说清后果那件事 归界面上那一问（判据在 protocol 那份文件头 6.3）
-   **删完当场重载角色表**，同上面那条

#### `/api/plugins*` 那一组 —— 插件那一页（方案 59 §六 E2，2026-08-21）

> handler 在 [src/plugin.ts](https://npmcn.com/package/%40epoch-agent/server/src/plugin.ts)（单独成文件，刀口按题目下同上： 这一份答的是「怎么往这台机器上装一包扩展物」）。底座是 `EpochRuntime.plugins` （方案 59 PR-4），判据全文在 protocol 的 [wire-plugin.ts](https://npmcn.com/package/%40epoch-agent/protocol/src/wire-plugin.ts) 文件头。

**这一层一条策略判断都没有。** 装什么、装不装得上、要不要重启 —— 三个问题的 答案全在 runtime 那一片里。在这儿重判一次的下场是「同一个问题两个答案」， 而它们分叉那天，界面上是一条画成可点、点了却被拒的行。

##### ⚠️ 2026-11-06：市场那一半删了，装进来只剩「本机目录」和「落包」

`POST /api/plugins/install`（市场 ref）、`/marketplaces{,/remove}`、`/upload{,/preview}`、 `/refresh` 全部撤掉，`GET /api/plugins` 也不再带 `hits` / `marketplaces` / `uploadEnabled`。宿主自己的面板要显示市场就自己下包，把字节交给 `stage`， 或者先解到本机一条目录再 `install/local` —— 清单在 `docs/HOST_CAPABILITIES.md`。

##### ⚠️ 本地那两条收的是**一条绝对路径**

请求体上是 `{path, token}`，而 `path` 指的是**服务端那台机器**上的目录。 于是「网线另一头就是这台机器」是这条路全部的前提 —— 写口那道 403 就是它的兑现 （`lanExposed` 为真时一律拒）。两段式（预览 → `token` → 真装）没有变： 一包插件可能带 `hooks.json` 和 `mcp.json`，那两样会起子进程。

##### ⚠️ 闸门：那一组 POST 在回环之外一律 403（`view` 那一条除外），\*\*而

## 常见问题

### @epoch-agent/server 是什么？

epoch-agent 本地 HTTP \+ SSE 服务端：给浏览器的那一层，嵌入方不需要它

### 如何安装 @epoch-agent/server？

在项目目录运行 npm install @epoch-agent/server，或使用 pnpm add / yarn add / bun add。

### @epoch-agent/server 支持 TypeScript 吗？

支持。包内自带 TypeScript 类型声明（package\.json 声明了 types 入口），安装后即可获得类型提示，无需再安装 @types 包。

### @epoch-agent/server 支持 ESM 还是 CommonJS？

以 ES Module 形式发布，推荐使用 import 引入。在 CommonJS 项目中，Node\.js 20\.19\+ / 22\.12\+ 可以直接 require\(\) 不含顶层 await 的 ES Module，更早的版本需要改用动态 import\(\)。

### @epoch-agent/server 需要什么版本的 Node\.js？

package\.json 的 engines 字段要求 Node\.js \>=22\.0\.0。版本不符时 npm 默认只给出警告，开启 engine-strict 后会拒绝安装。

### @epoch-agent/server 可以商用吗？

@epoch-agent/server 使用 Apache-2\.0 许可。宽松许可：允许商业使用、修改和再分发，并附带明确的专利授权；分发时需保留许可与 NOTICE 文件，修改过的文件要注明改动。以上为概括，不构成法律意见，请以许可原文为准。

### @epoch-agent/server 的下载量有多少？

根据 npm 官方下载统计，@epoch-agent/server 在 2026/10/14 – 2026/11/12 这 30 天内被下载 4,092 次，日均约 136 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入，适合横向比较热度，不能直接等同于使用人数。

### @epoch-agent/server 还在维护吗？

最新版本 6\.2\.0 发布于 2026/11/13（今天）；至今共发布 57 个版本，其中过去 12 个月发布了 57 个；首次发布于 2026/08/19。发布频率只是参考信号：功能稳定的工具可能很少需要更新，建议结合源码仓库的 issue 和提交记录判断。

