@epoch-agent/server 0.18.0 → 0.20.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/README.md CHANGED
@@ -89,6 +89,9 @@ tarball 里真的有那些字节。
89
89
  | `GET /api/health` | **不鉴权**,只回 `{ok, version}` |
90
90
  | `GET /api/config` | 模型 / 权限级别 / 工作区 / 品牌 / 启动诊断 / 绑没绑在回环之外(`?lang=` 见下) |
91
91
  | `GET /api/events` | SSE,负载是 `WireEnvelope` |
92
+ | `POST /api/host/compose` | 宿主投递:建/取会话 + 记一笔「该往输入框里填什么」+ 广播一帧(见下) |
93
+ | `GET /api/host/compose` | 此刻还没被认领的那一笔(页面**每次连上流**打一发,见下) |
94
+ | `POST /api/host/compose/:id/take` | 认领那一发自动发送(**一次性**,回 `{ok:false, reason}`,见下) |
92
95
  | `GET /api/sessions[?q=…]` | 会话列表;带 `q` 走 FTS5 检索 |
93
96
  | `POST /api/sessions` | 幂等确保会话,可带 `{workspace?}`(见下) |
94
97
  | `GET /api/sessions/:id` | **这一段**自己那一行;**不受上一条那 50 行封顶**(见下) |
@@ -175,6 +178,83 @@ tarball 里真的有那些字节。
175
178
  清单以外还要知道的六件事(鉴权两段式、`Origin` 校验、`seq` 与 `Last-Event-ID` 续传、
176
179
  断连不等于人走了)写在 [docs/EMBEDDING.md §10](../../docs/EMBEDDING.md)。
177
180
 
