@epoch-agent/server
本地 HTTP + SSE 服务端。epoch web 的后半截——给浏览器的那一层。
谁会用它
两类宿主,接法完全一样:
- 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/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,能力页那个端点
的在 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拼错是 400bad-cursor—— 「当没给」会静默吐全量, 翻页永远不前进。?turnMinMessages=&turnMinTurns=让切点优先落在轮的开头(2026-11-12,口径同 deepseek-harness 的turnWindow)。两个必须成对给;turnMinMessages必须 小于limit(缺省那一页也算),否则 400bad-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拼错是 400bad-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,别的一律 400bad-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 —— 网线上原样发它们,
不做投影。
四件在这一组上和别处不一样的事:
- 校验不过是
200+issues非空,不是 400。 请求本身完全合法,是表单里 某几格填错了,而那几格要在表单上逐格标红。这一组的 400 只留给「这个 JSON 读不懂」(schedule/body.ts那一层)。发下去的是码不是句子 —— 同一条校验在 CLI 和表单上的措辞本来就该不一样(CLI 说「--max-turns要是 正整数」,而表单里没有这个东西); - 定时任务只剩轮次和超时两把尺子(2026-10-29 撤掉了预算那一格
maxBudgetUsd)。所以GET /api/schedules回的defaults里只有那两格 加一份清单,POST也不再要求、不再接受预算; POST .../run会真的起一次 agent 运行,按那条任务自己的清单不经确认地跑 命令,而且可能跑到墙钟超时(缺省 15 分钟)才回来。它是这一组里最该守住 CSRF 的一条 —— 写成 GET 等于任何一个页面都能替用户点那个按钮。跑失败也是 200: 一次真的跑过、真的失败了的运行不是「请求发坏了」,而回来那份pendingApprovals(欠条)正是界面接下来要画的东西;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)
上面那条说「收路径不收字节」,而那句话把用户堵在原地:他手里的技能就在他这台上。 这一条就是那条路 —— 字节走网线过去落地、解开,回来一条服务端本机路径, 接着走上面那两条。
⚠️ 它没有推翻上面那节判据,三条缺一不可:
- 落点在扫描范围之外。 字节落进
~/.epoch/skill-drops/,而scanSkillDirs扫的是~/.epoch/skills/。传上来 ≠ 装上了 —— 这一发结束时 system prompt 里一个字都没变; - 给人的那道闸门一格没动。 出口是一条路径,下游仍是
import/preview→import,连token那条防 TOCTOU 的缝都照旧; - 它吃一道 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 | 重新读一遍再改 |
| 不是合法 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 也是 403entry-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,那两样会起子进程。