@epoch-agent/server

中文文档
6.2.0发布于 2026/11/13Apache-2.0TypeScriptES Module近 30 天下载 4092

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

中文使用文档
文档目录17 节
  1. 谁会用它
  2. 前端产物随本包发
  3. 端点
  4. 分页与提问索引: GET .../messages + GET .../user-messages (2026-11-…
  5. 字面检索: GET .../message-search (2026-11-12)
  6. 两张名册: GET /api/team 与 GET /api/team/subagents (2026-11-06)
  7. 子 agent 的会话: GET .../messages 不报「这一轮断了」(2026-11-06)
  8. POST / GET /api/appearance —— 界面外观偏好(2026-09-29,GET 2026-10…
  9. /api/host/* —— 宿主把一句话送进输入框(2026-09-28)
  10. /api/host/badges —— 宿主在运行中开关的未读点(2026-10-15)
  11. /api/host/tray + /api/host/tray/items —— 宿主决定托盘项是哪一态、长什么样(2…
  12. /api/host/topbar —— 顶栏左侧那排按钮的运行期清单(2026-11-13)
  13. /api/sessions/:id/goal + /api/goals/blocked —— 目标那一组(方案 52 …
  14. /api/schedules* —— 定时任务那一组(方案 45 PR-3)
  15. ?lang= —— 这一次响应用哪个语言(方案 58)
  16. GET /api/sessions/:id —— 按 id 问一行(2026-08-17)
  17. 能力页那一屏上的两个动作(方案 56)

@epoch-agent/server

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

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

谁会用它

两类宿主,接法完全一样:

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

自己画 UI 的同进程宿主不需要这一层:跨一层 HTTP 是白付的序列化成本,直接依赖 runtime 就好。

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 的 EpochRuntime.events(方案 61)—— 两者的取舍表在 EMBEDDING §13.5。

主题和语言给 ui,别去写浏览器的 localStorage(2026-08-15,方案 54 §七):

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, 和 web 那两份的一致性由 packages/web/__tests__/prefs.test.ts 钉着。

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

前端产物随本包发

dist/web 是 web 的构建产物,由 scripts/copy-web-assets.mjs 在构建期拷进来,GET /* 托管的就是它。不传 webRoot 就是它——想换成自己那套 前端才传。

产物落在这个包而不是 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/eventsSSE,负载是 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/mcpMCP 服务器清单(进程级,不要 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/loginOAuth 登录:202 + 异步,结果走 mcp-auth-changed(回环绑定才给)
POST /api/mcp/:name/logout删掉本地 OAuth 凭据(回环绑定才给)
GET /api/mcp/configmcp.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,能力页那个端点 的在 wire-capability.ts,SSE 的信封在 wire.ts。浏览器 import 的是同一份 —— 两边都不许再本地 声明一份。第三方要接这套 API 自己做前端(跨进程 / 跨语言 / 跨设备)的话, 清单以外还要知道的六件事(鉴权两段式、Origin 校验、seq 与 Last-Event-ID 续传、 断连不等于人走了)写在 docs/EMBEDDING.md §10。

分页与提问索引: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; 存储本体和四条「为什么不塞进 display.*」的判据在 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。

载荷与回执

// 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 的文件头。

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

目标没有内存态 —— 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。

/api/schedules* —— 定时任务那一组(方案 45 PR-3)

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

载荷契约在 protocol/src/wire-schedule.ts, 而任务本身的形状(ScheduleDefinition / ScheduleRun)在 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)。判定、注册进 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✅ 已抽取,跟着走
    静态那句 找不到 XhandleStatic(dispatch 调它)✅ 已抽取,跟着走
    没有会话 X / 没有这个端点handleApi 及其分组分发⬜ 还没抽取;lang 在作用域里
    只支持 …(methodNotAllowed)handleApi 及其分组分发,外加 dispatch 里静态那条 GET, HEAD⬜ 还没抽取;两处 lang 都在手边

    上面前三格 2026-08-21 之前是 ❌(langOf() 那时在 handleApi 里解析, 它们三个都不在那条路上)。挪解析点是一次决定,不是一次重构 —— 要答的是 「一条连凭据都没过的请求该不该拿到译文」。答案是「该」,三条判据在 src/routes.ts 文件头「三」第 4 条:langOf() 是纯函数、 值域闭合成三个值(注入不了一个字节);请求本来就自己决定拿到四条理由里的哪一条 和什么状态码(选语言是严格更少的能力);而 GET /api/health 排在 guard 之前, 鉴权之前本来就在渲染。落地证明在 __tests__/wire-lang.test.ts 那三个 describe, 决定本身由仓库根那条门禁钉着(langOf( 只许一处,且行号小于 guard()

  • SSE(/api/events)上没有这个参数,也不该加:那条流上一句我们渲染的文案都没有, 钉一个语言在一条长期活着的连接上只会造一个静默过期的值(方案 58 §2.2)

  • ⚠️ 它绝不改进程状态。 两个标签页各切一种语言互不影响,用例钉在 __tests__/wire-lang.test.ts(诊断那一半)和 __tests__/wire-lang-handlers.test.ts(错误消息那一半)

⚠️ 往这张表里加端点的人注意:新 handler 必须收 lang 并往下递。 这不是一句叮嘱 —— 仓库根的 __tests__/wire-lang-threading.test.ts 扫 routes.ts 里每一条分发调用,实参里没有 lang 就点名报错;同一份还查 「server 里每个 t() 都收了第三个参数」,以及「langOf( 只出现一次、而且排在 guard( 前面」。它防的是一类只在合并点才存在的缺陷: 一条并行分支加的 handler,在它自己那条分支上什么都不缺,合进来之后成为唯一一个 不认 ?lang= 的口子,而它编译得过、跑得通、用例全绿。

⚠️ 还有一条否定性的姊妹门禁 (__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),只显示「当前值」的设置界面会在 「用户改了一个开关、被上面某一层压回去」时撒谎。三个能被项目级覆盖的键 (permissions / model / maxTurns)另带一条六层链条,其余键的链条是空数组 ——它们只可能有 ① 和 ②,画一条六层链等于暗示一件假事。

POST 同路径是写那一半(2026-08-15)。保存语义那三件(即时生效还是按保存 / 写哪一层 / 写完要不要重启会话)各有一个答案,判据全文在 core/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 的 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。 同样的口径还有一个 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 文件头。

⚠️ 这条和导入一样,不吃 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(刀口按题目下:那边是「三栏取什么数」, 这两条是「怎么动 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 文件头那五节 + 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 的文件头上(它和 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(又单独成文件,刀口按题目下: mcp.ts 答的是「怎么动这个进程里的连接 / 怎么加一台」,这三条答的是「怎么把 整份原文发出去、收回来、再搬进这个进程」)。

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

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

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 文件头第二节。

十档结局
情况结果用户的下一步
绑在回环之外(三条都是)403 mcp-config-lan-exposed去那台机器上改那个文件
文件不存在(GET)200 exists:false照着模板写第一台
读不下去(权限 / 它是个目录)409 mcp-config-unreadable去看那个文件
请求体不对400 bad-mcp-config-body(客户端 bug)
指纹对不上409 mcp-config-stale重新读一遍再改
不是合法 JSON400 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。

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(单独成文件,刀口按题目下: 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 的文件头(五节)。 一句话:往 ~/.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 的文件头第二节)
  • ⚠️ 这个文件里只有「建」:RoleWriteControlView 上只有一个 add,所以 src/role-add.ts 写不出别的动作。删那一条 2026-09-16 开了,镜像和 handler 一起住在另一个文件里(下面那一节);「改」照旧没有, 而那条叮嘱对它仍然成立 —— 先回去读 protocol 那份文件头第三节

POST /api/roles/remove —— 能加就得能删(2026-09-16)

handler 在 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 文件头第六节。

五档拒绝各对一个不同的下一步(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(单独成文件,刀口按题目下同上: 这一份答的是「怎么往这台机器上装一包扩展物」)。底座是 EpochRuntime.plugins (方案 59 PR-4),判据全文在 protocol 的 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 共发布 57 个版本,首次发布于 2026/08/19;过去 12 个月发布了 57 个版本。

版本发布日期距今
6.2.0latest2026/11/13今天
6.1.02026/11/112 天前
6.0.02026/11/112 天前
5.0.02026/11/103 天前
4.1.12026/11/067 天前
4.1.02026/11/067 天前

关于 @epoch-agent/server 的常见问题

@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 和提交记录判断。