181
+ ### `/api/host/*` —— 宿主把一句话送进输入框(2026-09-28)
182
+
183
+ ```
184
+ POST /api/host/compose 建/取一段会话,记一笔「该往输入框里填什么」,广播一帧
185
+ GET /api/host/compose 此刻还没被认领的那一笔(没有就是 null)
186
+ POST /api/host/compose/:id/take 认领那一发自动发送(一次性)
187
+ ```
188
+
189
+ **这一组治的是「宿主够不着那张页」**:宿主能用 REST 建会话、发消息,但改不动这张
190
+ 页的状态 —— 草稿只在内存里(`web/composer/draft.ts` 明写不落盘),而入站那条路
191
+ 刻意关着(「我们不监听 `message`」,判据在 `embedding` §9 末尾)。于是「在我自己的
192
+ 界面上点一个动作 → epoch 窗口里开一段会话、输入框里已经写好那句话」说不出口。
193
+ 宿主侧的完整接法在 [EMBEDDING.md §10.7](../../docs/EMBEDDING.md)。
194
+
195
+ #### 载荷与回执
196
+
197
+ ```jsonc
198
+ // POST /api/host/compose —— 目标会话二选一(都不给 = 引导会话那条老路)
199
+ {
200
+ "sessionId": "ses_…", // 已经存在的那一段
201
+ "workspace": "/abs/path", "create": true, "role": "…", // 或者照 POST /api/sessions 那一套
202
+ "text": "把这段字填进输入框", // 可选;不给 = 只把界面切过去
203
+ "autoSend": false // 可选;true 而没给 text → 400
204
+ }
205
+ → 200 { sessionId, created, composeId, expiresAt }
206
+ ```
207
+
208
+ 建会话那一支走的是 `POST /api/sessions` **同一个函数**(`ensureSession`),所以
209
+ `bad-workspace` / `unknown-role` / 503 `no-provider` 那几档一个字都不缺。
210
+ `sessionId` 不认识 → 404 `unknown-session`(判据逐字同 `PUT /api/last-session`:
211
+ **Hub 里没有不算不存在** —— 上个进程留下的会话正是「往已存在的那段里填」的主场景)。
212
+
213
+ #### 三条投递路径,客户端按 `id` 去重
214
+
215
+ | 页面此刻在哪 | 走哪条 |
216
+ | -------------------------------------- | ------------------------------------------------------------------------ |
217
+ | 已经开着、流连着 | SSE 帧 `host-compose`(信封上那个 `sessionId` 是**哪一段**,不是收件人) |
218
+ | 刚启动,`/config` 已经回来、流还没接上 | 连上那一刻自己打的 `GET /api/host/compose` |
219
+ | 比这一笔还晚开 | `GET /api/config` 的 `hostCompose`(首屏一次落到位,不闪) |
220
+
221
+ 中间那一格是**必须有的**:前两条各管一头,而页面启动的那几十毫秒**两条都不在**,
222
+ 那一笔谁也接不住 —— 宿主拿到 200,屏幕上什么都没有。治法不是给帧再加特例,而是
223
+ 这个仓库既有的规矩(`hub.subscribe()` 那个 `Last-Event-ID` 参数的注释):
224
+ **新连接自己拉一次状态,流上只走增量**。所以 `GET` 这一条是**读**、不消费,
225
+ 几个标签页各拉一次都拿得到。
226
+
227
+ #### 一笔待办:进程内存、按会话一格、十分钟
228
+
229
+ 它是**一笔此刻的投递**,不是配置 —— 进程重启就没了,也不该有:一个三天前的
230
+ 「帮我填这句话」在启动时自己冒出来,比丢掉它坏得多。同一段会话先后记两笔时**新的
231
+ 盖掉旧的**(认领过的那些不盖:它们是回执,不是待办)。
232
+
233
+ #### ⚠️ 兑现它的是**页面**,不是这里
234
+
235
+ 服务端只记、只发帧;把字填进框、以及按下发送,都是页面的活(乐观气泡、排队回执、
236
+ 输入框那两道闸全在 `composer` 那一侧)。三条代价如实写在这儿:
237
+
238
+ - **页面没开着、或者用户一直不开,自动发送不会发生** —— 字在待办里等着,过期为止。
239
+ 宿主想确证那一发到底出去没有,认事件流上的 `run-start`(它本来就有这条路);
240
+ - **认领不是「一定发得出去」的保证**,只是「不会重复发」:认领之后 `submit()` 照旧
241
+ 可能因为只读 / 输入框还锁着而早退,那时字留在框里,由人来按;
242
+ - **不额外加回环闸**:它给的能力不比 `POST /api/sessions/:id/messages` 多一个字
243
+ (那条端点也没有那道闸),而那道闸守的是「这一发会往用户盘上写东西 / 起一个
244
+ 子进程」那一类(`POST /api/mcp` / `POST /api/roles` / 插件那五条)。
245
+
246
+ #### 为什么 `take` 是一次性的
247
+
248
+ 多开两个标签页时两页都会切、都会填,但只有认领到的那一页会按下发送 —— 两个窗口
249
+ 各发一次会让模型白跑一轮,而那是钱和一段看不懂的记录。它**还挡住了第二条重放
250
+ 路径**:这一帧和其它 Hub 事件一样进环形缓冲,断线重连带 `Last-Event-ID` 补帧时会
251
+ **再投一次**,而那种重复在屏幕上是「用户手快发了两次」的形状,查不出来。
252
+
253
+ 四档答案(`ok` / `taken` / `expired` / `unknown`)**互斥且都说得出话**,而认领过的
254
+ 那一笔**留着不删、只标 `takenAt`**:删掉的话第二次认领会答 `unknown`,而那是一句
255
+ 假话(它确实存在过、也确实被认领过),下一个查这件事的人会顺着去查一个
256
+ 「不存在的 id」。
257
+
178
258
  ### `/api/sessions/:id/goal` + `/api/goals/blocked` —— 目标那一组(方案 52 PR-4)
179
259
 
180
260
  **四条 session-scoped + 一条进程级**,而那条分界是硬的:前四条问「**这一段**会话的
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Lang, AgentRoleSource, WireRoleRemoveFailure, 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, WirePluginUploadForm, 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, AgentRoleSource, WireRoleRemoveFailure, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireSkillStageFailure, WireProjectInventory, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, WireHubEvent, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginVisibility, WirePluginStatus, WirePluginRefuseReason, WirePluginUploadForm, 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, RunningSchedule, ApprovalRevokeResult, LevelChangeResult, LastOpenSessionControl, WorkspaceFilesView } from '@epoch-agent/runtime';
3
3
  import { ServerResponse } from 'node:http';
4
4
 
@@ -1541,6 +1541,95 @@ interface GoalCatalogView {
1541
1541
  clear: (sessionId: string) => GoalClearView;
1542
1542
  }
1543
1543
 
