@epoch-agent/server 0.9.0 → 0.10.0

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Lang, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginVisibility, WirePluginStatus, WirePluginRefuseReason, WirePluginUploadFieldName, WirePluginUploadFields, WirePluginEntry, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireCachedApproval, WireCachedDecision, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingValueKind, WireSettingsResponse, EpochConfig, TokenUsage, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
1
+ import { Lang, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireSkillStageFailure, WireProjectInventory, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginVisibility, WirePluginStatus, WirePluginRefuseReason, WirePluginUploadFieldName, WirePluginUploadFields, WirePluginEntry, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireCachedApproval, WireCachedDecision, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingValueKind, WireSettingsResponse, EpochConfig, TokenUsage, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
2
2
  import { SerializableApprovalRequest, SerializableQuestionRequest, ApprovalRevokeResult, LevelChangeResult, WorkspaceFilesView } from '@epoch-agent/runtime';
3
3
  import { ServerResponse } from 'node:http';
4
4
 
@@ -94,6 +94,76 @@ interface UiPreset {
94
94
  */
95
95
  declare function firstScreenUrl(host: string, port: number, token: string, ui?: UiPreset): string;
96
96
 
97
+ /**
98
+ * 输入框附件的那两半(2026-09-11)—— 上传落盘,以及发消息时把它们变成部件。
99
+ *
100
+ * ## 这条路和 `@文件` 的分工
101
+ *
102
+ * | | `@路径`(方案 63) | 这一条 |
103
+ * | --- | --- | --- |
104
+ * | 文件从哪来 | 用户工作区里**本来就有** | 用户**刚拖进来** |
105
+ * | 字节谁持有 | 用户的盘 | 先经 HTTP 落进 `~/.epoch/attachments/<会话 id>/` |
106
+ * | 边界 | `resolveInWorkspace()`(工作区内) | 构造保证(只能落在那一个目录里) |
107
+ * | 权限 | 每次读过 `file_read` | **录入时不过**,判据见下 |
108
+ *
109
+ * 两条路最后产出的是**同一种部件**(`EpochFilePart`),走同一本预算账,
110
+ * 收据也进同一个 `files` 数组 —— 于是界面上「这条消息附了哪些文件」只有一张表,
111
+ * 而不是两种长得不一样的卡片。
112
+ *
113
+ * ## ⚠️ 这一层**一个字节的文件读写都没有**
114
+ *
115
+ * 落盘走 `streamBodyToFile()`(`http.ts` 的原语,边收边写),落点由
116
+ * `ctx.runtime.attachments.place()` 给;读回来走 `.read()`。
117
+ * 判据同 mentions.ts 文件头那条:路径的清洗规则住在 infra 的 `bySession`,
118
+ * **而 `server` 不许 import `infra`** —— 在这一层拼一次路径就是给同一个目录
119
+ * 第二个说法,分叉的表现是「传上去了、模型说看不到」,而 HTTP 200、
120
+ * 盘上真有那个文件、日志一个字不报。
121
+ *
122
+ * ## ⚠️ 上传这条端点没有大小上限
123
+ *
124
+ * 是拍过的决定,不是漏了。字节边收边写,峰值内存是一个 chunk;护栏是认证
125
+ * (同别的 API)和那本 512MB 的总账。完整判据在 `http.ts` 的
126
+ * `streamBodyToFile` 和 core 的 `ATTACHMENT_MAX_TOTAL_BYTES` 上 ——
127
+ * 两处都写着「这拦不住当前会话里传一个 2GB 的文件」。
128
+ */
129
+
130
+ /**
131
+ * 读一次附件正文的结果。`EpochRuntime.attachments` 的 `read()` 结构上满足它。
132
+ *
133
+ * 两档失败的码**和 `WorkspaceFilesView.FileReadOutcome` 对齐** ——
134
+ * 少了 `denied`(这条路上不做权限判定,判据见 `AttachmentControlView.read`)。
135
+ */
136
+ type AttachmentReadView = {
137
+ ok: true;
138
+ path: string;
139
+ text: string;
140
+ bytes: number;
141
+ } | {
142
+ ok: false;
143
+ reason: 'unreadable' | 'binary';
144
+ bytes?: number;
145
+ };
146
+ /** 引擎那片附件面。`EpochRuntime.attachments` 结构上正好满足它 */
147
+ interface AttachmentControlView {
148
+ /** 挑落点。`undefined` = 名字不合法(投影之后不是一个文件名) */
149
+ place(sessionId: string, name: string): {
150
+ path: string;
151
+ name: string;
152
+ } | undefined;
153
+ /** 已落盘的名字 → 绝对路径;指不出去时 `null` */
154
+ resolve(sessionId: string, name: string): string | null;
155
+ /**
156
+ * 读正文。
157
+ *
158
+ * ⚠️ **它不过 `file_read` 权限判定**,判据全文在 runtime 的
159
+ * `attachment-stage.ts` 上。一句话:附件是用户递进来的消息内容,
160
+ * 不是 agent 的一次文件访问;判定在模型之后真去读它的那一刻。
161
+ */
162
+ read(sessionId: string, name: string): AttachmentReadView;
163
+ /** 清历史会话的附件,best-effort,不抛 */
164
+ prune(keepSession: string): number;
165
+ }
166
+
97
167
  /**
98
168
  * `mcp.json` 的**原文**那三条端点 —— `GET` / `PUT /api/mcp/config` 和
99
169
  * `POST /api/mcp/config/apply`(2026-08-18)。
@@ -640,6 +710,86 @@ interface SkillRemoveControlView {
640
710
  remove(name: string, lang?: Lang): Promise<RemoveResultView>;
641
711
  }
642
712
 
713
+ /**
714
+ * 「把你手里那一份交上来」那条端点 —— `POST /api/skills/stage`(2026-09-13)。
715
+ *
716
+ * ```
717
+ * POST /api/skills/stage?name=<原始文件名> <原始字节> → 落地 + 解包 → 一条本机路径
718
+ * ```
719
+ *
720
+ * 界面上技能页那个落区背后就是这一条。它**一个技能都不装**:出口只是一条服务端
721
+ * 本机路径,接着还要走 [skill-import.ts](./skill-import.js) 那两条
722
+ * (`import/preview` → `import`,含 `token` 那道防 TOCTOU 的缝)。
723
+ * 判据全文在 protocol 的 `WireSkillStageResponse` 上,以及 runtime 的
724
+ * [skill-stage.ts](../../runtime/src/skill-stage.ts) 文件头。
725
+ *
726
+ * ## ⚠️ 一、隔壁那两条说「不收字节」,这一条收 —— 为什么不是自相矛盾
727
+ *
728
+ * [skill-import.ts](./skill-import.js) 的文件头第 1 条逐字写着「请求体里只有
729
+ * `path` 和 `token`,**没有 `content`**」。那句话**今天仍然成立,而且是关于那两条
730
+ * 端点的**:那两条会往 `~/.epoch/skills/` 里落东西,而落进那儿 = 进往后每一轮的
731
+ * `<available_skills>`。
732
+ *
733
+ * 这一条落进的是 `~/.epoch/skill-drops/`,**那不在 `scanSkillDirs` 的扫描范围里**。
734
+ * 也就是说这一发结束时 system prompt 里一个字都没变 —— 「传上来」和「装上了」
735
+ * 是两件分得开的事,而分开它们正是这个设计成立的全部原因。
736
+ *
737
+ * ## ⚠️ 二、那道 403 在**读 body 之前**,而这一条上它有两重意思
738
+ *
739
+ * 1. **省内存那一重**(同 `POST /api/plugins/stage`):body 上限 50 MiB,
740
+ * 漏一格就是让 LAN 上的人一发一发地灌这台机器的内存;
741
+ * 2. **本体那一重**:`--host 0.0.0.0` 那一档下我们不知道浏览器在谁那儿,而
742
+ * 这条端点的产物**下一步就会被人按确认装进 system prompt**。技能目录的闸门表
743
+ * 上用户级那一格逐字写着「无(是用户自己的机器)」——「是用户自己的机器」
744
+ * 正是这一档下不成立的那条。
745
+ *
746
+ * ⚠️ **它和身份 / 插件那两道 403 的措辞不能互抄**,判据记在 catalog 里
747
+ * `skill_stage_lan_exposed` 上面那几行:技能不起子进程(插件那句是假话),
748
+ * 而且**这一档下导入本身没关**(只是不收上传)—— 那句出路必须说出来,
749
+ * 否则用户的结论是「这个功能没了」。
750
+ *
751
+ * ⚠️ **界面那一侧还有一道,但它是给人的**:`GET /api/config` 上带着 `lanExposed`,
752
+ * 能力页据此在按下去之前就把落区收起来。两道各答一半 —— 别因为界面画了就把这一道
753
+ * 摘掉,那一格是客户端自己改得动的布尔(口径逐字同 `role-add.ts` 文件头)。
754
+ *
755
+ * ## ⚠️ 三、这一层一条策略判断都没有
756
+ *
757
+ * 后缀认不认、名字削不削、解不解得开,**全在 runtime 那一侧**。这一层只做三件事:
758
+ * 判那道 403、读 body、把 `?name=` 原样递下去。
759
+ *
760
+ * 文件名**在这儿不净化**,判据逐字同 `plugin.ts` 里 `stagePlugin` 那一处:
761
+ * 唯一那道收窄在 runtime 的 `safeBaseName`(它是落点的产地),在这儿先削一遍的
762
+ * 下场是两处各削一半,而两处削法分叉的那天没有任何东西会红。
763
+ *
764
+ * ## 为什么单开一个文件
765
+ *
766
+ * 同 `skill-import.ts` 从 `capability.ts` 里分出来那两条判据。外加一条这一份
767
+ * 自己的:**镜像必须跟着 handler 走**。`SkillImportControlView` 上只有
768
+ * `preview` / `import` 两个方法,于是那个文件**写不出**「往盘上落一份字节」这件事;
769
+ * 把这条 handler 塞进去、再往那张镜像上加一个 `stage`,等于让那两条端点顺手
770
+ * 也能落字节 —— 判据同 `capability.ts` 上 `mcp` 那一格为什么是两张镜像的交集。
771
+ */
772
+
773
+ /**
774
+ * `SkillStageFlow`(runtime)在服务端眼里的样子 —— **只有一个动作**。
775
+ *
776
+ * ⚠️ 它**不和 `SkillImportControlView` 合并**,判据在文件头最后一节:
777
+ * 合并之后 `import/preview` 那个 handler 也就顺手能往盘上落字节了。
778
+ */
779
+ interface SkillStageControlView {
780
+ stage(input: {
781
+ name: string;
782
+ data: Uint8Array;
783
+ }, lang?: Lang): Promise<{
784
+ ok: true;
785
+ path: string;
786
+ } | {
787
+ ok: false;
788
+ reason: WireSkillStageFailure;
789
+ detail: string;
790
+ }>;
791
+ }
792
+
643
793
  /**
644
794
  * 能力页的取数与投影 —— `GET /api/sessions/:id/capabilities`(方案 42 PR-1),
645
795
  * 外加技能正文那条下钻(方案 56 §1.2)。
@@ -793,6 +943,24 @@ interface RuntimeCapabilities {
793
943
  * 要收得两条一起收。
794
944
  */
795
945
  readonly skillWrite: SkillImportControlView & SkillRemoveControlView;
946
+ /**
947
+ * 把浏览器里那一份字节换成一条本机路径(2026-09-13)—— 技能页那个落区背后的
948
+ * 那一条。**进程级**,同上面那一格。
949
+ *
950
+ * ## ⚠️ 它**单开一格,不并进 {@link skillWrite}**
951
+ *
952
+ * 上面那一格的 ⚠️ 说「导入收的是路径不是字节」,而那句话必须继续为真。
953
+ * 并进去之后 `skill-import.ts` 里那两个 handler 就顺手也能落字节了 ——
954
+ * 收窄靠的是**镜像里没有那个方法**,不是靠 review 盯着(判据同下面 `mcp`
955
+ * 那一格为什么是两张镜像的交集)。真源在 runtime 那边同样是两格
956
+ * (`skillWrite` / `skillStage`),这儿只是照着对形状。
957
+ *
958
+ * ⚠️ 这一条**吃一道回环绑定的 403**(`ctx.lanExposed` 为真就拒,连 body 都不读),
959
+ * 而它**不在这份镜像上** —— 镜像答的是「runtime 借得到什么」,闸门答的是
960
+ * 「这一发准不准发」。判据同 `roleWrite` 那一格,全文在
961
+ * [skill-stage.ts](./skill-stage.js) 文件头第二节。
962
+ */
963
+ readonly skillStage: SkillStageControlView;
796
964
  /**
797
965
  * MCP 的写口:重连一台(方案 56 §1.1)+ 加一台(2026-08-18)+ **那份文件的原文
798
966
  * 读写与应用**(2026-08-18)。**全都是进程级**。
@@ -849,8 +1017,12 @@ declare function collectCapabilities(runtime: RuntimeCapabilities, tools: readon
849
1017
  };
850
1018
  trust: {
851
1019
  trusted: boolean;
1020
+ exec: boolean;
852
1021
  };
853
- } | null, role: string | null): WireCapabilitiesResponse;
1022
+ } | null, role: string | null, project: {
1023
+ inventory: WireProjectInventory;
1024
+ trustRequest: boolean;
1025
+ }): WireCapabilitiesResponse;
854
1026
  /**
855
1027
  * 一份 `SKILL.md` 最多发多少字节。形状照
856
1028
  * [workspace/diff.ts](./workspace/diff.ts) 的 `MAX_DIFF_BYTES`。
@@ -1282,14 +1454,52 @@ interface GoalCatalogView {
1282
1454
  * 4. **一个会话同一时刻只有一个 `run()`**(方案 30 §2.3)。同一会话的第二条消息
1283
1455
  * **排队**而不是并发;不同会话之间**可以**并行 —— 那正是多会话的意义。
1284
1456
  * 理由与「谁在表里、谁排在后面」的实现都在 [sessions/registry.ts](./sessions/registry.ts)。
1285
- * 5. **旁听者(`observe()`)不许进 `sinks`**(方案 54 §三)。`sinks.size` 是用来回答
1286
- * 「**人**还在不在」的那个计数器,全仓有四处判它:`connectionCount`、
1287
- * `armGrace()` 的触发、`drainQueue()`、`reap()`。宿主为了在自己的窗口上点亮一枚
1288
- * 状态灯会常驻一条旁听 —— 那要是算一条连接,「全断 30 秒放弃挂起的审批 + 中止
1289
- * 回合」和「全断了丢掉排队的消息」就一起失效了,**而宿主没有任何办法知道**。
1290
- * 加集合、不加计数:新东西进不了那个计数器。
1457
+ * 5. **旁听者(`observe()`)默认不算「人」**(方案 54 §三)。「人还在不在」这个问题
1458
+ * 有三处在问:`armGrace()` 的触发、`drainQueue()`、`reap()`。宿主为了在自己的
1459
+ * 窗口上点亮一枚状态灯会常驻一条旁听 —— 那要是算一条连接,「全断 30 秒放弃挂起
1460
+ * 的审批 + 中止回合」和「全断了丢掉排队的消息」就一起失效了,**而宿主没有任何
1461
+ * 办法知道**。所以缺省一个字没变。
1462
+ *
1463
+ * ⚠️ 2026-09-11 起多了一档**由宿主自己声明**的例外:`observe(sink, {counts:true})`
1464
+ * 算一份在场({@link ObserveOptions.counts})。一盏状态灯和一条即时通讯桥
1465
+ * 在这个问题上的答案本来就不同,而只有宿主分得清自己是哪一种 ——
1466
+ * 判据全文在那一格上。
1467
+ *
1468
+ * `connectionCount` **仍然只数 SSE**:它答的是「有几条连接」而不是
1469
+ * 「有没有人」,`/api/config` 把那个数报给界面看。
1291
1470
  */
1292
1471
 
1472
+ /** {@link SessionHub.observe} 的可选项。 */
1473
+ interface ObserveOptions {
1474
+ /**
1475
+ * 这条旁听**算不算一份「在场」**。缺省 `false`(一个字都没变的老行为)。
1476
+ *
1477
+ * 🔴 **它决定的是三件事:全断之后要不要放弃挂起的审批、要不要中止在跑的回合、
1478
+ * 以及排队的消息要不要丢掉。** 给 `true` 的那一刻,这条旁听在这三处和一个
1479
+ * 开着的标签页等价(`connectionCount` 除外 —— 那一格答的是「有几条连接」)。
1480
+ *
1481
+ * ## 什么时候该给 `true`
1482
+ *
1483
+ * 判据只有一条:**这条旁听的另一头有没有一个人**,而且那个人**答得了审批**。
1484
+ *
1485
+ * | 宿主那一侧是什么 | 给什么 | 为什么 |
1486
+ * | --- | --- | --- |
1487
+ * | 窗口角上一枚状态灯 | 不给 | 灯不会答审批。它亮着不代表有人看着 |
1488
+ * | 写日志 / 采指标 | 不给 | 同上,而且它常驻整个进程 |
1489
+ * | 即时通讯桥(飞书 / Slack / Telegram…) | `true` | 人在手机上,**审批卡就在他手里** |
1490
+ *
1491
+ * ⚠️ **给错的后果不对称,所以缺省落在「不算」那一侧。**
1492
+ * 该给不给:人在手机上等着,而回合在 30 秒后被中止、排队的消息被丢掉
1493
+ * (2026-09-11 一个飞书宿主实测到的就是这一档 —— 用户关掉电脑上的窗口,
1494
+ * 手机上那一轮 30 秒后停了,而他什么都没做)。
1495
+ * 不该给却给了:没人看着的时候一轮活会一直跑下去、挂起的审批永远挂着 ——
1496
+ * 那正是这三处判定存在的理由(方案 20 §4.3)。
1497
+ *
1498
+ * ⚠️ **不做成 Hub 级的开关**(`countObserversAsConnections` 那种):同一个宿主
1499
+ * 完全可以同时挂一盏灯和一条桥,而那两条的答案不同。这件事是**每条旁听**的属性。
1500
+ */
1501
+ counts?: boolean;
1502
+ }
1293
1503
  interface SessionHubOptions {
1294
1504
  /** 环形缓冲容量,默认 512 */
1295
1505
  ringCapacity?: number;
@@ -1399,6 +1609,13 @@ declare class SessionHub {
1399
1609
  * 两个集合的写法让那四处一个字都不用改。
1400
1610
  */
1401
1611
  private readonly observers;
1612
+ /**
1613
+ * 上面那些旁听里,**声明了自己算一份在场**的那几条({@link ObserveOptions.counts})。
1614
+ *
1615
+ * 第三个集合而不是在 `observers` 上加标记位,理由同上面那段:判「人还在不在」
1616
+ * 的三处要问的是一个**数**,而 `Set.size` 就是那个数。
1617
+ */
1618
+ private readonly counted;
1402
1619
  /** requestId → sessionId。`POST /api/approvals/:requestId` 上没有会话 id */
1403
1620
  private readonly requestOwner;
1404
1621
  /** 全局广播游标。0 是保留值(连接级帧),所以广播帧从 1 开始 */
@@ -1430,8 +1647,23 @@ declare class SessionHub {
1430
1647
  * @returns Hub 里本来有没有它
1431
1648
  */
1432
1649
  unregister(sessionId: string): boolean;
1433
- /** 当前活着的 SSE 连接数。给 `/api/config` 和用例看 */
1650
+ /**
1651
+ * 当前活着的 SSE 连接数。给 `/api/config` 和用例看。
1652
+ *
1653
+ * ⚠️ **只数 SSE,不数旁听者 —— 连声明了 `counts` 的那些也不数。**
1654
+ * 它答的是「有几条连接」,而 `/api/config` 把这个数原样报给界面;
1655
+ * 把宿主那条旁听混进去,用户会在界面上看到一个他找不到的「1 个连接」。
1656
+ * 「有没有人在场」是另一个问题,答它的是 {@link present}。
1657
+ */
1434
1658
  get connectionCount(): number;
1659
+ /**
1660
+ * 此刻**有没有人在场** —— `armGrace()` / `drainQueue()` / `reap()` 三处判的都是它。
1661
+ *
1662
+ * = 活着的 SSE 连接 + 声明了 `counts` 的旁听({@link ObserveOptions.counts})。
1663
+ * 单独一个私有取数口而不是三处各写一遍加法:那三处里有两处的错法是**静默的**
1664
+ * (挂起的审批永远不被放弃、没人看着的时候凭空开始跑一条排队的消息)。
1665
+ */
1666
+ private get present();
1435
1667
  /**
1436
1668
  * 接一条 SSE 连接。
1437
1669
  *
@@ -1448,9 +1680,13 @@ declare class SessionHub {
1448
1680
  *
1449
1681
  * 和 `subscribe()` 的差别不是「少发一帧」,是它整条不进那套连接语义:
1450
1682
  *
1451
- * - **不进 `sinks`**:不计入 `connectionCount`,不影响 idle-grace 的收摊
1452
- * (`armGrace()` → `reap()`),也不影响 `drainQueue()` 的「全断了就丢队列」。
1453
- * 那三处判的是「**人**还在不在」,而旁听者是宿主的代码,不是一双眼睛
1683
+ * - **不进 `sinks`**:不计入 `connectionCount`,而且**缺省**也不影响 idle-grace
1684
+ * 的收摊(`armGrace()` → `reap()`)和 `drainQueue()` 的「全断了就丢队列」——
1685
+ * 那三处判的是「**人**还在不在」,而一条旁听可能只是一盏状态灯。
1686
+ * ⚠️ 后三处从 2026-09-11 起**由宿主自己声明**:
1687
+ * `observe(sink, { counts: true })` 算一份在场,判据全文在
1688
+ * {@link ObserveOptions.counts} 上(一条即时通讯桥的另一头真的有个人,
1689
+ * 而审批卡就在他手机里)
1454
1690
  * - **不发 `connected`、不认 `Last-Event-ID`、不从环形缓冲补帧**:补帧机制存在的
1455
1691
  * 理由是网线断过一段,而同进程的函数调用不会丢帧。真要历史就走
1456
1692
  * `GET /api/sessions/:id/messages`,和浏览器同一条路
@@ -1461,7 +1697,7 @@ declare class SessionHub {
1461
1697
  *
1462
1698
  * @returns 取消旁听。**幂等** —— 多调几次不会误删后来注册的同一个函数
1463
1699
  */
1464
- observe(sink: FrameSink): () => void;
1700
+ observe(sink: FrameSink, opts?: ObserveOptions): () => void;
1465
1701
  /**
1466
1702
  * 发一条消息。会话空闲就立刻开一轮,否则**排队**(方案 30 §2.3 第 4 条)。
1467
1703
  *
@@ -1538,6 +1774,38 @@ declare class SessionHub {
1538
1774
  * 把这条通道接到 `append` 上),先回来看这一段。
1539
1775
  */
1540
1776
  notifyTasksChanged(sessionId: string): void;
1777
+ /**
1778
+ * 广播「会话列表变了」—— 多了一段或少了一段(2026-09-11)。
1779
+ *
1780
+ * 语义、不带载荷的理由、以及覆盖不到的那一档(改标题)全在 protocol 的
1781
+ * `sessions-changed` 上。这里只记**为什么产地在路由层而不是在 `register()` 里**。
1782
+ *
1783
+ * ## 🔴 为什么不写在 `register()` / `unregister()` 里
1784
+ *
1785
+ * 那是第一版,看着更收敛(`hub-register-sites.test.ts` 已经钉住了「会话只能从
1786
+ * 那三处变活」),**但它会把每一条流的帧序整体后移一格**:`register()` 的第三个
1787
+ * 调用点是起服务时那一次(`index.ts`),那一刻一个连接、一个旁听都还没有,
1788
+ * 而它照样要占一个全局 `seq`、读一格 `clock`、进一格环形缓冲。代价不是那一格
1789
+ * 内存,是 `fixtures/` 那批逐条断言 `seq` / `ts` 的信封 —— 每一段会话的第一帧
1790
+ * 从 `seq: 1` 变成 `seq: 2`,而那批 fixture 是 protocol / server / web 三边共用的
1791
+ * 契约(`scriptedClock` 还会直接抛「时钟被多读了一格」)。
1792
+ *
1793
+ * 为一帧发给**零个**收件人的帧去改一份三边共用的契约,换不回任何东西。
1794
+ *
1795
+ * ## 所以产地是两条端点,各自的判据
1796
+ *
1797
+ * | 产地 | 什么时候发 | 为什么 |
1798
+ * | --- | --- | --- |
1799
+ * | `POST /api/sessions`(`workspace/handlers.ts`) | **只在 `outcome.created`** | 复用那一支没多出一行来,发了等于让所有标签页白重取一次 |
1800
+ * | `DELETE /api/sessions/:id`(`api.ts`) | 删成了就发 | 不发的话**别的**标签页会一直挂着一行已经没了的会话,点进去才拿 404 |
1801
+ *
1802
+ * 另外两处刻意**不发**:
1803
+ *
1804
+ * - **起服务时那一次**(`index.ts`)—— 见上面那段,且那时没有任何收件人;
1805
+ * - **复活**(`sessions/revive.ts`)—— 它把库里还在、手里没有的那段接回 Hub,
1806
+ * 而那一行**本来就在列表里**(列表的另一半来自 DB)。列表没变,不该发。
1807
+ */
1808
+ notifySessionsChanged(sessionId: string): void;
1541
1809
  /**
1542
1810
  * 收摊:撤掉倒计时、放弃所有挂起的审批、中止所有在跑的回合。
1543
1811
  *
@@ -1597,7 +1865,9 @@ declare class SessionHub {
1597
1865
  * 一轮跑完,把排在后面的那条接上(方案 30 §2.3 第 4 条)。
1598
1866
  *
1599
1867
  * 三个前提缺一不可,缺了就是「浏览器早就关了,服务端自己接着聊」:
1600
- * 会话还在表里(没被删)、Hub 没收摊、且**还有连接活着**。
1868
+ * 会话还在表里(没被删)、Hub 没收摊、且**还有人在场**(`present`,
1869
+ * 判据在它和 {@link ObserveOptions.counts} 上 —— 2026-09-11 之前这里数的是
1870
+ * `sinks.size`,于是一条即时通讯桥的用户在手机上排的第二条消息会被静默丢掉)。
1601
1871
  */
1602
1872
  private drainQueue;
1603
1873
  private armGrace;
@@ -1818,7 +2088,16 @@ interface AttachedMentions {
1818
2088
  * ⚠️ **从 2026-08-26 起这条路上有两个消费方了**:`usedBytes` 不再从 0 起算,
1819
2089
  * 会话那一半排在文件那一半**之后**,拿到的预算是扣掉文件之后的余额。
1820
2090
  */
1821
- declare function attachMentions(ctx: ApiContext, sessionId: string, message: EpochUserContent): AttachedMentions;
2091
+ declare function attachMentions(ctx: ApiContext, sessionId: string, message: EpochUserContent,
2092
+ /**
2093
+ * 输入框里拖进来的那几个附件的**文件名**(2026-09-11)。
2094
+ *
2095
+ * 走的是 `POST .../attachments` 落盘之后回来的那个 `name`,判据在
2096
+ * [attachments.ts](./attachments.js) 文件头。它们和 `@文件` 产出**同一种部件**、
2097
+ * 进**同一本预算账**、收据也进**同一个 `files` 数组** —— 于是界面上
2098
+ * 「这条消息附了哪些文件」只有一张表。
2099
+ */
2100
+ uploads?: readonly string[]): AttachedMentions;
1822
2101
 
1823
2102
  /**
1824
2103
  * 一个会话自己那三样(模型 / 权限档 / plan 模式)在**服务端眼里**的样子
@@ -1904,6 +2183,19 @@ interface SessionModelView {
1904
2183
  hasImages?: boolean;
1905
2184
  usedTokens?: number;
1906
2185
  }): SetSelectionView;
2186
+ /**
2187
+ * {@link reset} 会把**这段会话**送回哪个模型 —— 开它那一刻配置里那个(2026-09-13)。
2188
+ *
2189
+ * ⚠️ **这一格进来是因为 `runtime.config.model` 从此答不了这个问题。** 设置页改
2190
+ * `model` 那条路现在会推进进程那份默认(`SettingApply` 的 `new-session` 一档),
2191
+ * 而正在跑的会话连同它们的 `reset()` 目标刻意不跟 —— 于是「进程默认」和
2192
+ * 「这段会话回得去的地方」分家了。
2193
+ *
2194
+ * 继续读 `runtime.config.model` 的表现:菜单里那一项写着一个名字、按下去回到
2195
+ * 另一个,两个都是真实存在的模型,屏幕上没有任何东西会红。判据全文在 core 的
2196
+ * `ProviderRouter.configSelection` 上。
2197
+ */
2198
+ configured(): string;
1907
2199
  /**
1908
2200
  * 只换**这一轮**(自定义斜杠命令 frontmatter 的 `model:`),`restore` 无条件还原。
1909
2201
  *
@@ -2122,10 +2414,15 @@ interface SessionLoopView {
2122
2414
  * (「要让用户换这个会话此刻的模型,得先在 server 加端点」)。
2123
2415
  *
2124
2416
  * ⚠️ **和 [settings.ts](./settings.ts) 那条写端点不是一回事,别合并。**
2125
- * 那一条改盘上的配置文件、要重启才生效、答的是「以后默认用哪个」;
2126
- * 这一条改的是这个进程里正在跑的那个选择、立刻生效、什么都不写盘。
2417
+ * 那一条改盘上的配置文件、答的是「以后默认用哪个」;这一条改的是这个进程里
2418
+ * 正在跑的那个选择、只管这一段会话、什么都不写盘。
2127
2419
  * 整张对照表在 `protocol/src/wire-model.ts` 的文件头。
2128
2420
  *
2421
+ * 2026-09-13 那一条从「要重启才生效」变成了「此后新开的会话用它」
2422
+ * (`SettingApply` 的 `new-session` 一档)。**分工一个字没变,但这个文件受了一处
2423
+ * 影响**:`runtime.config.model` 从此会往前走,而正在跑的会话不跟 —— 于是
2424
+ * 下面 `getModel` 的 `configured` 改成问会话自己那份 router,判据写在那一行上。
2425
+ *
2129
2426
  * ## 结构镜像,不是 import
2130
2427
  *
2131
2428
  * `@epoch-agent/server` **不许 import `@epoch-agent/core`**(`check-layers.mjs`
@@ -2264,6 +2561,8 @@ interface ModelControlView extends ModelTurnsView {
2264
2561
  hasImages?: boolean;
2265
2562
  usedTokens?: number;
2266
2563
  }): SetSelectionView;
2564
+ /** `reset` 会把这段会话送回哪儿。判据见 `sessions/scope.ts` 的同名那一格 */
2565
+ configured(): string;
2267
2566
  }
2268
2567
  /**
2269
2568
  * 这段会话里出现过图片吗 —— 只借**读历史**这一件事。
@@ -2284,16 +2583,24 @@ type ModelSessionFacts = SessionHistoryFacts;
2284
2583
  */
2285
2584
  interface RuntimeModel {
2286
2585
  model: ModelControlView | null;
2287
- /** 配置里那个模型(`reset` 的目标)。`EpochConfig.model` 结构上满足它 */
2586
+ /**
2587
+ * **这个进程此刻的默认模型** —— 也就是现在新建一段会话会从哪个模型起步。
2588
+ * `EpochConfig.model` 结构上满足它。
2589
+ *
2590
+ * ⚠️ **它不是「`reset` 会把你送回哪儿」**(2026-09-13 起)。那个问题由
2591
+ * `ModelControlView.configured()` 按会话答:设置页改 `model` 会推进这一格,
2592
+ * 而已经在跑的会话连同它们的 reset 目标刻意不跟。两者今天在一台没人改过设置的
2593
+ * 机器上恒等 —— 而「今天恰好相等」正是这个仓库反复在治的那种依赖。
2594
+ */
2288
2595
  config: {
2289
2596
  model: string;
2290
2597
  };
2291
2598
  }
2292
2599
 
2293
2600
  /**
2294
- * 插件那一页的十三条端点(方案 59 §六 E2,2026-08-21;本地源与市场管理 2026-09-04
2601
+ * 插件那一页的十四条端点(方案 59 §六 E2,2026-08-21;本地源与市场管理 2026-09-04
2295
2602
  * 补,判据在 protocol 的 `wire-plugin.ts` 文件头第一节那次**翻案**;上传 2026-09-08
2296
- * 补,判据在那份文件头第一节之二;刷新 2026-09-10 补):
2603
+ * 补,判据在那份文件头第一节之二;刷新 2026-09-10 补;落区 2026-09-11 补):
2297
2604
  *
2298
2605
  * ```
2299
2606
  * GET /api/plugins 装着的 + 市场里有什么 + 已加的市场 + pendingRestart + uploadEnabled
@@ -2309,6 +2616,7 @@ interface RuntimeModel {
2309
2616
  * POST /api/plugins/upload/preview {path} → 会打包哪些文件,一个字节都不发
2310
2617
  * POST /api/plugins/upload {path, token} → 打包 + 交给宿主
2311
2618
  * POST /api/plugins/refresh (不读正文) → 让宿主重拉目录 + 回一份新列表
2619
+ * POST /api/plugins/stage {path} 或字节 → 落地 + 解包 + 验清单 → 一条目录路径
2312
2620
  * ```
2313
2621
  *
2314
2622
  * ## ⚠️ 这一层**一条策略判断都没有**
@@ -2322,10 +2630,10 @@ interface RuntimeModel {
2322
2630
  * 界面上是一条画成可点、点了却被拒的行(判据同 `PluginSearchHit.installable`
2323
2631
  * 为什么不从结果里剔掉远程那条)。
2324
2632
  *
2325
- * ## ⚠️ 那一道闸:**十一条 POST 在回环之外一律 403**,这是这个文件的正题
2633
+ * ## ⚠️ 那一道闸:**十三条 POST 在回环之外一律 403**,这是这个文件的正题
2326
2634
  *
2327
2635
  * 判据全文在 protocol 的 [wire-plugin.ts](../../protocol/src/wire-plugin.ts) 文件头
2328
- * 第二节,这里记它对这一层的**六**条直接后果:
2636
+ * 第二节,这里记它对这一层的**七**条直接后果:
2329
2637
  *
2330
2638
  * 1. **`ctx.lanExposed` 为真时 403 `plugin-write-lan-exposed`,一个字节都不写。**
2331
2639
  * 装一包插件会落下 `hooks.json` 和 `mcp.json`,那两样**起子进程** ——
@@ -2348,6 +2656,11 @@ interface RuntimeModel {
2348
2656
  * 目录、把内容**发到外面去**。放行的话,一个 LAN 上的人能指着 `~/.ssh` 说
2349
2657
  * 「上传它」。⚠️ **预览那一条同样挡**,虽然它一个字节都不发 ——
2350
2658
  * 它回的那份**文件清单本身就是情报**(拿它能探这台机器上有什么)。
2659
+ * 7. **落区那一条(`stage`)最容易被当成例外,而它恰恰不能是**(2026-09-11):
2660
+ * 「字节是浏览器那台机器上的,又不读服务端的目录」—— 但它**落了盘**,
2661
+ * 而落下来的东西下一步就要被装,装进去的 `hooks.json` 起子进程。风险在落地
2662
+ * 之后那一步,不在字节从哪儿来。⚠️ 它还有一条独占的理由,就是上面第 4 条:
2663
+ * 它的 body 上限是 50 MiB,漏一格就是让 LAN 上的任何人一发一发地灌满内存。
2351
2664
  *
2352
2665
  * ## ⚠️ 这一片可以整个不存在 —— 503 `no-plugin-control`
2353
2666
  *
@@ -2370,7 +2683,7 @@ interface RuntimeModel {
2370
2683
  *
2371
2684
  * 同 [role-add.ts](./role-add.ts) 从 `capability.ts` 里搬出来那次,**刀口按题目下
2372
2685
  * 不按行数**:那个文件答的是「能力页那三栏取什么数」,这一份答的是「怎么往这台
2373
- * 机器上装一包扩展物」。而且这一份带着一整套结构镜像 + 一道闸门 + 七档拒绝的映射。
2686
+ * 机器上装一包扩展物」。而且这一份带着一整套结构镜像 + 一道闸门 + 十档拒绝的映射。
2374
2687
  *
2375
2688
  * ## 进程级,所以 URL 上没有 `:id`
2376
2689
  *
@@ -2571,6 +2884,28 @@ type UploadOutcomeView = {
2571
2884
  version: string;
2572
2885
  bytes: number;
2573
2886
  } | RefusalView;
2887
+ /**
2888
+ * `PluginStageInput`(runtime)的镜像 —— 落区那一条收的两种入口。
2889
+ *
2890
+ * ⚠️ **一个可辨识联合,不是「一条 path + 一份可选的 bytes」**:后者拼得出
2891
+ * 「两个都给了」,而这一层就得回答「以哪个为准」—— 一个往盘上写东西的动作
2892
+ * 不该有一个含糊的输入。判据全文在 runtime 的 `PluginStageInput` 上。
2893
+ */
2894
+ type StageInputView = {
2895
+ kind: 'path';
2896
+ path: string;
2897
+ } | {
2898
+ kind: 'bytes';
2899
+ name: string;
2900
+ data: Uint8Array;
2901
+ };
2902
+ /** 落区那一条的结局。成功那一档回的是**一条目录路径**,判据见 `PluginControlView.stage` */
2903
+ type StageOutcomeView = {
2904
+ ok: true;
2905
+ path: string;
2906
+ name: string;
2907
+ version: string;
2908
+ } | RefusalView;
2574
2909
  /**
2575
2910
  * `PluginControl`(runtime)在服务端眼里的样子 —— **只有那十四个动词加两格布尔**。
2576
2911
  *
@@ -2641,16 +2976,23 @@ interface PluginControlView {
2641
2976
  * 口径逐字同 {@link uploadEnabled}。
2642
2977
  */
2643
2978
  readonly refreshEnabled: boolean;
2979
+ /**
2980
+ * 一个压缩包 → 一条已经验过的插件目录路径(2026-09-11,落区那一条)。
2981
+ *
2982
+ * ⚠️ **它一个插件都不装**,所以它回的**不是** {@link InstallOutcomeView} ——
2983
+ * 拿那个形状套会让下一个人以为这一下已经装完了(那个类型的成功档里有 `record`)。
2984
+ */
2985
+ stage(input: StageInputView, lang?: Lang): Promise<StageOutcomeView>;
2644
2986
  }
2987
+ declare function toWirePluginEntry(entry: PluginEntryView,
2645
2988
  /**
2646
- * 装着的一条 → 网线上那一行。
2989
+ * {@link displayNamesOf} 那一份。
2647
2990
  *
2648
- * `marketplace` / `counts` 缺席时下发 **`null` 而不是省掉这个键**,判据同
2649
- * `WireSkillImportEntry.conflict`:两格在 wire 上写成必填,于是「服务端忘了转
2650
- * 这一格」会当场编译不过。而 `counts` 漏掉的表现是一个生效中的插件在界面上
2651
- * 「什么都不带」。
2991
+ * ⚠️ **必传**(哪怕是一张空表),不给默认值 —— 判据同这个文件里
2992
+ * `toWirePluginHit(hit, installed)` 那一格:加一个调用点却忘了传的表现是
2993
+ * 「这张卡上的名字变回英文」,而一个带默认值的参数让那件事**编译期不红**。
2652
2994
  */
2653
- declare function toWirePluginEntry(entry: PluginEntryView): WirePluginEntry;
2995
+ displayNames: ReadonlyMap<string, string>): WirePluginEntry;
2654
2996
  /** `PluginUploadForm`(runtime)的镜像 —— 上传那一屏上用户填的七格 */
2655
2997
  type UploadFormView = {
2656
2998
  visibility?: WirePluginVisibility;
@@ -2724,6 +3066,7 @@ interface ProviderOptionView {
2724
3066
  envVar: string | null;
2725
3067
  hasKey: boolean;
2726
3068
  keyHint: string | null;
3069
+ current: boolean;
2727
3070
  }
2728
3071
  /** 一次配 key 的结果。runtime 的 `ProviderKeyWritten` 满足它 */
2729
3072
  interface ProviderKeyWrittenView {
@@ -3009,7 +3352,17 @@ interface BoundWorkspace {
3009
3352
  * `unknown` + 未信任 是「还没问过你」,`untrusted` + 未信任 是「你自己拒过」。
3010
3353
  */
3011
3354
  interface TrustView {
3355
+ /** 读类:这个目录的指令文件 / 技能 / 角色 / 命令加载了吗 */
3012
3356
  trusted: boolean;
3357
+ /**
3358
+ * 执行类:它的 hooks / policies / 项目设置生效了吗(2026-09-11)。
3359
+ *
3360
+ * ⚠️ **`trusted: true` + `exec: false` 是最常见的一档**(拆档之前写下的记录
3361
+ * 全在这儿),所以凡是画「已信任」的地方都得把这一格一起画出来 ——
3362
+ * 否则用户看到的是「已信任」配上「我的 hook 没跑」这一对无法自洽的事实。
3363
+ * 档位判据在 [protocol 的 `TrustTier`](../../../protocol/src/trust.ts)。
3364
+ */
3365
+ exec: boolean;
3013
3366
  /** 存储里的原始判定,**未经**降级规则处理 */
3014
3367
  level: TrustLevel;
3015
3368
  }
@@ -3069,6 +3422,14 @@ interface WorkspaceView {
3069
3422
  root: string;
3070
3423
  name: string;
3071
3424
  }>;
3425
+ /**
3426
+ * 从 {@link known} 那份清单里划掉一行(2026-09-11)。
3427
+ *
3428
+ * ⚠️ **它不碰信任记录** —— 划掉的只是「最近在这儿干过活」。那个目录的信任判定
3429
+ * 一个字都不动,下次再绑回去还是原来那一档。判据全文在 runtime 的
3430
+ * `WorkspaceControl.forget` 和 [forget.ts](./forget.js) 的文件头上。
3431
+ */
3432
+ forget(root: string): void;
3072
3433
  /**
3073
3434
  * **任意**目录的信任判定,不要求它被哪个会话绑着(方案 42 PR-3)。
3074
3435
  *
@@ -3077,6 +3438,13 @@ interface WorkspaceView {
3077
3438
  * 绑着的那几个,于是绝大多数行会显示成「未知」,而真相是它们各有各的记录。
3078
3439
  */
3079
3440
  trustOf(dir: string): TrustView;
3441
+ /**
3442
+ * 这个目录里有什么被闸门挡着(方案 61 ②)。**未信任的目录也数得出来**。
3443
+ *
3444
+ * ⚠️ 它比 {@link trustOf} 贵(要 readdir 几个目录),所以「已知工作区」那份
3445
+ * 清单**不要**逐行调它 —— 判据在 runtime 的 `WorkspaceControl.inventoryOf` 上。
3446
+ */
3447
+ inventoryOf(dir: string): WireProjectInventory;
3080
3448
  }
3081
3449
 
3082
3450
  /**
@@ -3439,7 +3807,12 @@ interface RuntimeSecurity {
3439
3807
  * @param bound 这个会话绑的工作区(`workspaces.of(sessionId)`)。没绑就是 null,
3440
3808
  * 那时 `workspace` 是 null 而**不是**一个空壳 —— 「不使用工作区」是真的一档
3441
3809
  */
3442
- declare function collectSecurity(runtime: RuntimeSecurity, workspaces: Pick<WorkspaceView, 'known' | 'trustOf'>, bound: BoundWorkspace | null, session: Pick<SessionPermissionsView, 'level'> | null): WireSecurityResponse;
3810
+ declare function collectSecurity(runtime: RuntimeSecurity, workspaces: Pick<WorkspaceView, 'known' | 'trustOf'>, bound: BoundWorkspace | null, session: Pick<SessionPermissionsView, 'level'> | null,
3811
+ /** 清单 + 门铃(方案 61)。由调用方用 `projectInventoryOf()` 算好递进来 */
3812
+ project: {
3813
+ inventory: WireProjectInventory;
3814
+ trustRequest: boolean;
3815
+ }): WireSecurityResponse;
3443
3816
 
3444
3817
  /**
3445
3818
  * 检查点与回退的取数、投影与三条端点(方案 27 的 web 那一半)。
@@ -3859,7 +4232,14 @@ interface CatalogRow {
3859
4232
  endedAt: number | null;
3860
4233
  messageCount: number;
3861
4234
  model: string;
3862
- costUsd: number;
4235
+ /**
4236
+ * 累计估算花费。**`undefined` = 这段会话一轮都没算出过钱**,不是 0
4237
+ * (会话库 v9,2026-09-13;判据全文在 core 的 `SessionMeta.estimatedCostUsd`)。
4238
+ *
4239
+ * 底下 `toWireSummary()` 那句 `?? null` 因此有了真实的两档 —— 在 v9 之前
4240
+ * 它恒为 0,那一句 `?? null` 一次都没走到过。
4241
+ */
4242
+ costUsd?: number;
3863
4243
  /**
3864
4244
  * 用户当初对「这段会话在哪儿干活」做的那次决定(会话库 v7,2026-08-19)。
3865
4245
  * `undefined` = 库里没记过(v7 之前的老行),网线上那是
@@ -4168,6 +4548,24 @@ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSe
4168
4548
  * 服务日志状态码诊断全部正常。那正是这一节要修的病本身。
4169
4549
  */
4170
4550
  artifactsRoot: string;
4551
+ /**
4552
+ * 用户附件的暂存面(2026-09-11)。`EpochRuntime.attachments` 结构上满足它。
4553
+ *
4554
+ * ## ⚠️ 为什么这一格**不是**一个和 `artifactsRoot` 并排的字符串
4555
+ *
4556
+ * 上面那个能是字符串,是因为 artifact **写和读都在引擎侧**,这一层只拿去显示。
4557
+ * 附件反过来:**写在这一层**(上传落盘),**读在引擎侧**(plugin-file 放开
4558
+ * `attachmentsDir(sessionId, homeDir)` 给模型)。
4559
+ *
4560
+ * 于是这一层还得知道 `<会话 id>` 那一层怎么拼 —— 而那是 infra `bySession()`
4561
+ * 的规则(只留 `[A-Za-z0-9_-]`),**而 `server` 不许 import `infra`**。
4562
+ * 照着抄一份的下场是:sessionId 里出现一个 `:` 的那天,这边写进 `sess:1/`、
4563
+ * 引擎去 `sess_1/` 找 —— 表现是「传上去了,模型说看不到这个文件」,
4564
+ * 而 HTTP 200、盘上真有那个文件、日志一个字不报。
4565
+ *
4566
+ * 所以引擎交出来的是**算好的目录**,不是拼它的材料。
4567
+ */
4568
+ attachments: AttachmentControlView;
4171
4569
  usageScope: 'session' | 'run';
4172
4570
  /**
4173
4571
  * 一段会话到此刻的**累计**用量(2026-09-04)。`EpochRuntime.sessionUsage`
@@ -4491,7 +4889,54 @@ interface ApiContext {
4491
4889
  * 自己也要再查一遍**,别指望浏览器只在按钮亮着的时候才发那一下。
4492
4890
  */
4493
4891
  sameMachine: boolean;
4892
+ /**
4893
+ * 宿主接了「请求授信」那条回调吗,以及接的是哪一个(方案 61,2026-09-11)。
4894
+ *
4895
+ * ## ⚠️ 这不是「信任的写入口上了网线」,读完这一段再动它
4896
+ *
4897
+ * 信任的写入口**刻意不上网线**(方案 42 PR-3):把它搬到 HTTP 上等于让浏览器
4898
+ * 决定「哪个目录里的文件能当指令读」。**这一条一个字都没翻** —— 这个回调
4899
+ * 收不到 `level`,也收不到 `scope`,浏览器能做的只有**按门铃**。
4900
+ * 决定仍然发生在宿主的对话框里,落盘仍然只有 `runtime.trust.record()` 一条路。
4901
+ *
4902
+ * 形状照抄 `POST /api/workspaces/pick`(`workspace/native-pick.ts`):
4903
+ * 浏览器发起一个**它自己答不了**的请求,答案来自一个人在别处按的那一下。
4904
+ *
4905
+ * `undefined` = 宿主没接(`epoch web` 起的那一档就是)。那时端点回 404,
4906
+ * 而界面上照旧画「复制 `epoch trust add`」那一块 —— **不画灰按钮**(决定 20 ①)。
4907
+ */
4908
+ trustRequest?: TrustRequestFn;
4494
4909
  }
4910
+ /**
4911
+ * 宿主那条「去问用户要不要信任这个目录」的回调。
4912
+ *
4913
+ * ## 三件宿主必须做、而这一层**验证不了**的事
4914
+ *
4915
+ * 1. **真的问用户。** `runtime.trust.record()` 不带闸门,它就是那个决定本身
4916
+ * (判据在 runtime 的 `EpochRuntime.trust` 上);
4917
+ * 2. **两档分开问。** 读类和执行类不能一个勾选框全包 —— 那正是方案 61 ① 要修的
4918
+ * 那个缺陷(一句只承诺了「项目指令会加载」的问话,顺带把 hook 也授了);
4919
+ * 3. **档位由宿主定。** 这个回调的入参里**没有** `level` / `scope`,那是刻意的:
4920
+ * 有了它们,浏览器就从「按门铃」变成了「下命令」。
4921
+ *
4922
+ * ## 它 resolve 的时候什么都没发生过,也可能发生了
4923
+ *
4924
+ * 返回值是 `void`:这一层**不报告用户选了什么**。要知道结果就重新取一次状态
4925
+ * (那份载荷里 `trusted` / `execTrusted` 是真源)。
4926
+ * 让它回一个 `granted: boolean` 的话,那个值和盘上的记录会各自演化 ——
4927
+ * 而两者分叉时界面会信错的那一个。
4928
+ */
4929
+ type TrustRequestFn = (req: {
4930
+ /** 用户想解锁的那个目录(服务端绝对路径,已归一到项目根) */
4931
+ root: string;
4932
+ /**
4933
+ * 浏览器**想**解锁哪一档 —— 这是个**提示,不是命令**。
4934
+ *
4935
+ * 它只决定宿主的对话框默认摆在哪一档上(用户从技能页点进来的那一下多半只想
4936
+ * 要读类)。宿主完全可以无视它,也完全可以问完之后授一个不一样的档。
4937
+ */
4938
+ want: 'read' | 'exec';
4939
+ }) => void | Promise<void>;
4495
4940
 
4496
4941
  /**
4497
4942
  * 鉴权中间件 —— cookie + Origin/Host 校验。
@@ -5158,9 +5603,30 @@ interface EpochWebServer {
5158
5603
  *
5159
5604
  * 「四种状态怎么映射到宿主窗口上的一枚灯」是宿主的产品决定,我们不替它做。
5160
5605
  *
5606
+ * ## 🔴 2026-09-11:`{ counts: true }` —— 「这条旁听的另一头**有一个人**」
5607
+ *
5608
+ * 上面那句「明确不算人」对一枚状态灯是对的,对一条**即时通讯桥**是错的:
5609
+ * 那一头是用户的手机,而审批卡就在他手里。缺省那一档下,两件事会在他等着的
5610
+ * 时候发生 —— 他关掉电脑上那个窗口 30 秒后**那一轮被中止**,以及他连发的
5611
+ * 第二条消息**被静默丢掉**(一个飞书宿主 2026-09-11 实测到的正是这两条)。
5612
+ *
5613
+ * 所以这件事由宿主自己声明:
5614
+ *
5615
+ * ```ts
5616
+ * // 一枚状态灯:不给(缺省)。灯不会答审批,它亮着不代表有人看着
5617
+ * server.observe(paintLamp);
5618
+ * // 一条飞书 / Slack 桥:给。人在手机上,他答得了审批
5619
+ * server.observe(onFrame, { counts: true });
5620
+ * ```
5621
+ *
5622
+ * 给了之后,这条旁听在**上面那两条判定**上和一个开着的标签页等价
5623
+ * (`connectionCount` 除外 —— 那一格答的是「有几条连接」)。
5624
+ * **给错的后果不对称**,判据和那张「什么时候该给」的表全文在
5625
+ * [hub.ts](./hub.ts) 的 `ObserveOptions.counts` 上。
5626
+ *
5161
5627
  * @returns 取消旁听。**幂等**。`close()` 之后所有旁听自动失效
5162
5628
  */
5163
- observe(sink: (frame: WireEnvelope) => void): () => void;
5629
+ observe(sink: (frame: WireEnvelope) => void, opts?: ObserveOptions): () => void;
5164
5630
  /**
5165
5631
  * 当前所有会话在 Hub 侧的状态快照 —— 等价于 `GET /api/sessions` 的实时那一半,
5166
5632
  * 不过 HTTP。
@@ -5241,6 +5707,45 @@ interface CreateWebServerOptions {
5241
5707
  heartbeatMs?: number;
5242
5708
  /** 时钟注入,缺省 `Date.now`。用例靠它对齐 fixture 的 `ts` */
5243
5709
  clock?: () => number;
5710
+ /**
5711
+ * 「用户在界面上请求授信」时去问谁(方案 61,2026-09-11)。
5712
+ *
5713
+ * ```ts
5714
+ * await createWebServer({
5715
+ * runtime,
5716
+ * version,
5717
+ * onTrustRequest: async ({ root, want }) => {
5718
+ * const inv = runtime.workspaces.inventoryOf(root); // 框里要摆的事实
5719
+ * const answer = await myApp.showTrustDialog({ root, inv, want });
5720
+ * if (answer.kind === 'trust') {
5721
+ * runtime.trust?.record(root, 'trusted', answer.scope, { exec: answer.exec });
5722
+ * }
5723
+ * },
5724
+ * });
5725
+ * ```
5726
+ *
5727
+ * ## ⚠️ 不给它,界面就退回「复制一条命令」那一块
5728
+ *
5729
+ * 而那块东西对**嵌入宿主的用户是死路**:他没装 CLI。这一格存在的全部理由就是
5730
+ * 那条死路(判据全文在 runtime 的 `EpochRuntime.trust` 上:
5731
+ * 「界面把这件事说得很清楚,然后递给用户一条 `epoch trust add <root>` ——
5732
+ * 而走这条路的用户压根没装 CLI」)。
5733
+ *
5734
+ * 反过来,`epoch web` 起的那一档**就该不给**:敲得出 `epoch web` 的人手边
5735
+ * 有 CLI,那条命令对他是对的;而多一条原生框就多继承一份 `native-pick.ts`
5736
+ * 文件头记的那两个盲区。
5737
+ *
5738
+ * ## 宿主的三条义务(这一层验证不了,所以写在这儿)
5739
+ *
5740
+ * 1. **真的问用户。** `record()` 不带闸门,它就是那个决定本身;
5741
+ * 2. **两档分开问**(读类 / 执行类)。一个勾选框全包就是方案 61 ① 要修的那个
5742
+ * 缺陷本身;
5743
+ * 3. **别信 `want`。** 它是浏览器给的**提示**(对话框默认落在哪一档),
5744
+ * 不是命令 —— 拿它当档位用,等于把决定权还给了浏览器。
5745
+ *
5746
+ * 完整类型和逐条判据在 [context.ts](./context.js) 的 `TrustRequestFn`。
5747
+ */
5748
+ onTrustRequest?: TrustRequestFn;
5244
5749
  }
5245
5750
  type CreateWebServerResult = {
5246
5751
  ok: true;
@@ -5258,4 +5763,4 @@ type CreateWebServerResult = {
5258
5763
  */
5259
5764
  declare function createWebServer(opts: CreateWebServerOptions): Promise<CreateWebServerResult>;
5260
5765
 
5261
- export { type ApiContext, type AttachedMentions, type AuthGuardOptions, type BindDecision, type BindRequest, type BoundWorkspace, type CatalogDeletion, type CatalogHit, type CatalogRow, type CheckpointSummaryView, type CommandsView, type CreateSessionResult, type CreateWebServerOptions, type CreateWebServerResult, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, type EpochWebServer, type ExpansionOutcome, type FrameSink, type HubSession, type HubSessionState, type LiveSessionView, MAX_CANDIDATES, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, type ModelTurnsView, type NativePickerFacts, type PluginControlView, type QueuedMessage, type RewindFilePlanView, type RewindOutcomeView, type RewindPreviewView, type RewindResultView, type RuntimeCapabilities, type RuntimeCheckpoints, type RuntimeCommands, type RuntimeSecurity, type RuntimeSettings, type ScheduleCapabilityView, type ScheduleControlView, type ScheduleCreateView, type ScheduleFireView, type ScheduleRegisterView, type ScheduleSaveView, type ScheduleUpdateView, type SelectionView, type SessionCatalog, type SessionContextFacts, type SessionFactoryView, type SessionHistoryFacts, SessionHub, type SessionHubOptions, type SessionLoopView, type SessionModelView, type SessionPermissionsView, type SessionPlanView, type SessionReferencesView, type SessionRoleView, type SetLevelView, type SetSelectionView, type StartResult, TASK_TAIL_BYTES, type TaskRegistryView, type TrustView, type TurnDecorator, UI_LANG_PARAM, UI_THEME_PARAM, type UiLangPref, type UiPreset, type UiThemePref, type WebRuntimeView, type WorkspaceBindFailure, type WorkspaceBindOutcome, type WorkspaceCheckpoints, type WorkspaceDiffOptions, type WorkspaceView, attachMentions, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, isSameMachine, listFileCandidates, listReferenceCandidates, readRewindInput, resolveArtifactPath, resolveNativeDirPicker, toWireCheckpoint, toWireCommand, toWirePluginEntry, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };
5766
+ export { type ApiContext, type AttachedMentions, type AuthGuardOptions, type BindDecision, type BindRequest, type BoundWorkspace, type CatalogDeletion, type CatalogHit, type CatalogRow, type CheckpointSummaryView, type CommandsView, type CreateSessionResult, type CreateWebServerOptions, type CreateWebServerResult, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, type EpochWebServer, type ExpansionOutcome, type FrameSink, type HubSession, type HubSessionState, type LiveSessionView, MAX_CANDIDATES, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, type ModelTurnsView, type NativePickerFacts, type ObserveOptions, type PluginControlView, type QueuedMessage, type RewindFilePlanView, type RewindOutcomeView, type RewindPreviewView, type RewindResultView, type RuntimeCapabilities, type RuntimeCheckpoints, type RuntimeCommands, type RuntimeSecurity, type RuntimeSettings, type ScheduleCapabilityView, type ScheduleControlView, type ScheduleCreateView, type ScheduleFireView, type ScheduleRegisterView, type ScheduleSaveView, type ScheduleUpdateView, type SelectionView, type SessionCatalog, type SessionContextFacts, type SessionFactoryView, type SessionHistoryFacts, SessionHub, type SessionHubOptions, type SessionLoopView, type SessionModelView, type SessionPermissionsView, type SessionPlanView, type SessionReferencesView, type SessionRoleView, type SetLevelView, type SetSelectionView, type StartResult, TASK_TAIL_BYTES, type TaskRegistryView, type TrustRequestFn, type TrustView, type TurnDecorator, UI_LANG_PARAM, UI_THEME_PARAM, type UiLangPref, type UiPreset, type UiThemePref, type WebRuntimeView, type WorkspaceBindFailure, type WorkspaceBindOutcome, type WorkspaceCheckpoints, type WorkspaceDiffOptions, type WorkspaceView, attachMentions, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, isSameMachine, listFileCandidates, listReferenceCandidates, readRewindInput, resolveArtifactPath, resolveNativeDirPicker, toWireCheckpoint, toWireCommand, toWirePluginEntry, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };