@epoch-agent/server 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,81 +84,87 @@ tarball 里真的有那些字节。
84
84
 
85
85
  ## 端点
86
86
 
87
- | 端点 | 说明 |
88
- | ------------------------------------------------- | -------------------------------------------------------------------------------------- |
89
- | `GET /api/health` | **不鉴权**,只回 `{ok, version}` |
90
- | `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(`?lang=` 见下) |
91
- | `GET /api/events` | SSE,负载是 `WireEnvelope` |
92
- | `GET /api/sessions[?q=…]` | 会话列表;带 `q` 走 FTS5 检索 |
93
- | `POST /api/sessions` | 幂等确保会话,可带 `{workspace?}`(见下) |
94
- | `GET /api/sessions/:id` | **这一段**自己那一行;**不受上一条那 50 行封顶**(见下) |
95
- | `DELETE /api/sessions/:id` | 真删:Hub + SQLite + 检查点目录 |
96
- | `PATCH /api/sessions/:id` | `{title}` → 重命名,回改完之后的整行 |
97
- | `GET /api/sessions/:id/messages` | 历史回放(**冷却 / 上个进程留下的也回放**) |
98
- | `POST /api/sessions/:id/messages` | 发消息,立即 202;这一轮在跑就**排队**;带 `@:` 时 202 上有 `references` 收据(见下) |
99
- | `GET /api/sessions/:id/reference-candidates` | 打 `@:` 那一刻面板该列哪几段会话;`?q=` 只碰 id / cwd / 标题(**不搜转录文本**,见下) |
100
- | `POST /api/sessions/:id/abort` | 中止本轮,并丢掉排在后面的消息 |
101
- | `GET /api/sessions/:id/approvals` | 重连后补拉挂起的审批 |
102
- | `POST /api/approvals/:requestId` | `{outcome}` 接回引擎 |
103
- | `GET /api/sessions/:id/questions` | 重连后补拉挂起的提问 |
104
- | `POST /api/questions/:requestId` | `{answers, skipped?}` → 接回引擎 |
105
- | `GET /api/sessions/:id/capabilities` | 专家 / 技能 / 连接器三栏(方案 42 PR-1) |
106
- | `GET /api/sessions/:id/skills/:name` | 一个技能的正文(方案 56 §1.2,**不计数**,见下) |
107
- | `POST /api/mcp` | 加一台 MCP,写进 `~/.epoch/mcp.json`(**进程级 + 落盘,回环绑定才给**,见下) |
108
- | `POST /api/mcp/:name/reconnect` | 重连一台 MCP(方案 56 §1.1,**进程级**,见下) |
109
- | `GET /api/mcp/config` | `mcp.json` 的**原文** + 指纹(**回环绑定才给,含这条 GET**,见下) |
110
- | `PUT /api/mcp/config` | `{text, revision}` → 整份覆盖(**只写盘,不生效**,见下) |
111
- | `POST /api/mcp/config/apply` | `{revision}` 把盘上那份搬进这个进程(**立刻生效**,见下) |
112
- | `POST /api/skills/import/preview` | 「将导入什么」,不写字节(方案 42 §六,**进程级**,见下) |
113
- | `POST /api/skills/import` | 从本机目录导入技能(**收路径不收字节**,见下) |
114
- | `POST /api/roles` | 新建一个身份,写进 `~/.epoch/agents/`(**回环绑定才给**,见下) |
115
- | `GET /api/plugins` | 装着的 + 市场里有什么 + `pendingRestart`(**进程级**,方案 59 E2,见下) |
116
- | `POST /api/plugins/install/preview` | 「将安装什么」,不写字节(**只收 `<市场>/<插件>`**,见下) |
117
- | `POST /api/plugins/install` | `{ref, token}` → 真装(**回环绑定才给;装完不生效**,见下) |
118
- | `POST /api/plugins/update/preview` | `{name}` → 更新会带来什么(**更新也要过这一道**,见下) |
119
- | `POST /api/plugins/update` | `{name, token}` → 真更新(**回环绑定才给**) |
120
- | `POST /api/plugins/uninstall` | `{name}` 卸载(**回环绑定才给**;卸完那些扩展物还活着,见下) |
121
- | `GET /api/sessions/:id/security` | 权限 / 沙箱 / 策略 / 工作区 / 审计流水五块(42 PR-3) |
122
- | `GET /api/sessions/:id/tools` | 有哪些工具、此刻谁在挡它们(43 §七,**不是上一条的子集**) |
123
- | `GET /api/sessions/:id/settings` | 每一项设置赢在哪一层 + 能写进哪几层(43 §七,见下) |
124
- | `POST /api/sessions/:id/settings` | `{key, layer, value}` → 把一个键写进一层(见下) |
125
- | `GET /api/sessions/:id/model` | 这个会话**此刻**用哪个模型(方案 26,见下) |
126
- | `POST /api/sessions/:id/model` | `{model}` 立刻换;给 `null` = 回到配置里那个 |
127
- | `GET /api/providers` | 支持哪几家 + 各自 key 配了没(**进程级**,闭合集,见下) |
128
- | `POST /api/providers/:type/models` | 探这一家有哪些模型(**进程级**,`{refresh?}` 绕缓存,见下) |
129
- | `GET /api/sessions/:id/commands` | 自定义斜杠命令表(**不含正文**,见下) |
130
- | `GET /api/sessions/:id/tasks` | 后台任务 + 输出尾巴(**进程级**,见下) |
131
- | `GET /api/sessions/:id/plan` | 已批准的计划(**单数**)+ plan 模式的状态 |
132
- | `POST /api/sessions/:id/plan` | `{action:'enter'\|'exit'}` / 出 plan 模式 |
133
- | `GET /api/sessions/:id/permission` | 此刻是哪一档 + 哪几档改不成(决定 20 ③,见下) |
134
- | `POST /api/sessions/:id/permission` | `{level}` → 立刻换档(见下) |
135
- | `POST /api/sessions/:id/approval-cache` | `{id}` 撤销一条**缓存的审批决定**(**不是 `/approvals`**,见下) |
136
- | `GET /api/sessions/:id/checkpoints` | 这个进程现在能退哪几轮(方案 27,见下) |
137
- | `GET /api/sessions/:id/checkpoints/:turn` | 按下去之前那份清单:会动哪些文件 |
138
- | `POST /api/sessions/:id/rewind` | `{turnIndex, scope, overwrite?}` 真的退 |
139
- | `GET /api/sessions/:id/artifacts` | 这个会话改过哪些**文件**(42 PR-4) |
140
- | `GET /api/sessions/:id/workspace` | **这个会话**绑在哪儿(见下) |
141
- | `GET /api/workspaces/dirs[?path=][&hidden=1]` | 这台机器上有哪些目录(**进程级**,见下) |
142
- | `POST /api/sessions/:id/workspace` | `{root}`\|`{none:true}` → 给还没选地盘的那一档选(方案 55 PR-1,见下) |
143
- | `POST /api/workspaces` | `{parent, name}` → 新建一个目录(一次 `mkdir`,见下) |
144
- | `GET /api/sessions/:id/diff` | **这个会话**的工作区 diff(见下) |
145
- | `GET /api/schedules` | 定时任务两个 tab 一次取齐(**进程级**,方案 45 PR-3,见下) |
146
- | `GET /api/schedules/pending` | 还欠着的那几张欠条(dock 第四档 / 侧栏那个点,PR-4)。⚠️ `pending` 是**保留段**不是 id |
147
- | `POST /api/schedules` | 建一条定时任务,**同时注册进 OS**(见下) |
148
- | `GET /api/schedules/:id` | 一条的全部字段 + 下次运行 + 清单适不适用 |
149
- | `PATCH /api/schedules/:id` | 改一条;`{enabled}` 就是那个启用开关 |
150
- | `DELETE /api/schedules/:id` | 删一条,**同时撤掉 OS 注册**;录像留着 |
151
- | `GET /api/schedules/:id/runs` | 这条任务的运行记录(最近 20 次,最新在前) |
152
- | `POST /api/schedules/:id/run` | 「先跑一次」——**真的起一次 agent 运行**(见下) |
153
- | `POST /api/schedules/:id/fix` | `{runId}` → 把那一轮的欠条加进授权清单(见下) |
154
- | `GET /api/schedules/:id/runs/:runId/recording` | 那一次的 `WireEnvelope` 录像(见下) |
155
- | `GET /api/sessions/:id/goal` | **这段会话**的目标 + 表单那四把尺子(方案 52 PR-4,见下) |
156
- | `POST /api/sessions/:id/goal` | `{objective, maxRounds?}` → 建一个(**人专用**) |
157
- | `PATCH /api/sessions/:id/goal` | `{action}` edit / budget / pause / resume / complete。**`blocked` 不在这条路上** |
158
- | `DELETE /api/sessions/:id/goal` | 清掉这个目标 |
159
- | `GET /api/goals/blocked` | 哪几段会话此刻卡着(dock 第四档,**进程级**,见下) |
160
- | `GET /api/artifacts/:sid/:name` | 工具产出的大块内容 |
161
- | `GET /*` | 静态产物 + SPA 回退(`assets.ts`) |
87
+ | 端点 | 说明 |
88
+ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
89
+ | `GET /api/health` | **不鉴权**,只回 `{ok, version}` |
90
+ | `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(`?lang=` 见下) |
91
+ | `GET /api/events` | SSE,负载是 `WireEnvelope` |
92
+ | `GET /api/sessions[?q=…]` | 会话列表;带 `q` 走 FTS5 检索 |
93
+ | `POST /api/sessions` | 幂等确保会话,可带 `{workspace?}`(见下) |
94
+ | `GET /api/sessions/:id` | **这一段**自己那一行;**不受上一条那 50 行封顶**(见下) |
95
+ | `DELETE /api/sessions/:id` | 真删:Hub + SQLite + 检查点目录 |
96
+ | `PATCH /api/sessions/:id` | `{title}` → 重命名,回改完之后的整行 |
97
+ | `GET /api/sessions/:id/messages` | 历史回放(**冷却 / 上个进程留下的也回放**) |
98
+ | `POST /api/sessions/:id/messages` | 发消息,立即 202;这一轮在跑就**排队**;带 `@:` 时 202 上有 `references` 收据(见下) |
99
+ | `GET /api/sessions/:id/reference-candidates` | 打 `@:` 那一刻面板该列哪几段会话;`?q=` 只碰 id / cwd / 标题(**不搜转录文本**,见下) |
100
+ | `GET /api/sessions/:id/file-stat?path=…&path=…` | 这几条路径还在不在、是什么、打不打得开(「路径可点」的底座,一发最多 32 条,见下) |
101
+ | `GET /api/sessions/:id/file?path=…` | 一个**文本**文件的正文(图片 / PDF / 媒体走下一条) |
102
+ | `GET /api/sessions/:id/file-bytes?path=…` | 字节流。图片 / PDF / 媒体内联,**其余一律 `attachment`**(安全判断,见下) |
103
+ | `POST /api/sessions/:id/file-open` | `{path}` → 交给系统默认应用(**同机 + 工作区内 + 扩展名白名单**,见下) |
104
+ | `POST /api/sessions/:id/abort` | 中止本轮,并丢掉排在后面的消息 |
105
+ | `GET /api/sessions/:id/approvals` | 重连后补拉挂起的审批 |
106
+ | `POST /api/approvals/:requestId` | `{outcome}` 接回引擎 |
107
+ | `GET /api/sessions/:id/questions` | 重连后补拉挂起的提问 |
108
+ | `POST /api/questions/:requestId` | `{answers, skipped?}` 接回引擎 |
109
+ | `GET /api/sessions/:id/capabilities` | 专家 / 技能 / 连接器三栏(方案 42 PR-1) |
110
+ | `GET /api/sessions/:id/skills/:name` | 一个技能的正文(方案 56 §1.2,**不计数**,见下) |
111
+ | `POST /api/mcp` | 加一台 MCP,写进 `~/.epoch/mcp.json`(**进程级 + 落盘,回环绑定才给**,见下) |
112
+ | `POST /api/mcp/:name/reconnect` | 重连一台 MCP(方案 56 §1.1,**进程级**,见下) |
113
+ | `GET /api/mcp/config` | `mcp.json` 的**原文** + 指纹(**回环绑定才给,含这条 GET**,见下) |
114
+ | `PUT /api/mcp/config` | `{text, revision}` → 整份覆盖(**只写盘,不生效**,见下) |
115
+ | `POST /api/mcp/config/apply` | `{revision}` 把盘上那份搬进这个进程(**立刻生效**,见下) |
116
+ | `POST /api/skills/import/preview` | 「将导入什么」,不写字节(方案 42 §六,**进程级**,见下) |
117
+ | `POST /api/skills/import` | 从本机目录导入技能(**收路径不收字节**,见下) |
118
+ | `POST /api/skills/remove` | `{name}` → 删掉一份用户级技能(**不可撤销;只有用户级删得掉**,见下) |
119
+ | `POST /api/roles` | 新建一个身份,写进 `~/.epoch/agents/`(**回环绑定才给**,见下) |
120
+ | `GET /api/plugins` | 装着的 + 市场里有什么 + `pendingRestart`(**进程级**,方案 59 E2,见下) |
121
+ | `POST /api/plugins/install/preview` | 「将安装什么」,不写字节(**只收 `<市场>/<插件>`**,见下) |
122
+ | `POST /api/plugins/install` | `{ref, token}` → 真装(**回环绑定才给;装完不生效**,见下) |
123
+ | `POST /api/plugins/update/preview` | `{name}` 更新会带来什么(**更新也要过这一道**,见下) |
124
+ | `POST /api/plugins/update` | `{name, token}` → 真更新(**回环绑定才给**) |
125
+ | `POST /api/plugins/uninstall` | `{name}` → 卸载(**回环绑定才给**;卸完那些扩展物还活着,见下) |
126
+ | `GET /api/sessions/:id/security` | 权限 / 沙箱 / 策略 / 工作区 / 审计流水五块(42 PR-3) |
127
+ | `GET /api/sessions/:id/tools` | 有哪些工具、此刻谁在挡它们(43 §七,**不是上一条的子集**) |
128
+ | `GET /api/sessions/:id/settings` | 每一项设置赢在哪一层 + 能写进哪几层(43 §七,见下) |
129
+ | `POST /api/sessions/:id/settings` | `{key, layer, value}` → 把一个键写进一层(见下) |
130
+ | `GET /api/sessions/:id/model` | 这个会话**此刻**用哪个模型(方案 26,见下) |
131
+ | `POST /api/sessions/:id/model` | `{model}` 立刻换;给 `null` = 回到配置里那个 |
132
+ | `GET /api/providers` | 支持哪几家 + 各自 key 配了没(**进程级**,闭合集,见下) |
133
+ | `POST /api/providers/:type/models` | 探这一家有哪些模型(**进程级**,`{refresh?}` 绕缓存,见下) |
134
+ | `GET /api/sessions/:id/commands` | 自定义斜杠命令表(**不含正文**,见下) |
135
+ | `GET /api/sessions/:id/tasks` | 后台任务 + 输出尾巴(**进程级**,见下) |
136
+ | `GET /api/sessions/:id/plan` | 已批准的计划(**单数**)+ plan 模式的状态 |
137
+ | `POST /api/sessions/:id/plan` | `{action:'enter'\|'exit'}` → 进 / 出 plan 模式 |
138
+ | `GET /api/sessions/:id/permission` | 此刻是哪一档 + 哪几档改不成(决定 20 ③,见下) |
139
+ | `POST /api/sessions/:id/permission` | `{level}` → 立刻换档(见下) |
140
+ | `POST /api/sessions/:id/approval-cache` | `{id}` → 撤销一条**缓存的审批决定**(**不是 `/approvals`**,见下) |
141
+ | `GET /api/sessions/:id/checkpoints` | 这个进程现在能退哪几轮(方案 27,见下) |
142
+ | `GET /api/sessions/:id/checkpoints/:turn` | 按下去之前那份清单:会动哪些文件 |
143
+ | `POST /api/sessions/:id/rewind` | `{turnIndex, scope, overwrite?}` → 真的退 |
144
+ | `GET /api/sessions/:id/artifacts` | 这个会话改过哪些**文件**(42 PR-4) |
145
+ | `GET /api/sessions/:id/workspace` | **这个会话**绑在哪儿(见下) |
146
+ | `GET /api/workspaces/dirs[?path=][&at=home][&hidden=1]` | 这台机器上有哪些目录(**进程级**,见下) |
147
+ | `POST /api/sessions/:id/workspace` | `{root}`\|`{none:true}` → 给还没选地盘的那一档选(方案 55 PR-1,见下) |
148
+ | `POST /api/workspaces` | `{parent, name}` 新建一个目录(一次 `mkdir`,见下) |
149
+ | `POST /api/workspaces/pick` | 在**服务进程那台机器**上弹一个系统目录框(**同机那一档**,2026-09-01,见下) |
150
+ | `GET /api/sessions/:id/diff` | **这个会话**的工作区 diff(见下) |
151
+ | `GET /api/schedules` | 定时任务两个 tab 一次取齐(**进程级**,方案 45 PR-3,见下) |
152
+ | `GET /api/schedules/pending` | 还欠着的那几张欠条(dock 第四档 / 侧栏那个点,PR-4)。⚠️ `pending` 是**保留段**不是 id |
153
+ | `POST /api/schedules` | 建一条定时任务,**同时注册进 OS**(见下) |
154
+ | `GET /api/schedules/:id` | 一条的全部字段 + 下次运行 + 清单适不适用 |
155
+ | `PATCH /api/schedules/:id` | 改一条;`{enabled}` 就是那个启用开关 |
156
+ | `DELETE /api/schedules/:id` | 删一条,**同时撤掉 OS 注册**;录像留着 |
157
+ | `GET /api/schedules/:id/runs` | 这条任务的运行记录(最近 20 次,最新在前) |
158
+ | `POST /api/schedules/:id/run` | 「先跑一次」——**真的起一次 agent 运行**(见下) |
159
+ | `POST /api/schedules/:id/fix` | `{runId}` → 把那一轮的欠条加进授权清单(见下) |
160
+ | `GET /api/schedules/:id/runs/:runId/recording` | 那一次的 `WireEnvelope` 录像(见下) |
161
+ | `GET /api/sessions/:id/goal` | **这段会话**的目标 + 表单那四把尺子(方案 52 PR-4,见下) |
162
+ | `POST /api/sessions/:id/goal` | `{objective, maxRounds?}` → 建一个(**人专用**) |
163
+ | `PATCH /api/sessions/:id/goal` | `{action}` → edit / budget / pause / resume / complete。**`blocked` 不在这条路上** |
164
+ | `DELETE /api/sessions/:id/goal` | 清掉这个目标 |
165
+ | `GET /api/goals/blocked` | 哪几段会话此刻卡着(dock 第四档,**进程级**,见下) |
166
+ | `GET /api/artifacts/:sid/:name` | 工具产出的大块内容 |
167
+ | `GET /*` | 静态产物 + SPA 回退(`assets.ts`) |
162
168
 
163
169
  载荷契约在 [protocol/src/wire-rest.ts](../protocol/src/wire-rest.ts),能力页那个端点
164
170
  的在 [wire-capability.ts](../protocol/src/wire-capability.ts),SSE 的信封在
@@ -376,6 +382,12 @@ POST /api/sessions/abc/permission?lang=zh → 错误消息也跟着这个语
376
382
  推给每个客户端各自解读)。同样只读,没有写端点。写法和边界在
377
383
  [EMBEDDING.md §9](../../docs/EMBEDDING.md)。
378
384
 
385
+ 2026-09-02 起同一条端点上还有第二个这样的键:`sidebarMenu`(宿主自定义的那几行侧栏
386
+ 菜单,来源 `EpochConfig.sidebarMenu`)。形状、口径、「没配就不带这个键」逐字同
387
+ `brand` —— **这一层原样转发,一个字都不改**:`label` 不翻译(它是宿主的话,
388
+ 不同于同一份响应里的 `diagnostics.detail`),`icon` 认不认得出来也不在这儿判,
389
+ 那张白名单是第一方 Web UI 的私有词表,而这条端点也服务着自己画前端的宿主。
390
+
379
391
  `/api/health` 在鉴权**之前**,其余全部在之后:不带 cookie 时 `/api/sessions` 是 401,
380
392
  而 `/api/health` 仍然 200。`DELETE` / `PATCH` / `POST` 和别的写请求一样要过 Origin 校验,
381
393
  少给 `Origin` 头就是 403 —— **`POST /api/sessions` 那个新的 `workspace` 字段没有例外**。
@@ -435,6 +447,43 @@ POST /api/sessions/abc/permission?lang=zh → 错误消息也跟着这个语
435
447
  400/403/404 **刻意相反**:`empty` 那一档带着 `issues`,而那串诊断就是用户唯一
436
448
  看得懂「为什么没导进来」的东西,`{error:{code,message}}` 塞不下它。
437
449
 
450
+ #### `POST /api/skills/remove` —— **能加就得能删**(2026-09-01)
451
+
452
+ 起因是一句话:这一栏原来只有「导入技能」,拿掉一份得让用户自己去
453
+ `~/.epoch/skills/` 里翻目录 —— 而那一栏里的每一行都是**每一轮都在花钱**的常驻开销
454
+ (行尾那个 tokens 数就是它)。
455
+
456
+ ⚠️ **没有 `remove/preview`,那不是没做完。** 导入那个 token 防的是「用户看过的
457
+ 那一份」和「真装上的那一份」不是一份(预览和导入之间目录可能被改过)。删除这边
458
+ 没有这条缝:要删掉的东西由**名字**唯一确定。删除的风险不是「删错一份」,是
459
+ **不可撤销**(`rmSync(recursive)`,没有回收站),而挡它的是另外两样东西 ——
460
+ 界面上那一步「再按一次确认」,和回执里那个 `path`(用户得看得见自己刚销毁了盘上
461
+ 的哪一处)。判据全文在 [src/skill-remove.ts](./src/skill-remove.ts) 文件头。
462
+
463
+ ⚠️ **这条和导入一样,不吃 `refusedByLan`,而那是一个判断。** 只给删除加一道闸的
464
+ 结果,是 `--host 0.0.0.0` 那一档下技能变成「只能加不能删」—— 那正是这次要修的病,
465
+ 换个地方原样长回来。两条一起加闸不行(那是把已经在用的导入功能收回去),所以这一格
466
+ 的口径是「和导入同档」。真要收,得两条一起收,而那是一次独立的决定。
467
+
468
+ 失败也是 200 + `ok:false`,四个 `reason` 各对**一个不同的下一步**:
469
+
470
+ | `reason` | 什么情况 | 用户该做什么 |
471
+ | ----------- | ------------------------------ | ---------------------------------- |
472
+ | `not-found` | 这个名字这台服务端不认识 | 手上那份清单旧了,刷新 |
473
+ | `readonly` | 项目级 / 插件 / 宿主那三档 | 去它自己那个目录里删(话里带路径) |
474
+ | `failed` | 真的没删掉(盘上那一处出了事) | 看 `detail` 里那句系统错 |
475
+ | `missing` | 这台服务端压根没起技能系统 | 去看配置 |
476
+
477
+ - **只有用户级删得掉**,而那道闸在 core(`requireWritable()`)不在这一层 ——
478
+ 判两遍会有一天判得不一样,而只有 core 那一遍碰得到磁盘。界面上那颗按钮只画在
479
+ 用户级那一组的行上,但那是**体贴,不是安全性质**:手搓一条
480
+ `POST /api/skills/remove {"name":"项目里那条"}` 会拿到 200 + `readonly`
481
+ - **没删成时 `name` 和 `path` 是空的**,不是「请求里那个名字」:那一格答的是
482
+ 「谁真的没了」,回一个其实还在盘上的名字是这条端点最坏的说谎方式
483
+ - 名字空着也走 `ok:false` + `not-found`,**不是 400**(判据同导入那两条:
484
+ `{error:{code,message}}` 在界面上会走到「请求坏了」那一支去)
485
+ - `detail` 那句话在 core / runtime 里**现渲染**,所以 `?lang=` 要一路递下去
486
+
438
487
  #### `POST /api/mcp/:name/reconnect` —— **进程级**的那个动作
439
488
 
440
489
  ⚠️ **这条 URL 上没有 `:id`,而且不许有。** MCP 连接是装配期起的那几个 client,
@@ -858,6 +907,41 @@ agent」),前者只能命中这个进程已经加载好的那张角色表。
858
907
  读当前面、算预算都在那儿),结果在 202 上回来 —— 另开一条解析端点的话,
859
908
  「面板看到的」和「真发出去的」之间会多一个能对不上的地方。
860
909
 
910
+ ### 输出里的路径可点:`/file-stat` `/file` `/file-bytes` `/file-open`(2026-09-02)
911
+
912
+ 模型正文和工具卡里的路径以前是纯文字,用户只能复制走。这四条端点是「点一下就
913
+ 在站内看到它 / 用系统应用打开它」的底座,实现在
914
+ [workspace/file-view.ts](src/workspace/file-view.ts) 和
915
+ [workspace/file-open.ts](src/workspace/file-open.ts)。
916
+
917
+ **它们不比模型自己那条 `file_read` 更宽**,这是这一组全部的安全性质:四条一步都
918
+ 不自己碰文件系统,全部经由 `EpochRuntime.workspaceFiles`(那一格的权限判定用的就是
919
+ `toolName: 'file_read'`,和 `@文件` 提及走同一本审批缓存账本)。用户写的
920
+ `deny file_read(.env*)` 因此照样拦得住浏览器。
921
+
922
+ - **`GET .../file-stat?path=…&path=…`** —— 「路径可点」的判据:**只有回 `ok` 的
923
+ 那几条才画成可点**(决定 20 ①)。一发最多 32 条(`WIRE_FILE_STAT_MAX`),
924
+ 超出的**照数回给浏览器**(`overLimit`),不悄悄丢。每条带 `kind`
925
+ (`text`/`image`/`pdf`/`audio`/`video`/`opaque`,浏览器据此决定用哪个标签)
926
+ 和 `openable`(**扩展名白名单 ∧ 同机**,浏览器不许自己再算一遍);
927
+ - **`GET .../file?path=…`** —— 文本正文,**不截**(截断是显示层的事)。
928
+ 超过 2MB 回 413 `too-large`;不是文本回 415 `not-text`;
929
+ - **`GET .../file-bytes?path=…`** —— 字节流。⚠️ **`inline` 是白名单,而这是一条
930
+ 安全判断**:工作区里完全可能躺着一个 `evil.html`(模型自己就写得出来),
931
+ 内联渲染它 = 在本服务同源里执行任意脚本,而这个源上挂着能操作 agent 的全部 API。
932
+ 所以只有图片 / 音频 / 视频 / PDF 给 `inline` + 真 MIME,其余一律
933
+ `application/octet-stream` + `attachment`,全部带 `X-Content-Type-Options: nosniff`。
934
+ PDF 那一档额外压 `Content-Security-Policy: sandbox`(推进不透明源);
935
+ - **`POST .../file-open`** `{path}` —— 交给用户机器上的系统默认应用。
936
+ 四道闸:**同机**(`ctx.sameMachine`,和 `POST /api/workspaces/pick` 问的是同一句话
937
+ `isSameMachine()`)→ 会话 + 工作区根 → 边界 + 权限 + **扩展名白名单** →
938
+ `spawn` **不经 shell**。白名单只收「站内渲染不了、而系统应用打得开」的那些
939
+ (Office / PDF / 本机才播得动的媒体);**代码和文本一律不在**(它们走站内查看,
940
+ 顺带绕开 Windows 上 `.js` / `.vbs` 被 Windows Script Host **执行**那颗雷),
941
+ 压缩包也不在(macOS 上「打开」一个 zip 是解压它,那是写副作用)。
942
+ ⚠️ 拒绝走 **200 + `{ok:false, refusal, message}`**,只有闸才是 4xx ——
943
+ 前者界面要原地显示那句话,不是弹一个网络错误。
944
+
861
945
  ### 逐条专家(方案 57)
862
946
 
863
947
  `POST /api/sessions/:id/messages` 上多一个可选字段 `role` ——「**这一条**用哪个专家」。
@@ -1531,8 +1615,15 @@ body: {root: '<绝对路径>'} | {none: true}
1531
1615
  ```
1532
1616
  GET /api/workspaces/dirs → 起点锚(三组:用户 home / 服务进程 cwd / 已知工作区)
1533
1617
  GET /api/workspaces/dirs?path=<abs> → 列这一层的子目录
1618
+ GET /api/workspaces/dirs?at=home → **home 那一层**(2026-09-01;`path` 压 `at`)
1534
1619
  ```
1535
1620
 
1621
+ ⚠️ **`?at=home` 是加法,不带参数那一发的语义一个字没改。** 它换回来的是一条
1622
+ **只有服务端算得出**的路径(浏览器不知道这台机器的 home:`about.homeDir` 是
1623
+ `~/.epoch` 不是它),为的是界面那一轮的「打开就落在一层真目录上」——
1624
+ 锚屏没有父级,于是「往上一层」在打开那一屏上根本画不出来,用户得先点进一层才翻得
1625
+ 上去。回的东西和带 `path` 那一发**逐字同型**,它不是第三种响应。
1626
+
1536
1627
  稿子那张工作区菜单里「打开本地文件夹」的底座。上一轮把它判成「这个形态在浏览器里
1537
1628
  没有出口」,理由是浏览器给不出**服务端**的绝对路径 —— **前半句对,后半句把
1538
1629
  「浏览器自己挑」和「让服务端告诉浏览器有哪些目录」当成了同一件事**。前者确实没出口
@@ -1598,7 +1689,7 @@ GET /api/workspaces/dirs?path=<abs> → 列这一层的子目录
1598
1689
  那是这类 picker 唯一一个必然会被骂的地方。`known` 那一组的顺序**原样照抄**
1599
1690
  `workspaces.known()`(MRU,服务端已经排好了)。
1600
1691
 
1601
- #### 为什么不弹一个原生对话框
1692
+ #### ⚠️ 「为什么不弹一个原生对话框」那四条 2026-09-01 被重判了一次
1602
1693
 
1603
1694
  四条判据(远程场景 / 平台分叉 / 测不了 / 信任分档无解)全文在
1604
1695
  [src/workspace/browse.ts](src/workspace/browse.ts) 的文件头,**这边不抄第二份**。
@@ -1606,6 +1697,43 @@ GET /api/workspaces/dirs?path=<abs> → 列这一层的子目录
1606
1697
  那台机器**上。那份文件头里同时如实记着「同机那一档原生框体验更好」,
1607
1698
  以及「哪天这个赌注被推翻了,回来读那一段,别重新推一遍」。
1608
1699
 
1700
+ **那一天是 2026-09-01。** 重判的结果:① 远程那条**成立**,于是原生框只在同机那一档
1701
+ 开(下面那条端点自带一道闸);② 平台分叉**弱化**(只写 macOS / Windows,别的回落
1702
+ 自绘);③ 测不了**只剩一半**(命令表 / 取消判定 / 那道闸都是纯函数);
1703
+ ④ 信任那条**作废** —— 方案 55 §2.6 判掉了「新建目录默认信任」,今天所有目录一律
1704
+ 未信任、绑定时过同一道闸,所以回一条裸路径什么都没丢。
1705
+
1706
+ ⚠️ **这条端点没有被替掉,也不该被替掉**:远程、SSH 起的服务、没有图形界面的机器上,
1707
+ 它是唯一一条选得到目录的路 —— 也就是第 1 条判据说的那件事,而它今天仍然是真的。
1708
+
1709
+ ### `POST /api/workspaces/pick`:弹一个系统目录框(2026-09-01)
1710
+
1711
+ ```
1712
+ body: 无
1713
+ 200: {path} | {canceled: true}
1714
+ ```
1715
+
1716
+ 「打开本地文件夹」在**同机**那一档上的实现(另一档还是上面那条 `dirs`)。判据全文在
1717
+ protocol 的 `WirePickDirectoryResponse` 那一节,实现在
1718
+ [src/workspace/native-pick.ts](src/workspace/native-pick.ts)。这儿只记四件事:
1719
+
1720
+ - **一道闸,四件事的合取**(`resolveNativeDirPicker`):回环绑定 + 没有 `SSH_CONNECTION`
1721
+ / `SSH_TTY` + 平台是 macOS / Windows。装配时算一次,同时下发到 `GET /api/config`
1722
+ 的 `nativeDirPicker` —— **界面那一格是给人的,这条端点上那道 403 才是给网线的**
1723
+ (判据逐字同 `POST /api/roles`)。⚠️ 继承了一个修不掉的盲区:`ssh -L` 端口转发时
1724
+ 请求从回环进来、`SSH_*` 又不在服务进程的环境里,那时框会弹在一台没人看着的机器上;
1725
+ 那种部署该用 `--host 0.0.0.0`(那时这道闸自己就关了)。
1726
+ - **取消是一档真值**(`{canceled: true}`),不是失败也不是空路径 —— 用户按了「取消」
1727
+ 之后界面上什么都不该说。
1728
+ - **没有超时**:一个对话框可以开五分钟。收尾靠请求本身 —— 浏览器一断(那张菜单
1729
+ 收起来 / 标签页关了),服务端就杀掉那个子进程。
1730
+ - **同一进程只许一个框**(第二发 409 `native-picker-busy`):两个框叠在一台机器的
1731
+ 屏幕上,用户答的那一个未必是浏览器还等着的那一个。
1732
+
1733
+ ⚠️ **`POST` 而不是 `GET`,理由比 `mkdir` 那条还硬**:这一下会在用户屏幕上真的弹一个
1734
+ 窗。写成 GET 的话,任何一个页面都能靠一个 `<img src>` 让别人的屏幕上冒出一个文件框
1735
+ (`auth.ts` 的 Origin 校验只在非安全方法上要求)。
1736
+
1609
1737
  ### `POST /api/workspaces`:新建一个工作区目录(方案 55 PR-3,2026-08-17)
1610
1738
 
1611
1739
  ```