1544
+ /**
1545
+ * 宿主投递那张待办表(2026-09-28)。
1546
+ *
1547
+ * 三条端点的语义、三条投递路径、以及「兑现它的是页面不是服务端」那几条判据都在
1548
+ * [host-compose.ts](./host-compose.js) 的文件头上。这个文件只管**存**:
1549
+ * 一笔待办长什么样、活多久、谁盖掉谁、以及认领那一下。
1550
+ *
1551
+ * ## 为什么它单独一个文件(而不是和那三条端点摆在一起)
1552
+ *
1553
+ * 为了让 `routes → host-compose → context` 那张图**没有环**。`ApiContext` 要把
1554
+ * 这张表递给每个 handler,于是它必须 import 这个类型;而三条端点要 import
1555
+ * `ApiContext`。两个东西在一个文件里的话,context 和它就成了互相 import ——
1556
+ * TS 让 `import type` 的环编译得过,但那正是 `routes → api → context` 这条线
1557
+ * 一直在避免的形状(判据写在 `context.ts` 文件头)。
1558
+ *
1559
+ * ## 一句话:进程级、只读内存、不落盘
1560
+ *
1561
+ * 它是**一笔此刻的投递**,不是配置:进程重启就没了,也不该有 —— 一个三天前的
1562
+ * 「帮我填这句话」在启动时自己冒出来,比丢掉它坏得多。
1563
+ *
1564
+ * ⚠️ **过期是惰性判的**,没有 `setInterval`:定时器活得比 `server.close()` 长,
1565
+ * 而那正是这一族(`hub.shutdown()`)一直在收拾的东西。代价是过期的条目要等到
1566
+ * 下一次读写才真的消失 —— 那张表最多几十项,不值得为它养一个定时器。
1567
+ */
1568
+
1569
+ interface HostComposeItem {
1570
+ id: string;
1571
+ sessionId: string;
1572
+ /** 空串 = 只把界面切过去,不填 */
1573
+ text: string;
1574
+ autoSend: boolean;
1575
+ createdAt: number;
1576
+ /** 认领那一刻。**认领过的条目留着不删** —— 判据在 {@link HostComposeStore.take} */
1577
+ takenAt?: number;
1578
+ }
1579
+ /** `take` 的四种答案。**互斥且都说得出话**,别把它们合成一个 `false` */
1580
+ type HostComposeTakeResult = 'ok' | 'taken' | 'expired' | 'unknown';
1581
+ interface HostComposeStoreOptions {
1582
+ /** 挂钟。注入是给用例用的(TTL 那两条路不然只能靠 sleep 测) */
1583
+ now?: () => number;
1584
+ /** 见 {@link HOST_COMPOSE_TTL_MS}。用例把它调小 */
1585
+ ttlMs?: number;
1586
+ /** 生成 id。注入是给用例用的(不然断言只能对着一个随机串) */
1587
+ newId?: () => string;
1588
+ }
1589
+ declare class HostComposeStore {
1590
+ /**
1591
+ * 键是**认领号的 id**,不是会话 id —— `take` 拿着 id 回来(判据在它自己的
1592
+ * JSDoc 上:会话 id 是这一笔的宾语,不是它的名字)。
1593
+ */
1594
+ private readonly items;
1595
+ private readonly now;
1596
+ private readonly ttlMs;
1597
+ private readonly newId;
1598
+ constructor(opts?: HostComposeStoreOptions);
1599
+ /**
1600
+ * 记一笔,并**盖掉同一段会话上还没认领的旧的那一笔**。
1601
+ *
1602
+ * 「按会话一格」就是这个 for 循环:宿主说的是「现在填这句」,把上一句留着只会让
1603
+ * 界面里出现一段没人要的文字。⚠️ **认领过的那些不盖**(它们不是待办,是回执),
1604
+ * 否则第二次认领会从 `taken` 变成 `unknown` —— 而那是一句假话。
1605
+ */
1606
+ record(input: {
1607
+ sessionId: string;
1608
+ text: string;
1609
+ autoSend: boolean;
1610
+ }): HostComposeItem;
1611
+ /**
1612
+ * 此刻还没被认领的那一笔,最近记的优先;一笔都没有就是 `null`。
1613
+ *
1614
+ * **认领过的不算**:它已经有人兑现了,再下发一次就是每一次刷新都把用户拽回那段
1615
+ * 会话。过期的不算,理由同。
1616
+ */
1617
+ pending(): HostComposeItem | null;
1618
+ /**
1619
+ * 认领 —— 一次性的。
1620
+ *
1621
+ * 四种答案各说各的事,而**认领过的那一笔留着不删**(只标 `takenAt`):删掉的话
1622
+ * 第二次认领会答 `unknown`,而那是一句假话(它确实存在过、也确实被认领过),
1623
+ * 下一个查这件事的人会顺着去查一个「不存在的 id」。
1624
+ *
1625
+ * ⚠️ **`ok` 不是「一定发得出去」的保证**,它只保证「不会重复发」—— 判据在
1626
+ * `host-compose.ts` 文件头最后一段。
1627
+ */
1628
+ take(id: string): HostComposeTakeResult;
1629
+ /** 过期的整条丢掉 —— 认领过的那些过了 TTL 也一并走(没人会再问它们了) */
1630
+ private sweep;
1631
+ }
1632
+
1544
1633
  /**
1545
1634
  * SessionHub —— 事件总线 + 回合状态机 + 审批生命周期。
1546
1635
  *
@@ -2006,6 +2095,21 @@ declare class SessionHub {
2006
2095
  * ⚠️ **它不发给旁听者**(`observe()` 那一路),判据在 {@link publish} 上。
2007
2096
  */
