@epoch-agent/server
v0.1.0
Published
epoch-agent 本地 HTTP + SSE 服务端:给浏览器的那一层,嵌入方不需要它
Readme
@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); // 同进程旁听事件流,**不算一条连接**
created.server.snapshot(); // 首屏那张状态快照(SSE 上的 session-state 只发变化)
await created.server.close(); // 幂等主题和语言给 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」。
端点
| 端点 | 说明 |
| ------------------------------------------------- | ------------------------------------------------------------------------------ |
| GET /api/health | 不鉴权,只回 {ok, version} |
| GET /api/config | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(?lang= 见下) |
| GET /api/events | SSE,负载是 WireEnvelope |
| 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 | 历史回放(冷却 / 上个进程留下的也回放) |
| POST /api/sessions/:id/messages | 发消息,立即 202;这一轮在跑就排队 |
| POST /api/sessions/:id/abort | 中止本轮,并丢掉排在后面的消息 |
| 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,不计数,见下) |
| POST /api/mcp | 加一台 MCP,写进 ~/.epoch/mcp.json(进程级 + 落盘,回环绑定才给,见下) |
| POST /api/mcp/:name/reconnect | 重连一台 MCP(方案 56 §1.1,进程级,见下) |
| 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/roles | 新建一个身份,写进 ~/.epoch/agents/(回环绑定才给,见下) |
| 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 = 回到配置里那个 |
| GET /api/providers | 支持哪几家 + 各自 key 配了没(进程级,闭合集,见下) |
| POST /api/providers/:type/models | 探这一家有哪些模型(进程级,{refresh?} 绕缓存,见下) |
| 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} → 立刻换档(见下) |
| 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=][&hidden=1] | 这台机器上有哪些目录(进程级,见下) |
| POST /api/sessions/:id/workspace | {root}|{none:true} → 给还没选地盘的那一档选(方案 55 PR-1,见下) |
| POST /api/workspaces | {parent, name} → 新建一个目录(一次 mkdir,见下) |
| GET /api/sessions/:id/diff | 这个会话的工作区 diff(见下) |
| GET /api/schedules | 定时任务两个 tab 一次取齐(进程级,方案 45 PR-3,见下) |
| 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/fix | {runId} → 把那一轮的欠条加进授权清单(见下) |
| GET /api/schedules/:id/runs/:runId/recording | 那一次的 WireEnvelope 录像(见下) |
| 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。
/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 说「--budget必填」, 而表单里没有这个东西); maxBudgetUsd必填,服务端不兜默认值。 漏发它是 400。这是方案 45 唯一 一处刻意增加的摩擦(§3.5):一个假默认会让「一觉醒来 40 美元」变成我们的错。 所以GET /api/schedules回的defaults里没有这一格;POST .../run会真的起一次 agent 运行,按那条任务自己的清单不经确认地跑 命令,而且可能跑到墙钟超时(缺省 15 分钟)才回来。它是这一组里最该守住 CSRF 的一条 —— 写成 GET 等于任何一个页面都能替用户点那个按钮。跑失败也是 200: 一次真的跑过、真的失败了的运行不是「请求发坏了」,而回来那份pendingApprovals(欠条)正是界面接下来要画的东西;POST .../fix只收runId,不收规则。 欠条已经在库里那条 run 上了, 让浏览器回传等于允许它编一条 —— 而这条路的后果是往授权清单里加规则。
录像那一条不再脱敏一次:脱敏在落盘那一刻就做完了(core 的 redactLine,
跑在已经序列化过的一整行上)。在这一层补一道会让下一个人以为盘上那一份是干净的
—— 而盘上那一份才是真正会被 cat 的那份。帧数超过 MAX_RECORDING_FRAMES
(4000)时截断 + truncated: true,界面必须把它画出来。
⚠️ 这一组借的是 WebRuntimeView.schedules(ScheduleControlView,
结构镜像在 src/schedule/view.ts)。判定、注册进 OS、
跑一次、算下一次触发时刻一样都不在这一层 —— 全在 core / runtime,
这个目录只做「HTTP ↔ 那几个方法」的翻译。
?lang= —— 这一次响应用哪个语言(方案 58)
API 上有一个通用的可选查询参数 lang(常量在 protocol 的 WIRE_LANG_PARAM)。
上面那张表里每一条都读它(2026-08-18 起,PR-2 之前只有 GET /api/config):
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 响应体,不是错误体) - ⚠️
code一个字都不跟。 那是契约(前端只认它做分支),同Diagnostic.module。 跟语言走的只有给人看的那句话 - ⚠️ 还有一批消息今天不跟,而这是已知的:
没有会话 X/没有这个端点/只支持 GET, POST/ 那四条鉴权拒绝理由 —— 它们是未抽取的中文字面量 (server 的 i18n 基线里那 28 条)。抽取它们归方案 40 那条棘轮,抽取的那天要连着把lang递进去(参数就在作用域里) - 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()都收了第三个参数」。它防的是一类只在合并点才存在的缺陷: 一条并行分支加的 handler,在它自己那条分支上什么都不缺,合进来之后成为唯一一个 不认?lang=的口子,而它编译得过、跑得通、用例全绿。
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回来的值一个字都不会变,界面照旧印旧值、另说一句「重启后生效」。 把那一行乐观地改成新值,会显示一个这台机器上哪儿都不存在的状态
⚠️ 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。
/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/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/providers* 那两条 —— 也是进程级,而且一半闭合一半不是
⚠️ 两条 URL 上都没有 :id,理由同上面那条 MCP 重连:provider 清单和模型
清单不属于某一段会话。它们同时服务两处界面(顶栏那枚模型 chip 的菜单、
设置页「模型与 Provider」那一行)—— 共用的是「有哪些可选」,两处选完之后的
去处刻意不同(那边 POST .../model 立刻生效,这边 POST .../settings 落盘)。
这两条的形状是这一轮的正题,它满足的是 web/src/shell/model-menu.tsx
从 2026-08-15 起就写着的那条判据(「菜单里没有一张模型清单,这是一个决定」)
自己写的开工条件:
| 哪一层 | 闭合吗 | 真源 |
| -------- | ---------- | ------------------------------------------------------------------ |
| provider | 闭合 | protocol 的 PROVIDER_INFOS(唯一一份,别在任何下游抄第二份) |
| 模型 | 不闭合 | provider 的 /models 探回来的那份 —— 是建议,不是值域 |
所以界面上手敲那个框一直在、一直是主路,探回来的画在它旁边当补全。 判据全文在 protocol/src/wire-provider.ts。
另外四条,每一条都有一次真实的踩点:
- 探测那条是 POST,不是因为要带参数:它带着用户那把 key 发一个真的外网
请求、还把结果写进磁盘缓存。写成 GET 就绕开了
auth.ts那道 Origin 校验 - 「探不到」是 200,不是 4xx / 5xx。四档各有各的下一步,而状态码表达不了
那个差别:
no-key(去配一把 key)/probe-failed(查网络 / key / 那个地址 有没有/models)/empty(只能手敲)/ok。⚠️ 一句都不许合并成 「没有可用模型」 —— 尤其no-key,那时说「这个 provider 没有模型」是假话 - 认不出的 provider 名是 404(
unknown-provider),同mcp_unknown_server: 请求没毛病,是这条 URL 上的东西不在,下一步是刷新 {refresh:true}绕开磁盘缓存。探测结果落盘缓存 24 小时,没有这一下, 用户换了 key / provider 上了新模型,看到的永远是缓存那一份。同epoch model --refresh
POST /api/roles —— 新建一个身份,而且这一条比别的都多一道闸(2026-08-18)
handler 在 src/role-add.ts(单独成文件,刀口按题目下:
capability.ts答的是「那三栏取什么数」,这一条答的是「怎么建一个身份」)。
能力页三栏里最后一个补上的写口 —— 在这条之前,全仓没有任何一条写角色的路。
请求体是 {name, description, prompt?, tools?, maxTurns?},和角色文件的
frontmatter 逐格对上(mcp.json 那条刻意收窄,这一条刻意不收:角色文件认得的
键总共就四个,砍掉任何一个都会让「从界面建的」和「手写的」变成两种东西)。
⚠️ 闸门:绑在回环之外(--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, 所以这个包写不出别的动作。要加remove先回去读 protocol 那份文件头第三节 —— 删一个身份不会往 system prompt 里塞字,但会静默拿掉别人正依赖的一段边界, 那道闸门的判据和「建」不一样
POST /api/sessions 的 role(决定 20 ②,2026-08-15)
{ "workspace": "/abs/path", "create": true, "role": "explore" }这个会话用哪个专家跑。 取值来自
GET /api/sessions/:id/capabilities 那份 roles。
- 认不出来 → 400
unknown-role,绝不静默降级成不分角色。 判据照--agent(runtime 抛AgentRoleError):用户显式点了一个专家,给他跑一个能力范围不同的 agent 是最坏的结果。400 而不是 404 —— 那条 URL 好好的,是请求体里的一个值 不对,和bad-workspace完全同构 - 不给就是不分角色,也就是这个字段出现之前的行为;空串 / 非字符串当成没给 (为一个可选字段回 400,挡掉的是整次建会话)
- 它是会话级的,即使决定 20 ② 把专家画在输入框「这一条」那一行上:角色同时 决定 system prompt 的身份行和工具表,而 system prompt 是 prompt cache 的前缀。 逐条换角色 = 逐条作废整段对话的缓存,还会让同一段历史里前后两轮的身份声明打架。 换专家的正确形态是新建一个会话
⚠️ 校验只在 runtime 一处(SessionFactory.create),这一层只把结果映射成 HTTP 码。
role 和 workspace 不是一类字段:后者是攻击面(它说的是「在哪个目录里跑
agent」),前者只能命中这个进程已经加载好的那张角色表。
自定义斜杠命令(方案 23)
GET /api/sessions/:id/commands 回的是补全面板要的那张表:name /
description / argumentHint 三个字段,没有 prompt。命令正文是用户自己写的
工作指令(项目内部的措辞、路径、人名都在里面),面板一个字都用不上 —— 少发它是
隐私和体积两头都对的选择,判据写在 WireCustomCommand 的 JSDoc 上。
没有「执行命令」的端点,而且不该有。 敲下 /foo bar 之后走的仍然是
POST /api/sessions/:id/messages:服务端在那条路上判「这行是不是 /xxx」,
是就 commands.expand(),把展开后的正文当作真正发出去的用户消息,并在 202 的
command 字段里回一句「认出来了,另外有这几条提醒」(ExpandedCommand.warnings
原样带回,不许吞 —— 一个 $3 因为少给一个参数被替成空串,和它本来就该是空串,
在屏幕上长得一模一样)。另开一条端点的话,排队、中止、事件流、只读判定四样都得
再写一遍。
展开只能在服务端做:插值要引号感知的 tokenizer(在 @epoch-agent/infra),
utility 关键字和 <provider>/<model> 的切法在 core,而 @epoch-agent/web 只许
依赖 protocol + view。
⚠️ 命令名不认识时照原样发给模型,不是 400。用户问「/etc/hosts 是什么」 收到一句「未知命令」的话,他会以为这个工具连一句话都读不懂。
⚠️
allowed-tools/model:的收窄由 runtime 的withToolScope/withModelScope包住这一轮的事件流,还原写在finally里。漏掉的症状是 「跑过一次/foo之后模型再也看不见别的工具了」,而且没有任何报错。 那一层收窄发生在工具表上、不经过PermissionManager,所以它不进权限审计流水 —— 那本账记的是「有人申请了、我们判了」,而被摘掉的工具一次申请都没发生过。
逐条专家(方案 57)
POST /api/sessions/:id/messages 上多一个可选字段 role ——「这一条用哪个专家」。
它和上面那条命令走的是同一条通道:scopeTurn() 多套一层,Hub 一个字都不用改
(它只知道「这一轮的流要先过一遍这个函数」)。三层的顺序是
角色 → 模型 → 工具,由外往里,角色收窄要盖住命令的 allowed-tools。
- 缺席 = 用这段会话的角色,不是「这一条没有角色」:用户点掉那枚 chip 的意思是 「用会话本来那个」。所以这个字段只有两档,不是三档;
- ⚠️ 认不出来的名字是 400
unknown-role,这一条不发出去,判据逐字照POST /api/sessions上那个同名字段 —— 静默按会话默认跑的话,用户以为explore在答、实际是build在改文件,而屏幕上没有任何区别。不是字符串则当没给 (拼错类型挡掉的会是整条消息,而没挑专家本来就是合法的一档); - 这一条覆盖会话角色,不取交集。取交集会造出一个看不出来的哑弹:会话是
explore(只读)时挑build得到的交集仍然只读 —— 用户挑了一个专家而什么都没发生。 权限档那一轴照旧独立生效(plan档下写文件仍然被拒); - 换人那一刻,历史里会多一条
[系统] 从这一条起由「x」接手。(role: 'user', AI SDK v7 不许messages里出现 system)。它落在 runtime 的AgentSession.run(), 因为它必须落盘 —— 重开会话回放时那才是唯一说得出「这段对话里谁干的」的东西。
⚠️ 角色那一层的还原同样写在
finally里,症状比工具表那次更贵一格: 身份行也留在原地,于是模型会一直自称是上一条消息挑的那个专家。
⚠️
GET /api/workspace/diff没了,换成上面那条 session-scoped 的。工作区改成 每会话绑一个之后(见下一节),一个进程级的 diff 端点只能是某一个会话的答案而不说 是哪个。不留旧路径当别名 —— 留着它等于把刚拆掉的那个歧义又请回来。
换模型:/model 改此刻,/settings 改下次启动
两条路刻意不合并。POST .../settings 改的是盘上那个配置文件(写进 ② 用户级或
④ 项目本地,要重启才生效);POST .../model 改的是这个进程里正在跑的那个选择
(立刻生效,什么都不写盘)。合成一条的话,用户要么在设置页改完发现这一轮没变,
要么在顶栏换完重启发现回去了。整张对照表在 protocol/src/wire-model.ts 的文件头。
⚠️ 读那一半也在这条路上,不能让界面读 GET /api/config 的 model。
那个字段是 runtime.config.model —— 装配时的值,换完之后一个字都不会变。
只借三个动词(get / set / reset)。enterTurn / drainNotices 刻意不借,
判据同 plan 那处「forget() 不借」:前者只管一轮、必须和一次 run() 成对,
而这条路上没有还原的对手方;后者的正确落点是回合收尾那一帧。
✅ 2026-08-16:任何一个活着的会话都换得动了,那道 409 拆了(方案 30 §十四)。
这一段上一版写的是「只有引导会话改得动」,判据是
ModelControl不在工厂 那张作用域表里 —— 它确实是进程级一份,放行才会造成「A 标签页换了模型把 B 会话 一起换了」。那个判据当时成立,而这一轮把它底下那个「一份」拆了:模型选择 从此一个会话一个ProviderRouter(runtime/src/session-model.ts), 于是那种错法在底座上就发生不了。闸门不是被放松了,是它挡的那个东西不存在了。连同 409 一起没的还有 503
no-model-control:provider 层起不来时这个进程 一个会话都建不出来,第一道闸先答 404。所以这条路上今天只剩一道闸: 工厂手里有没有这个会话的运行时 → 404unknown-session。⚠️ 问工厂不问 Hub, 判据见 src/model.ts 的文件头。⚠️
checkpoints.ts那个not-live-session照旧留着,两个码从来不是一回事: 那一条的下一步是「把这段会话接回这个进程」。bootstrap-session-only从这一轮起 全仓零个发送点。
换不成时是 200 + ok:false,而且中文一个字都不上网线
三种拒绝(没凭据 / 不认图片 / 窗口装不下)不是请求的毛病 —— 请求完全合法, 是「这个模型接不住这段对话」。4xx 在界面上读作「刚才那一下发坏了」, 而用户下一步该做的是去配一把 key / 换个模型 / 先清一段。
core 的 evaluateSelection() 产出的是码加参数(SelectionRejection),
reason 那句中文只是它的一次渲染,给同进程宿主(TUI / CLI)用的。
这一层只转码,不转那句话,判据逐字同 settings.ts 文件头第 1 条。
⚠️ 这一处特别容易图省事:镜像上真有中文可拿(reason / warnings 就在同一个
对象上,字段名还写着「给用户看的」)。所以 model.ts 那两份镜像刻意漏掉了
它们 —— 漏掉之后投影里根本写不出来,同 security.ts 那四处的机制。
__tests__/model.test.ts 逐字节扫响应体里有没有 CJK。
hasImages 这一层填,usedTokens 浏览器填
SelectionContext 那两项事实来源不同,都不是随便选的:
hasImages从引擎的会话历史上取,不收浏览器报的数。判据逐字同cli/src/tui-host.ts的sessionHasImages():界面只看得见自己画过的那些,--resume接回来的、以及压缩掉的那一截里的图片它一概不知道。 判成 false 的代价很具体 —— 切到不认图的模型,下一轮把整段带图历史发出去, provider 报 400 且已经计费usedTokens在请求体里,因为它是相邻两次usage的增量, 而服务进程里没人记这个数(tui/src/commands/types.ts上写着同一句)。 不给 = 告诉引擎「我不知道」,窗口那道检查放行
model 字段收的是用户敲的原文,服务端过一次 parseModelRef(它在 protocol,
这一层够得着)。⚠️ 不拆的后果是静默的:ModelControl.set() 收的是拆好的引用,
直接把 "anthropic/claude-…" 当裸模型名递进去的话 provider 一格不动,
于是「换一家」做不到,而响应里一切正常。
已批准的计划:同一个问题,两个数据源
GET /api/sessions/:id/plan 按「问的是不是这个进程正在跑的那个会话」分岔:
活着那一个(runtime.sessionId) → runtime.plan.approvedPlan() 内存
别的会话(只有 SQLite 里那份) → sessionStore.loadPlan(id) 盘看着像是「反正都落盘了,一律读盘更省事」。不是的:落盘是尽力而为 ——
savePlan() 失败只回 false(core 的 PlanModeState 拿到之后什么也不做,
因为那一刻要紧的是这一轮别断),于是盘上那份可能比内存里那份旧一次批准。而模型
system prompt 附加段里挂着的、界面上那一格要展示的,是内存那份。一律读盘的
失败形态很具体:用户刚点完「批准并执行」,那一格还是空的 —— 而模型已经在照着
那份计划干活了。
反过来 runtime.plan 为 null(权限层没起来 → plan 模式整个不存在)时不回退去
读盘:那种进程里没有任何计划在生效,把上一个进程留下的那份端出来,等于声称一份
没人在照着执行的纲正管着这段会话。
回的是 markdown 原文,不是渲染好的 html:正文是模型写的,渲染只能发生在浏览器
那条 marked → DOMPurify → shiki 管线里(web/src/thread/markdown.tsx 是那一侧
唯一能进 innerHTML 的出口)。服务端先渲再下发,等于在网线上凭空多出一条没过
sanitize 的路。
计划正文没有写入口,PATCH / DELETE 一律 404。计划只能由用户在宿主上批一次
来改(PlanModeState.settle)——在这儿开个写口等于让浏览器绕过审批直接往 system
prompt 的附加段里塞东西。
plan 模式:这张界面上唯一一条能改「模型此刻能不能写文件」的路
POST /api/sessions/:id/plan(2026-08-15 补,plan-mode.ts)。载荷只有一个词:
{ "action": "enter" } // 或 "exit"补它的理由是一次误报:TUI 有 /plan、CLI 有 --permission plan,而 web 上进
计划模式只能靠求模型自己调 enter_plan_mode。2026-08-14 的人眼验收因此空跑了一次
——用户说「先给我一份计划、别动手」,模型判断这是单文件改文案、不值得进只读模式,
于是审批 dock 从头到尾没出现(docs/verify/VERIFY_RECORD-web-loose-ends.md §4.1)。
和上面那条 GET 同路径不同方法,因为两者是同一个名词的两面:读那条答「照着
哪份纲」(会话的一份文档,别的会话去读盘),这条答「现在能不能改文件」(进程的
一个开关 —— PlanControl 挂在 runtime 上,不按会话分)。GET 的响应里因此多一个
mode,两件事一发拿齐:拆成两发的失败形态是它们来自两个瞬间,模式已经退出了而
旁边那一格还画着「只读」。
两道闸,顺序是有讲究的:
| 判据 | 结果 |
| -------------------------------- | --------------------- |
| 工厂手里没有这个会话的运行时 | 404 unknown-session |
| 这个会话没有 plan 模式(权限层) | 503 no-plan-mode |
✅ 2026-08-16:中间那道 409
bootstrap-session-only拆了(方案 30 §十四)。它挡的是「A 会话点的开关把 B 会话压成了只读」—— plan 模式当时是进程级的 一份状态(它压的是进程级的权限级别)。这一轮它一个会话一份了 (
runtime/src/session-guard.ts),那种错法在底座上就发生不了。⚠️ 上一版这里还有一句「它只挡住了从 web 主动进出那条路;模型自己调
enter_plan_mode仍然会影响整个进程」。那半件事同一轮一起治了: plan 模式那两个工具在这个会话的工具表上被换成绑在它自己那份状态上的 (withSessionTools)。只治端点不治工具的话,用户点开关进的是自己那份、 模型调工具进的是引导会话那份 —— 两者必须同时成立。
第二道不许假装成功 —— 回 200 的话界面会画上「只读」,而模型下一步照样能改文件。
「本来就是那个状态」不是错:enter 落在一个已经在模式里的会话上回 200 +
changed:false。回 409 的话,两个标签页里靠后按的那个会收到一条错误横幅,而它描述
的是一件已经如愿的事。core 在这一档给的那句 reason(「先用 exit_plan_mode 交计划」)
不往下发:那是写给模型看的,对浏览器没有可执行的下一步。
⚠️ 这条路上表达不出「把级别设成 auto」:
enter只能压到plan、exit只能 恢复成 core 记着的那一档,两者都由PlanModeState说了算。要设一个具体的档位走 下面那条POST .../permission—— 两条路刻意不合并,判据见那一节。PlanControl.forget()也刻意没有借过来:它的语义是「用户自己改了级别, 记着的那一档作废」,而那件事归 runtime 的PermissionsControl.setLevel()第 3 步。服务端手里同时握着setLevel和forget的话,「谁负责作废」立刻有 两个答案,而漏调的那一侧不会红。
权限档:把「模型接下来允许做什么」交给用户(决定 20 ③)
GET / POST /api/sessions/:id/permission(2026-08-15 补,src/permission.ts)。
写那一半的载荷只有一个档位名:
{ "level": "auto" } // plan / default / acceptEdits / auto / bypass补它的理由和上面那条一样是能力早就在、缺的只是转出去:PermissionManager.setLevel()
从第一天就有,TUI 的 /permission 走的就是它。于是 web 上输入框框外那一格权限档
从落地起就是一句只读事实,它的文件头写着「真菜单等设置端点那一轮」。
两道闸和 plan 那条逐字相同(404 unknown-session → 503
no-permission-layer)。
✅ 2026-08-16:中间那道 409
bootstrap-session-only拆了(方案 30 §十四)。 判据同plan/model那两条:PermissionManager的档位这一轮变成一个 会话一份(PermissionManager.scoped()),「A 会话换一次档会把 B 会话一起换掉」 这件事在底座上发生不了了。⚠️ 分家的只有档位那一个字段:规则表、审批缓存、审计流水共用同一份引用。 那三样是关于用户和这台机器的事实 —— 分了家的话,安全中心那一屏 (
GET .../security,它读的是进程那一本流水)会漏掉除引导会话之外的全部裁决。 逐条判据在 core/src/permission/shared.ts。
⚠️ 读那一半不套那道 503。 权限层没起来时那一档仍然说得准
(退回 config.permission)—— 改不动 ≠ 说不准。两者的区别由响应里的 editable
表达,界面据此把那一格画成控件还是画成事实(决定 20 ①:给得出的出口才画控件)。
今天 editable: false 的两档是「这个进程没在跑这段会话」和「这台机器没有权限层」。
⚠️ 判定本体一个字都不在 server。 「托管挡 bypass → setLevel →
plan.forget()」三步在 runtime 的 SessionPermissions.setLevel() 里
(2026-08-16 从 buildPermissionsControl 搬进 session-guard.ts,因为它现在
要按会话跑 N 遍),而它 2026-08-15 之前逐字长在 cli/src/tui-host.ts。
搬上来的理由是第三步最容易在第二份实现里被漏掉,而漏了只在
「plan 模式 + 中途换档 + 批准计划」三件事凑齐时才看得见。
⚠️ 被企业托管挡下是 200 + ok:false,不是 4xx(同 POST .../model):
请求完全合法,是这台机器上没有那一档(disableBypassPermissionsMode)。
用户的下一步不是重试,是去找管理员。哪几档改不成由读那一半的 blocked 提前
说出来,所以这条拒绝是第二道 —— 它挡的是一张开了很久的标签页。
⚠️ 换档不进权限审计流水,而那是一个判断不是一次遗漏。 那本账的口径是
「记裁决,不记动作」(判据是「有没有经过 PermissionManager.check()」,
写在 core 的 permission/audit.ts 文件头,而且它早就点名说过
disableBypassPermissionsMode 拦的切档位「不是一次操作裁决」)。
换档留下的痕迹在别处:换完之后每一次裁决都按新档判。
「谁在几点几分把档位放开了」要另起一本动作账,记在 src/permission.ts
文件末尾那条给合并的人的话里。
检查点回滚:这张界面上破坏力最大的一条路
三条端点(方案 27 的 web 那一半,src/checkpoints.ts):
GET /api/sessions/:id/checkpoints 这个进程现在能退哪几轮
GET /api/sessions/:id/checkpoints/:turn 会动哪些文件、哪些因为漂过而不敢动
POST /api/sessions/:id/rewind {turnIndex, scope, overwrite?}能力本身 2026-05 就做完了(runtime 的 CheckpointControl 三件套),缺的一直是这一段
传输 —— 方案 54 §1.2 那张表第一行的原话是「今天没有主。web 里 checkpoint /
rewind 零命中」。server 之前只借走了一半:workspace/diff.ts 的
WorkspaceCheckpoints 只有 list + preview,而且是给 diff 兜底用的窄形状
(非 git 目录下的改动清单)。补法是另开一个宽的(RuntimeCheckpoints),
不是把 rewind 塞进那个窄的 —— 宽的结构上满足窄的,diff 那侧一行都不用改,
而且仍然够不着 rewind。
四条不许松动的
写入口必须是 POST。
auth.ts的 Origin 校验只在非安全方法上要求(同源 GET 浏览器不发Origin,硬要它等于把 SSE 自己挡在门外)。一条会覆盖 / 删除用户 文件、还会真删消息的路走 GET,就是把 CSRF 的门开着。overwrite收的是路径清单,不是force: true。 引擎侧刻意没有那个形状 (RewindOptions的注释:「『全部覆盖』是个太容易点下去的按钮,而它毁掉的是 用户自己刚写的东西」),网线上也表达不出来。给了但不是字符串数组是 400bad-overwrite,不静默当成「一个都不覆盖」 —— 静默降级会让一个写坏的客户端 收到 200 + 一份「全跳过了」的结果,而它以为自己刚刚覆盖成功了。失败即什么都没改。 引擎那边回退是原子的,所以
files.failed非空时工作区 一个字节都没变,而且不会接着截对话(messagesRemoved必为 null)。 这一档是 200,不是 5xx:请求成功执行了,结果是「什么都没动」。取数一律走
checkpointsOf(ctx, sessionId),也就是sessionFactory.get(sessionId)?.checkpoints—— 这个会话自己那一份。 拿不到(上个进程留下的、被冷却掉的)时列表回空数组(它答的是「这个进程现在 能退哪几轮」,而那个问题对一段只剩历史的会话的正确答案就是「一轮都不能」), preview / rewind 回 409not-live-session。2026-08-15 之前这三条读的是
EpochRuntime.checkpoints(引导会话那一份)。 判据写成sessionId === ctx.runtime.sessionId并起名isLive(),而在多会话 工厂之前那两句话恰好同义。工厂落地当天它们分了家:每个会话各有一个CheckpointManager(workDir跟着自己绑的工作区走,作用域表里它是会话级), 于是第二个会话被告知「一轮都不能退」「不是本进程正在跑的那个」, 而这个进程正握着它的检查点管理器。修法是把那个谓词换成一个取数函数:拿得到对象就等于拿到了哪一段会话, 「判了 A 却退了 B」在这个形状下说不出口。
WebRuntimeView上那个进程级的checkpoints字段一并删掉了 —— 留着不读的话下一个写 handler 的人照样 够得着它,而它编译得过、跑得通、只是答错会话。GET /api/sessions/:id/diff的检查点退路同一天跟着改(它原来把 A 改过的文件挂在 B 的根目录下)。 落地记录在方案 30 §9.5。⚠️
POST /rewind的第一道闸同一天从「只问 Hub」放宽成「Hub 或会话目录」。 原来那句判据抄的是POST /abort/POST /plan的「只剩历史的会话没有对手方」, 而那句话对回退是假的:对手方是~/.epoch/checkpoints/<id>/,文件就在盘上, 缺的是一个知道往哪个目录还原的运行时。放宽之前,同一段只剩历史的会话在GET /checkpoints/:n上收到 409、在POST /rewind上收到 404「没有这个会话」, 而服务端明明列得出它;放宽之后这里的 409 也从不可达变回可达 (接线修好后hub.has()蕴含「工厂手里有」)。
⚠️ 这条路不过
PermissionManager,因此不进权限审计流水。 回退是宿主发起 的(用户在界面上点的),而权限层守的是模型调工具那条路。这不是漏了一道,是两 件事:审计流水回答的是「模型被允许干了什么」,把用户自己动的手记进那本账,等于 把他的操作栽给模型。真要一本「这台机器上谁动了工作区」的账,形状也不一样 —— 它得同时记/rewind、epoch之外的编辑器、和 terminal 里跑的命令。
后台任务::id 现在真的筛选(2026-08-15 收窄)
GET /api/sessions/:id/tasks 一度是这张表里唯一一条 URL 和作用域对不上的端点:
后台任务表是 plugin-terminal/src/background.ts
里一个模块级的 Map,那时不按会话分(那个文件的头写着当时的理由:任务要跨轮
活着,而 plugin 拿不到会话对象),于是同一个进程里的两个会话,这个端点回的是
同一批任务,:id 只做认门。
那条理由 2026-08-15 被拆掉了 —— plugin 拿不到会话对象,但一直拿得到
ToolContext.sessionId。任务表按会话分了区,于是 :id 现在做两件事:
- 认门——不存在的会话 404,不能回一份空清单冒充「这个会话没有后台任务」。 按会话分之后空清单成了更常见的形态,所以这一半反而更要紧
- 筛选——回的是这个会话起的那些,别的会话一条都不在里面
当初把 URL 定成会话作用域是对的,这一轮兑现了它:那段判据原话是「等任务表真的
按会话分了,改的是服务端一个文件,而不是所有客户端的 URL」(与能力页那条逐字相同,
见 src/capability.ts 的文件头)。这次改的确实只有
src/tasks.ts 加任务表自己 —— URL 一个字没动,WireTasksResponse 一个字段没加没减。
反过来那条代价也随之作废:web/src/inspector/output.tsx 顶上原来必须画一句
「这里是整个服务进程的后台任务」,这一轮如实撤掉了(连同它那条 i18n key)。
每个任务给的是输出的末尾一段(TASK_TAIL_BYTES),不是全部:
- 取末尾而不是开头——
taskOutput()默认那条是给模型设计的增量游标,since=0拿到的是二十分钟前的日志,而人来这一格是为了看构建结果和报错栈 omittedBytes报的是真实起点,不是我们请求的那个since:环形缓冲已经丢过 头的时候两者不一样,拿请求值去报,界面上那句「前面还有 N 字节」就是编的- 「丢掉的那一段拿不回来了」由
info.truncated单独说。两件事分开报
读输出内容要 runtime 转出来的 backgroundTaskOutput——server 不许
import plugin-terminal,在这边自己攒一张任务表就是同一件事的第二个真源。
多会话(方案 30 §2.3)
Hub 同时持有 N 个会话,每个会话 N 个 SSE 客户端。四条不许改回去的规矩:
seq是全局单调计数器,不是 per-session —— 它是这条 SSE 的游标, per-session 编号会让Last-Event-ID当场失效- 连接级帧(
connected/stream-reset)seq: 0,不占广播序号 - 断连不是拒绝:
abandon()整轮 +abort(),绝不逐个deny - 一个会话同一时刻只有一个
run():同会话的第二条消息排队 (202 +queued: true),不同会话之间照常并行
活跃会话上限 8(maxActiveSessions),超了就把最久没动过的空闲会话冷却掉:
它从 Hub 里消失、广播一帧 session-cooled,但仍然在会话列表里(live: false),
历史走 GET /messages 回放。正在跑的会话不会被挤 —— 上限是软的。
冷却时 Hub 还会叫一声宿主(SessionHubOptions.onCooled),由它去放掉那个会话在
引擎里占的东西(对话历史、AgentLoop、审批桥)。不接这条线的话,上限只挡住了
Hub 那张表变长,而释放那批内存正是冷却的全部意义。
列表行上的「这一轮怎么样了」(2026-08-15)
WireSessionSummary 多了两格,两格都是 Hub 侧的(DB 里没有对应的列):
| 字段 | 是什么 | 什么时候是 null |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| turnStartedAt | 这一轮开跑那一帧的信封时刻 | 没在跑;或者这个进程不认识它(live: false) |
| lastFinish | 上一轮的终局:reason / durationMs / runUsage | 还没跑过;那一轮没走到 finish;同上 |
三条规矩,改之前先读:
- 两个时刻都从信封上取,不另外读挂钟。
SessionHubOptions.clock上钉着 「每产出一个信封恰好读一次」的不变量(fixture 用例的脚本化时钟逐格对齐它), 所以publish()现在回传发出去的那个信封,起点和收尾时刻都从那儿拿。 - 开跑清终局、收尾清起点。 不清终局的话,一轮没走到
finish的 run (引擎在生成器外面抛了,走drive()的 catch)收尾时,行上挂的还是上上轮 那个'stop'—— 浏览器会拿一枚绿勾去描述一次崩溃。 awaiting-* → running不重置起点。 等人裁决的那段时间是这一轮的一部分。 浏览器 reducer 那侧逐条对应同一套规矩(sessionPatch/turnClock), 两边写法不一样的表现是「同一轮的耗时在回合脚注和后台回执上是两个数」。
它们是给后台回执用的(web 的 .toast:跑完了还是跑砸了、这一轮多久多少
token)。加这一列之前,那条回执只能画一枚绿勾 —— 而一轮触了预算上限的 run 上,
那枚绿勾是撒谎。
第二个会话从哪儿来(2026-08-15)
在这一天之前,createWebServer() 里那句 hub.register() 是全进程唯一的一次 ——
buildRuntime() 只交得出一个 AgentSession。于是上面整节写的东西在生产里一条都
触发不到:活跃会话恒为 1,上限碰不到,session-cooled 这一帧发不出来。
方案 30 §8.3 那张表里的三个 ❌(验收 8 / 12 / 13)记的都是这件事,记了三轮。
现在 POST /api/sessions 走的是 runtime.sessionFactory(SessionFactoryView,
EpochRuntime.sessionFactory 结构上满足它),四档语义:
| 请求 | 结果 |
| ----------------------------- | ---------------------------------------- |
| 什么都不带 | 引导会话,created: false(没变) |
| {workspace},那儿已有活会话 | 复用它,created: false |
| {workspace},那儿没有 | 新建一个绑在那儿的,created: true |
| {workspace, create: true} | 一定新建(同一个仓库里开两件事,决定 7) |
第一行不变是刻意的:前端「404 之后自愈」那条路(方案 43 §12.7 第 5 条)就是 不带 body 探一次、比较回来的 id —— 让它凭空建一个会话,等于给「探一下」加了副作用。
provider 起不来时是 503 no-provider,不是 500:服务本身好着,是它依赖的
那样东西还没就位。500 会让人去重启服务,而正确的下一步是配一把 key。
「哪些东西一个进程一份、哪些一个会话一份」的完整判断在 runtime 的 session-factory.ts 文件头,这边不抄第二份。
发第一句话时把旧会话接回来(2026-08-19)
POST /api/sessions/:id/messages 在原来那道「Hub 不认识 → 404」之前插一段
(sessions/revive.ts → SessionFactory.revive()):
| 这个 id 此刻 | 走哪条 |
| ----------------------------------------------- | ---------------------------------------------------- |
| Hub 手里有 | 照常 |
| 库里有这一行、且有落盘的工作区决定 | 读历史 → 装配 → hub.register() → 继续原路 |
| 同上,但绑不上(目录没了 / 锁着 / 没 provider) | 当场报,用户刚敲的那条消息不丢 |
| 没有决定可循(v7 之前的老行 / 假 id) | 照旧 404 / 409,不猜一个(比如「就绑启动目录」) |
时机是「发第一句话」,不是开机也不是点开。 点开一段旧会话只回放历史
(GET /messages);装引擎、绑目录、过信任闸门统统等到用户敲下第一句话。
这一条是 2026-08-18 那轮反对「把绑定落盘」的理由 ②「开机自动生效的绑定,那一刻
没有人在场」—— 它原样成立,这一轮一个字没推翻:服务起来时一个会话都不接。
⚠️ hub.register() 从此有三个调用点(index.ts 启动那一次、
workspace/handlers.ts 建会话那一次、这一次),而这正是那种「并行分支新增的那个
看不见」的合并点。门禁是扫源码的 AST 用例
(__tests__/hub-register-sites.test.ts),
不是一句叮嘱:每个调用点都得在建会话或复活路径上。上面「第二个会话从哪儿来」那节
开头那句「在这一天之前是全进程唯一的一次」讲的是 2026-08-15 之前,照旧作数 ——
变的只是今天这个数。
顺带治好冷却:release() 刻意不解绑,所以同进程被挤出 Hub 的会话走同一条路,
register() 会把 id 从冷却名单里摘掉。409 session-cooled 那句「重新打开它再发」
2026-08-12 就写在契约上、在这之前没有实现 —— 现在它不再是一道门,输入框照旧
能写,下一句话就把它接回来。⚠️ 所以复活这一层不要求「库里得有这一行」:
行是第一条消息那一刻才建的,一段还没说过话就被冷却掉的会话在库里查不到,
而它的绑定在这个进程的内存里好好待着。
如实记一笔债:会话的 role 不恢复(库里没有这一列),接回来的会话跑在默认 role 上。
工作区:每个会话绑一个(决定 18)
工作区不是进程级的。绑定活在 runtime.workspaces(EpochRuntime.workspaces
满足 WorkspaceView),服务端只做投影和校验的接线:
GET /api/sessions/:id/workspace回:id那个会话的绑定,连同「这个绑定 意味着什么」那三样(extraRoots/trusted/skippedInstructionFiles)。 界面上每一格工作区问的都是「我正看着的这个会话」,所以它们一律走这条(见下)GET /api/config回的workspace是引导会话的绑定,不是服务进程的工作目录。 没绑定时是null—— 「不使用工作区」是真的一档。⚠️ 它不是上面那条的简写:/config是进程级的一条路,首屏拉一次、之后不再变GET /api/config还捎一份knownWorkspaces:本机「已知工作区」最近使用清单, 落在~/.epoch/workspaces.json。本地文件,不是服务(判据是方案 42 §三那条 范围闸门:要一台我们运维的服务器 → 不做)- 会话列表的每一行带一个
workspace,三档:bound/none/unknown(侧栏按「空间 / 任务 / 早先的会话」分组要用它)。toWireSummaryWorkspace()四步优先级:内存绑定 →live→ 库里落着的那次决定 →unknown。 ⚠️ 第 2 步压第 3 步是硬的:这个进程手上有它、可它没绑,那就是没绑 —— 拿库里那一行去顶的表现是「侧栏画着 A、引擎跑在 B」判据史:2026-08-18 这一格是「可空引用」,而那个
null同时表示 「明确没地盘」和「这个进程说不出」;侧栏只能靠live === false && workspace === null去猜,并把猜出来的那批单独列成 「早先的会话」组。同一天还在这儿写着「绑定不落盘」和「别顺手在这里补一个 DB 兜底」—— 两句话 2026-08-19 都过期了:会话库 v7 落了那次工作区决定 (sessions.workspace_state/workspace_root),于是兜的不再是cwd(服务进程的启动目录)而是用户按下的那一下。「早先的会话」这一组留着, 收窄成 v7 之前的老行 DELETE /api/sessions/:id顺带解绑,但不动那份清单
POST /api/sessions 的 workspace 是一个攻击面
让浏览器传路径进来创建会话,等于让它指定「在哪个目录里跑 agent」。三道,缺一不可:
- 只认绝对路径,且必须存在、必须是目录 —— 否则 400
bad-workspace - 过一次工作区信任闸门(core/src/trust/gate.ts)。
未信任目录照样能绑(那两套机制刻意正交),但它的
AGENTS.md/CLAUDE.md一个字都不进 system prompt —— 响应里的trusted/skippedInstructionFiles就是这件事的凭据,界面必须说出来。守着它的用例在 runtime/__tests__/session-workspace.test.ts - bind.ts 那条「非回环不给 token 就拒绝启动」不放松,也不为这个 字段开例外
绑定后不给改(决定 18):中途换地盘会让前面那些工具调用里的 ./src/a.ts
指向另一个目录,而记录里没有任何地方写着从哪条开始换的。
⚠️ 所以「换一个工作区」的唯一形态是新建一个会话 —— 2026-08-15 多会话工厂落地
之后,POST /api/sessions 带另一个目录不再是 409 workspace-locked,而是
建一个绑在那儿的新会话(见上面那张表)。那个码没有作废:它守的仍然是同一条规矩,
只是这条路上再也走不到它了,真正会撞到它的是 runtime 那一层
(SessionWorkspaces.bind)。
「这个会话绑在哪儿」是一条独立端点(2026-08-15)
GET /api/sessions/:id/workspace。补它之前,唯一下发完整绑定的地方是
GET /api/config,而那是进程级的一条路,回的恒是引导会话那一份。多会话落地之后,
浏览器画输入框底栏那一格工作区时手里只有一个错答案:看着会话 B,那一格写的是
会话 A 的地盘。整笔账(连同「三条替代路都不通」)在方案 30 §10.4 第 1 条,
当时的结论逐字是「真正的治法是一条按会话取的读端点,那是服务端一轮活」。
四个答案,一个都不许合并:
| 判据 | 结果 |
| ------------------------------ | ------------------------------------ |
| Hub 和会话目录都不认识这个会话 | 404 unknown-session |
| 绑着一个目录 | 200 + {binding:{state:'bound', …}} |
| 这个进程手里有它、明确没绑 | 200 + {binding:{state:'none'}} |
| 建好了、还没选地盘 | 200 + {binding:{state:'unbound'}} |
| 这个进程手里没有这段会话 | 409 not-live-session |
第 3 行和第 5 行是这条端点原本全部的难点。绑定活在进程内存里、不落盘,所以上
一个进程留下的会话在 workspaces.of() 那儿也是 null —— 和「这个会话真的不使用
工作区」在数据上一模一样。合成一个的表现很具体:一段昨天在某个仓库里干了一整轮活
的会话,今天重新打开时界面上写着「不使用工作区」。判据逐字同
WireSessionSummary.workspace 那条 ⚠️(「那是如实说『这个进程不知道它在哪儿跑』,
不是『它没有工作区』」)。
⚠️ 这条端点 2026-08-19 一个字没改,但那个 409 从此是「此刻」的答案,不是永久的:
用户在那段会话里敲下第一句话,POST /messages 会把它接回来(见上面「发第一句话时
把旧会话接回来」),此后同一条端点对同一个 id 回的是 bound。所以浏览器不能把
409 缓存成结论 —— useSessionWorkspace() 在回合从 idle 迈出来那条边沿上重问一次,
不这么做的话屏幕上会出现这一轮最不能出的画面:用户刚发的那句话正在那个工作区里跑,
而底栏那一格写着「工作区未知」。这里没有跟着去补 DB 回落是刻意的:这条端点答的
是「引擎此刻在哪儿跑」,落盘的那份是用户的一次决定 —— 拿后者来顶,等于对一段
根本没装起来的会话声称它在那儿跑着。要看决定那一份的路是会话列表行上的
workspace(三档,见上)。
⚠️ 响应形状 2026-08-17 从 {workspace: … | null} 换成了 {binding}
(方案 55 PR-1),这是一次破坏性
wire 变更(同一个 monorepo 里 packages/web 是唯一消费方,同批改)。第 4 行就是
换掉它的理由:那个 null 一个值扛了「明确不要地盘」和「建好了还没选」两件事,而
界面上那张 picker 出不出现,正好取决于这两者的差别。不留兼容字段 —— 留一个和
binding 并行的 workspace,等于把刚拆开的歧义按一个新名字请回来。
⚠️ 码取 not-live-session 而不是 bootstrap-session-only,判据是这个仓库拆码
的唯一那一条(两个码 ⇔ 用户的下一步动作不同):后者说的是「这件事整个进程只有
一份」,而工作区绑定是一个会话一份、进程里有 N 个 —— 照那句话做的下一步
(回到最先打开的那个会话)在这条路上解决不了任何问题。
⚠️ 2026-08-16 起
bootstrap-session-only全仓一个发送点都没有了 (模型 / 权限档 / plan 模式三样都收窄成了会话级,方案 30 §十四)。 上面这段拆码的判据一个字都不用改 —— 它三轮逐字相同,而且三轮都是对的。 变的是另一边:那个码曾经描述的三件事,今天没有一件「整个进程只有一份」了。
「这个进程手里有没有它」问的是 hub.has() || hub.isCooled(),不是工厂:被冷却的
会话在工厂那儿已经不在了,而它的绑定还在(冷却只还内存,「它在哪儿跑」仍然
成立)。如实记一条残余边角:一段冷却得久到被挤出冷却名单、且从来没绑过工作区的
会话会拿到 409 而不是 none —— 那是往保守那一侧落,说「我不知道」比说一句可能是
假的「不使用工作区」轻。
⚠️ bound 和 unbound 两档不问 Hub:前者手里有绑定,后者是这个进程自己
workspaces.defer() 记下的一笔 —— 两样都比 Hub 知道得更确切。只有 none 那一档
要再问一次,因为它和「说不出来」在这张表上长得一样。
建一段「还没选地盘」的会话:deferWorkspace(方案 55 PR-1,2026-08-17)
POST /api/sessions 多收一个可选的 deferWorkspace?: boolean:
| 请求 | 建出来是哪一档 |
| ------------------------------------- | ---------------------------- |
| {create:true} | none —— 语义一个字不变 |
| {create:true, workspace} | bound —— 同上 |
| {create:true, deferWorkspace:true} | unbound |
| workspace + deferWorkspace 同时给 | 400 bad-request |
| deferWorkspace 但不带 create | 400 bad-request |
⚠️ 选「加一个显式字段」而不是「把不给 workspace 改判成 unbound」,判据是后者
会把现有调用方的语义悄悄换掉:能力页那张专家卡、前端 recoverFromGone 那个探针
都在发不带 workspace 的请求,而它们要的正是「不使用工作区」。改判之后那些会话会
在底栏挂出一张催人选目录的 picker,而没有任何人按过它。
两道 400 都是不静默降级(同 workspace 字段写坏时那一条):悄悄丢掉一个客户端
明确给了的字段,它会拿到一段和它以为的不一样的会话。
这一档只多一格记账,不改装配:那段会话的 AgentLoop 照旧跑在装配时那个
workDir 上(同不绑工作区那一档),差别全在 WorkspaceControl.stateOf() 报的是
unbound 还是 none。所以服务端走的是 runtime.workspaces.defer(),不给
sessionFactory.create() 加参数 —— 塞进工厂等于让一个纯记账的事实穿过整条装配链。
给一段 unbound 的会话选一个:POST /api/sessions/:id/workspace(方案 55 PR-1)
body: {root: '<绝对路径>'} | {none: true}补的又是一个出口,不是一个能力:WorkspaceControl.bind() 从 2026-08-12 起就在
「宿主看得见的那一面」上,路径校验、信任闸门、locked 幂等三样一个字都没动 ——
这条端点只是第二个调用点。
只有 unbound 收得动:
| 这个会话此刻 | 结果 |
| ------------ | ---------------------------------------------------- |
| unbound | 绑 / 转 none,200 回改完之后的整份 binding |
| bound | 409 workspace-locked —— 决定 18「绑定后不给改」 |
| none | 409 workspace-locked —— 「不使用」也是一次决定 |
⚠️ 第三行是这条端点唯一一处会被读错的地方:none 看着像「还空着」,但它是用户
明确按过的一档(决定 18 侧栏「任务」组的成员)。让它还能被改,等于给一段说过
「不要地盘」的会话补一个地盘 —— 而它前面那些工具调用里的相对路径已经按服务进程的
启动目录解析过了,判据逐字同 bound 那一行。不做 PATCH,也不做换绑。
请求体两支恰好一支:两支都给 / 都不给一律 400 bad-workspace。「两支都给」
不许挑一支执行 —— 那两支通向的是两段完全不同的会话。这一层一次路径校验都不做
(存不存在、是不是目录一律不看):那道门在 SessionWorkspaces.bind() 上,而且它还
连着信任判定,两处各写一份判据它们迟早走散。
{none:true} 那一支走的是 workspaces.release(),而那不是「解绑」:一个
unbound 的会话身上没有绑定可解,这条路上它只做一件事 —— 把 defer() 那一笔摘掉。
能这么用的前提是上面那道 409 闸(对一个 bound 的会话调它就是换绑)。
成功时回改完之后的整份 binding,前端因此不用再拉一次 —— 少这一下的代价很具体: 那一发和补拉那一发之间的一帧,底栏那一格写的还是「还没选」。
GET /api/workspaces/dirs:这台机器上有哪些目录(方案 55 PR-2,2026-08-17)
GET /api/workspaces/dirs → 起点锚(三组:用户 home / 服务进程 cwd / 已知工作区)
GET /api/workspaces/dirs?path=<abs> → 列这一层的子目录稿子那张工作区菜单里「打开本地文件夹」的底座。上一轮把它判成「这个形态在浏览器里
没有出口」,理由是浏览器给不出服务端的绝对路径 —— 前半句对,后半句把
「浏览器自己挑」和「让服务端告诉浏览器有哪些目录」当成了同一件事。前者确实没出口
(showDirectoryPicker() 交出来的是沙盒句柄),后者就是这条读端点。
进程级,不挂在会话下面。 判据和 .../:id/settings、.../:id/security 那两条
刻意相反:那两处跟着工作区和信任判定走,所以是会话作用域;而「这台机器上有哪些
目录」是机器事实,和你现在看着哪个会话无关(同 GET /api/config)。
挂在 /api/workspaces 底下而不是 /api/fs:它只服务于「挑一个工作区」这一件事。
叫 fs 会让下一个人以为这是一条通用文件访问端点,然后往上加读文件。
?hidden=1:「默认不列」原来是一句悄悄话(2026-08-18)
上面那一行原来只有前半句。「默认」那两个字当时没有落点 —— 网线上没有任何东西
能把点目录要回来,屏幕上也没有任何东西说过「这一层刚被我藏掉了几个」。于是它在用户
那儿的表现不是「默认」,是「这台机器上没有那些目录」:~/.claude、~/.epoch、
一个 .config 底下的技能树,在这张 picker 上都等于不存在,而手敲入口只有已经知道
自己丢了什么的人才会想去用。
所以补了两样,判据和 omitted 那一条逐字同源(静默过滤读起来就是「就这些」):
?hidden=1—— 这一发把.开头的目录也列出来。1和true都认,别的一律当 没给(含hidden=0);认不出来的值不报 400 —— 它是个显示开关,拼错的后果是 「这一屏和默认一样」,而响应里的hidden当场就把真相说出来了- 响应里的
hidden: number—— 这一层藏掉了几个。带?hidden=1那一发是0(那时一个都没藏),锚屏也是0(那一屏一个字都不过滤)
和 omitted 是两个数,不合并:那一个说「这一层多到列不完」(下一步换个更具体的
路径),这一个说「有一类东西被规则挡了」(下一步把开关打开)。被藏掉的不占封顶那
1000 个名额,所以同一层上两个数可以同时非零,而且它们不相加。
⚠️ 默认仍然是不列,那一半没翻:一个 home 底下几十个点目录会把「我多半想去的
地方」冲成一屏噪音。⚠️ 而它不是一道安全闸,别当成一道 —— 过得了鉴权那两道的人
本来就能手敲 ~/.ssh 让 bind() 认它(方案 55 第一天就有那条路)。这个开关只谈
「这一屏上看得见什么」,一个字都不谈信任。
⚠️ 不做「限制在 home 之下」这类根闸。 用户的仓库经常在 D:\ / /Volumes/…,
加一道 99% 场景下只会挡住正当操作的闸,换不来安全 —— 真正的边界是鉴权那两道,
过得了那两道的人本来就能让 agent 在任意目录里跑命令。
⚠️ 不带 path 时给的是三组锚,不是文件系统根。 别让人从 / 开始爬 ——
那是这类 picker 唯一一个必然会被骂的地方。known 那一组的顺序原样照抄
workspaces.known()(MRU,服务端已经排好了)。
⚠️ 它把服务端的目录树读给浏览器 —— 这是设计意图,也是这一轮唯一的新攻击面
| 做什么 | 为什么 |
| ------------------------------ | ------------------------------------------------------------------------ |
| 走现有那两道鉴权,一道不减 | cookie + Origin/Host。不进 /api/health 那条免鉴权白名单 |
| 只列目录,不列文件 | 这条端点的宾语是「工作区」,而工作区是目录 |
| 一次一层,不递归 | 递归的响应体没有自然上界,还把「这台机器上有什么」一次性交出去 |
| 默认不列 . 开头的目录 | .ssh / .gnupg / .aws 出现在一张挑工作区的列表里是纯粹的无谓暴露 |
| 每层封顶 1000 | 超了就截断并说出来(omitted)—— 静默截断读起来像「就这些」 |
| EACCES / ENOENT 分开说 | 403 vs 404:「没权限看」和「不存在」是用户下一步动作完全不同的两件事 |
| 不对符号链接做特殊处理 | 一次一层本来就不会走进环。原样列,path 给 resolve() 之后的 |
⚠️ 不做「限制在 home 之下」这类根闸。 用户的仓库经常在 D:\ / /Volumes/…,
加一道 99% 场景下只会挡住正当操作的闸,换不来安全 —— 真正的边界是鉴权那两道,
过得了那两道的人本来就能让 agent 在任意目录里跑命令。
⚠️ 不带 path 时给的是三组锚,不是文件系统根。 别让人从 / 开始爬 ——
那是这类 picker 唯一一个必然会被骂的地方。known 那一组的顺序原样照抄
workspaces.known()(MRU,服务端已经排好了)。
为什么不弹一个原生对话框
四条判据(远程场景 / 平台分叉 / 测不了 / 信任分档无解)全文在
src/workspace/browse.ts 的文件头,这边不抄第二份。
一句话:epoch web --host 0.0.0.0 --token … 是一等公民,而原生框弹在服务进程
那台机器上。那份文件头里同时如实记着「同机那一档原生框体验更好」,
以及「哪天这个赌注被推翻了,回来读那一段,别重新推一遍」。
POST /api/workspaces:新建一个工作区目录(方案 55 PR-3,2026-08-17)
body: {parent: '<绝对路径>', name: '<单段名字>'}稿子决定 18 那张菜单里的「新建工作区」。它就是一次 mkdir,后面三件都不做:
- 不落「已知工作区」清单。 用户接着挑中它那一下走
POST /api/sessions/:id/workspace,而bind()自己就会remember()—— 在这儿多记一笔等于两份真源 - 不建会话。 「建一个目录」和「在那儿开一段对话」是两个动作
- 不给它任何信任记录(见下)
| 情况 | 结果 | 用户的下一步 |
| -------------------------- | -------------------------- | ------------------- |
| name 带分隔符 / . .. | 400 bad-workspace | 换个名字 |
| parent 不是绝对路径 | 400 bad-workspace | 改输入 |
| 已经存在 | 409 workspace-exists | 换个名字 / 直接挑它 |
| parent 不可写 | 403 workspace-denied | 换个地方 |
| parent 不存在 | 404 not-found | 先建父目录 |
⚠️ 已存在 → 409,不复用。 「新建」按下去得到一个别人的目录是这条路上最贵的
错:那个目录里可能有一份 CLAUDE.md、一个 git 仓库、一堆别的东西,而用户以为自己
拿到的是一个空目录。想用它就在列表里挑它 —— 那条路上他看得见「来过」那个标记。
实现上靠的是 mkdirSync 的默认 recursive: false(recursive: true 对已存在的
目录是静默成功,那正好是这条要防的形态),它同时管住了「不替用户补建父目录」。
⚠️ 新建出来的目录未信任 —— 这一条推翻了设计稿
稿子那个模态原来写着「新建的目录默认信任(是用户刚建的空目录),而 『打开本地文件夹』挑的既有目录不是」。那一半判成不做(方案 55 §2.6),两条判据:
- 信任的写入口刻意不上网线(方案 42 PR-3)。
runtime/src/build.ts上trust那段 JSDoc 逐字写着:「把它搬到 HTTP 上等于让浏览器决定『哪个目录里的文件能当 指令读』。这一条不翻 ——WebRuntimeView里没有这个字段,server 也永远不该转发它。」 - 而且稿子那条判据本身站不住。 「它是用户刚建的空目录」——空只在 t=0 那一刻
成立,而信任记录是持久的。这个目录接下来要发生的第一件事,正是让 agent 在里面
干活;agent 完全写得出一份
CLAUDE.md,而那份文件下一轮就会作为指令进 system prompt —— 一条模型给自己授信的路。
代价如实写着:往里放 AGENTS.md 之后它不会被加载,安全中心那一屏照旧会说清楚。
授信走 off-wire 那条路(epoch trust add <root>,或嵌入宿主自己
runtime.trust.record())。守着这一条的是一条反向验证用例
(workspace-create.test.ts:建完再绑上去,trusted 必须是 false)——
缺了它,上面这两条判据就只是一段散文。
工作区 diff
GET /api/sessions/:id/diff 比较的根是这个会话绑定的工作区。旧内容走
git cat-file blob <sha>,新内容直接读工作区文件,都绕开 .gitattributes 配的
diff driver 和 textconv:那两样会让 git diff 输出转换后的内容,拿它当「文件真实
内容」是错的。不是 git 仓库时退化到
方案 27 的检查点范围(那时没有
旧内容,如实标 no-snapshot,不拿现状冒充)。二进制和超过 512KB 的只报字节数。
会话没绑工作区时回空视图 + notes: ['no-workspace'],不是 404、也不是
no-changes:「没有地盘」和「地盘里没东西」是两件事,用户的下一步动作完全不同。
文件
| 文件 | 职责 |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| bind.ts | 绑定策略(decideBinding)、默认端口 4400、一次性 token 生成 |
| auth.ts | token → cookie、Origin / Host 双校验(防 DNS rebinding) |
| routes.ts | 路由分派 + 错误形状 |
| api.ts | 端点处理(工作区和能力页除外),只经 WebRuntimeView 这一层看 runtime |
| capability.ts | 能力页取数与投影 + 技能正文那条下钻。runtime 那三样能力的结构镜像(不许 import core) |
| mcp.ts | 那两条 MCP 动作(重连 / 添加)+ McpControl 的镜像。⚠️ 进程级,URL 上都没有 :id |
| commands.ts | 斜杠命令:命令表投影、「这行是不是 /xxx」的判定、两层作用域包装的接线 |
| tasks.ts | 后台任务取数与投影。⚠️ 那张表是进程级的,:id 只认门(见上) |
| plan.ts | GET /api/sessions/:id/plan。两个数据源,按「是不是活着那个会话」分 |
| plan-mode.ts | POST 那一条:进 / 出 plan 模式。模式是进程级的,与上一行的作用域差一层 |
| checkpoints.ts | 检查点三条端点 + RuntimeCheckpoints 镜像。⚠️ 破坏力最大的一条路(见上) |
| web-root.ts | 运行期定位随包发出去的前端产物(dist/web)——嵌入宿主不传 webRoot 时靠它 |
| context.ts | ApiContext / WebRuntimeView —— 两组端点共用,放这儿是为了无环 |
| hub.ts | SessionHub——唯一写者、回合状态机、连接生命周期 |
| sessions/registry.ts | 会话登记表:多会话、每会话排队、活跃上限与 LRU 冷却 |
| sessions/factory.ts | 多会话工厂的结构镜像 + 「建不出来」→ 503 的映射(方案 30 §6.3) |
| sessions/summary.ts | 列表行 = Hub 侧状态(含回合钟)+ DB 侧元信息,两个真源拼起来;工作区那一格的四步优先级 |
| sessions/revive.ts | 发第一句话时把库里还在、进程手里没有的会话接回来(v7 落盘的那次工作区决定) |
| sessions/types.ts | HubSession / FrameSink / TurnDecorator(放这儿断开 hub ↔ registry 的环) |
| workspace/git.ts | git 管道命令(--raw -z / cat-file blob / ls-tree -l) |
| workspace/browse.ts | 列一层目录 + 起点锚(方案 55 PR-2):封顶 / 过滤 / 三种失败,以及「为什么不弹原生框」 |
| workspace/diff.ts | 工作区 diff:git 主路径 + 检查点退路 + 二进制 / 超大判定 |
| workspace/session.ts | 会话 ↔ 工作区绑定:runtime 那片能力的镜像、三档 Wire 投影、两处请求体校验 |
| workspace/handlers.ts | POST /api/sessions(可选工作区 / deferWorkspace)、.../:id/workspace 读写、.../:id/diff |
| sse.ts | SSE 帧编码 + Last-Event-ID 续传 |
| ring.ts | 全局 seq 的环形缓冲(续传的数据来源) |
| assets.ts | 静态托管 + SPA 回退;产物不存在时发占位页 |
| http.ts | IncomingMessage → 与框架无关的请求视图 |
两个刻意的取舍
端口被占了就报错退出,不自动找下一个可用端口。 自动顺延意味着每次启动的 URL 都可能
不同:用户收藏的那个标签页会连到一个「不是这次这个」的服务上(或者一个别的程序),
而两边都不会报错。--port 本来就是给这种情况准备的。
Origin 必须与 Host 逐字符相等,Host 的端口必须等于监听端口。 只查白名单的话,
Origin: http://127.0.0.1:4400 + Host: evil.com:4400 会同时过两道。代价是任何反向
代理都必须同时改写这两个头——web 的 vite dev 代理就是因为这个必须配
changeOrigin: true + headers: { Origin: ... },少一个就是全线 403。
开发
pnpm --filter @epoch-agent/server test__tests__/wire-fixtures.test.ts 断言本服务产出的信封序列逐条等于
fixtures/wire/。改了事件形状就会红,那是故意的。