2008
2097
  notifyPluginsChanged(): void;
2098
+ /**
2099
+ * 广播「宿主往某段会话的输入框里送了一段字」(2026-09-28)。
2100
+ *
2101
+ * 语义、三条投递路径、以及「客户端**不许**按会话过滤它」那条判据全在 protocol
2102
+ * 的 `host-compose` 上。唯一的调用方是 `POST /api/host/compose`
2103
+ * ([host-compose.ts](./host-compose.js))—— 同 {@link notifyTasksChanged},
2104
+ * Hub 不认识那张待办表,它在这条链上只负责发帧。
2105
+ *
2106
+ * ⚠️ **它带 `sessionId`**(不同于上面那条 `plugins-changed`):这一笔确实属于
2107
+ * 那一段会话。而这一帧的收件人恰恰是**不在**那段会话上的那个标签页 ——
2108
+ * 那条区别写在 protocol 的 JSDoc 里,客户端在 `parked` 那道早退**之前**拦它。
2109
+ */
2110
+ notifyHostCompose(sessionId: string, payload: Omit<Extract<WireHubEvent, {
2111
+ type: 'host-compose';
2112
+ }>, 'type'>): void;
2009
2113
  /**
2010
2114
  * 收摊:撤掉倒计时、放弃所有挂起的审批、中止所有在跑的回合。
2011
2115
  *
@@ -2951,7 +3055,8 @@ interface PluginHitView {
2951
3055
  name: string;
2952
3056
  description?: string;
2953
3057
  source: string;
2954
- category?: string;
3058
+ /** 挂着的那几类(2026-09-28 起是复数,判据在 core 的 `EntrySchema.category` 上) */
3059
+ category?: readonly string[];
2955
3060
  keywords?: readonly string[];
2956
3061
  /** 目录里那一格「谁写的」。**摊平在投影里做**,判据在 `WirePluginHit.author` 上 */
2957
3062
  author?: {
@@ -5145,6 +5250,16 @@ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSe
5145
5250
  }
5146
5251
  interface ApiContext {
5147
5252
  hub: SessionHub;
5253
+ /**
5254
+ * 「宿主往输入框里送了一句话」那张待办表(2026-09-28)。
5255
+ *
5256
+ * 语义、三条投递路径、TTL,全在 [host-compose-store.ts](./host-compose-store.js)
5257
+ * 的文件头上 —— 这一格只是把它递给三个 handler(记一笔 / 读那一笔 / 认领那一笔)。
5258
+ *
5259
+ * ⚠️ **它是这个服务进程自己的状态,不落盘**:`server.close()` 之后那些待办就没了,
5260
+ * 而这正是它该有的形态(判据在 store 的文件头「一句话:进程级、只读内存、不落盘」)。
5261
+ */
5262
+ hostCompose: HostComposeStore;
5148
5263
  runtime: WebRuntimeView;
5149
5264
  /** `/api/health` 回的版本号。由 cli 传进来 —— server 不该去猜宿主的版本 */
5150
5265
  version: string;