@mrrisega/dsh-remote 0.6.11 → 0.6.13

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.
Files changed (48) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +123 -2
  2. package/clients/dsh-remote/mobile-adapter.mjs +108 -4
  3. package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
  4. package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
  5. package/dsh-setup.mjs +55 -15
  6. package/package.json +4 -4
  7. package/packages/dsh-remote-web/lib/client.js +137 -0
  8. package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
  9. package/packages/dsh-remote-web/lib/index.js +748 -18
  10. package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
  11. package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
  12. package/packages/dsh-remote-web/package.json +5 -1
  13. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
  14. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
  15. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
  23. package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
  24. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
  25. package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
  26. package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
  27. package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
  28. package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
  29. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
  30. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
  31. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
  32. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
  33. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
  34. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
  35. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
  36. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
  37. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
  38. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
  39. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
  40. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
  41. package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
  42. package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
  43. package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
  44. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
  45. package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
  46. package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
  47. package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
  48. package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
@@ -0,0 +1,1991 @@
1
+ /**
2
+ * dsh-events — DSH 事件订阅器(微信机器人通道的事件源,见 docs/wechat-bot-channel.md §4/§5/§6)。
3
+ *
4
+ * 为什么需要一条**独立连接**
5
+ * dsh-bridge.mjs 对 DSH 的 WebSocket 帧是逐字节透传、**从不解析**的(它不认识「会话」),
6
+ * 而插件宿主半边(packages/dsh-remote-web/lib/index.js)**没有任何审批挂钩**。
7
+ * 所以要观察事件,必须有一个本地进程自己开一条到 DSH 的连接。
8
+ *
9
+ * 依赖
10
+ * 零新增依赖:只用仓库已 vendor 的 `ws`(`dsh-bridge.mjs` 同样是 `import WebSocket from "ws"`)
11
+ * 与 node 内建模块。
12
+ *
13
+ * 线上形状(逐条抄自 DSH shipped source,勿凭记忆改)
14
+ * - mux 路由 `REMOTE_STREAM_MUX_PATH = '/api/remote.mux'`
15
+ * @deepseek-ai/dsh-api-gateway/lib/types/stream-protocol.js:3
16
+ * (⚠️ dsh-bridge.mjs:26 的注释写的 `/api/events.mux` 是**过期注释**,全 DSH 零命中)
17
+ * - 客户端→服务端 `{type:'open',streamId,endpoint,payload}` / `{type:'cancel',streamId}`
18
+ * stream-protocol.js `parseRemoteStreamClientMessage()`:155(键集精确匹配)
19
+ * - 服务端→客户端 `{type:'item',streamId,value}` / `{type:'end',streamId}` /
20
+ * `{type:'error',streamId,error:{code,message,details}}`,同文件 `parseRemoteStreamServerMessage()`:175
21
+ * - `$events`:endpoint `'$events'`,payload `{args:{}}`,**首帧** value 为
22
+ * `{type:'ready',clientId,host:{home}}`;clientId 是回答任何事件的必需品
23
+ * dsh-api-gateway/lib/index.js `openRemoteEvents()`:585
24
+ * - `emit` 帧 `{type:'emit',event,args}`,args 即 Cordis 监听参数
25
+ * dsh-api-remotes/lib/index.js `broadcastRemoteEvent()`:621
26
+ * - `waterfall` 帧 `{type:'waterfall',event,eventId,agentId,request}`,
27
+ * `request` 已被服务端剥掉 `agent`/`signal`(可达字段:审批 toolName/callId/reason;
28
+ * 提问 questions) dsh-api-gateway/lib/index.js `startRemoteEvent()`:630
29
+ * - 🔴 第三种帧:waterfall 事件被结算/取消时服务端补发 `{type:'cancel',eventId}`
30
+ * (同文件 `finishRemoteEvent()`:712)。规格 §4 的帧清单里没有它,但它是
31
+ * 「这条审批已作废」的**唯一**信号 —— §6① 要求消息上能显示「这条已过期」。
32
+ * - 回执走 HTTP(不是 socket):`POST {upstream}/api/$events/result`
33
+ * body `{type:'client-request',rpcId,method:'$events/result',payload:{args:{clientId,eventId,outcome}}}`
34
+ * 响应 `{type:'server-response',rpcId,result:{ok:true,value}|{ok:false,error:{code,message,details}}}`
35
+ * @deepseek-ai/dsh-client-connection/lib/index.js `rpcFetchHandler()`:635 / `fullResponse()`:685
36
+ * ⚠️ 规格 §4 说「`{ok:false,error}` 体」——实际嵌在 `result` 里。
37
+ * - `session/follow`:payload **必须是 `{args:{request:{address:{kind:'session',sessionId:'<id>'}}}}`**
38
+ * (外层 `args` 是 gateway 的硬校验,见 dsh-api-gateway/lib/index.js `remoteRequest():929`;
39
+ * 规格 §4 只给了 args 内层对象,照抄会被服务端拒掉 —— 真机实测踩过)
40
+ * 首帧 `{type:'snapshot',header,cursor,records,hasMore,projections}`(真机实测键集一致,
41
+ * `header.id` 是完整会话 id),其后 `{type:'event',event:{type,seq,time,data:{turn,reason:{kind}}}}`
42
+ * @deepseek-ai/dsh-api-session-controller/lib/index.js `follow()`:1400
43
+ * - 🔴 **follow 的 sessionId 用「事件里报什么就发什么」**,不要一律加 `session-` 前缀:
44
+ * 真机实测(2026-09-21)本机根会话的日志 id 是 `session-<uuid>`,而**子代理会话的真实 id 是裸 uuid**
45
+ * (裸 id → `session/agent-busy`,说明找到了;`session-<uuid>` → `session/not-found`,说明不存在)。
46
+ * 规格 §4 那句「必须用持久 session-<uuid>,裸 uuid 会 not-found」只在根会话上成立。
47
+ * 本模块主形式原样发送,另一种形式只在 `session/not-found` 时兜底各试一次(见 `sessionIdCandidates`)。
48
+ * - `api-session/*` 的口径(真机逐帧核对):`status` = `[sessionId, running]`(**边沿**事件)、
49
+ * `error` = `[id, message]`、`activity` = `[sessionId, epochMs]`、`removed` = `[sessionId]`、
50
+ * `added` = `[summary]`,summary 字段 `sessionId/updatedAt/running/blank/parentSessionId/origin/cwd/projections`。
51
+ * - `turn/end.reason.kind` 闭集:completed|aborted|blocked|error|max-tokens|interrupted
52
+ * (同包 typert.host.js 的 `TurnEndReasonMap`)
53
+ *
54
+ * 会话发现(为什么还需要 $events 之外的东西)
55
+ * `$events` 的 `api-session/status` 是**边沿**事件:它只是「状态变了」,**不补发**。
56
+ * 于是 bridge 重启/断线重连时,若某任务正在跑,我们永远收不到它的 `status:true`,
57
+ * 也就永远不 follow,最终拿不到它的 `turn/end` —— P0 节点 4 会**静默丢失**。
58
+ * 两条补洞来源(都只读,默认开):
59
+ * ① `session/list`(**一元 HTTP**,不是流):每项带真实 `running` → 连接时/周期对账时
60
+ * 直接发现「正在跑」的会话并 follow;也可据此关掉已停的 follow(防泄漏)。
61
+ * ② `session/control`(流):baseline 的 `queues/jobs/projections` **键**即实时会话集合
62
+ * (**没有** running 字段),其后的 queue/jobs/projection 帧是该会话「活着」的电平信号。
63
+ *
64
+ * 会话控制(一元 RPC 层)
65
+ * 除回执外,本模块还给上层提供一组**会话控制**方法(`listSessions` / `createSession` /
66
+ * `promptSession` / `pageSession` / `lastAssistantText` / `cancelSession` / `renameSession`)。
67
+ * 它们和回执走同一条 HTTP 通道(`POST {upstream}/api/<method>`,信封见上),
68
+ * **一律返回类型化结果、绝不抛异常**(失败形状 `{ok:false, code, message, details?}`)。
69
+ * 线参名逐个抄自 DSH 自己的 zod 描述符
70
+ * (@deepseek-ai/dsh-api-session-controller/lib/typert.host.js `TYPERT.invocations`):
71
+ * session/list → `{_request:{}}` ← **唯一**用 `_request` 的(送 `request` 被拒:missing "_request")
72
+ * session/create → `{request:{workspaceId?,cwd?,sessionId?,agentPreset?}}`
73
+ * session/prompt → `{request:{requestId,sessionId,mode,content,clientTimeZone?}}`(前四个**全必填**)
74
+ * session/page → `{request:{address:{kind:'session',sessionId},throughSeq,beforeSeq?,maxMessages?}}`
75
+ * session/cancel → `{request:{sessionId}}` → `{accepted:true}`
76
+ * session/rename → `{request:{sessionId,title}}` → `{title,seq}`
77
+ * ⚠️ **没有 delete/dispose**:描述符里 `session/*` 只有上面这些(外加 search/fork/attachment/
78
+ * modelCatalog/selectModel/updateQueue/control/follow),不要发明一个删除方法。
79
+ *
80
+ * `throughSeq` 从哪来(`session/page` 的必填项)
81
+ * ① `session/list` 每项的 `projections.asOfSeq` —— 投影快照的 as-of 序号,本身就是会话日志里的
82
+ * 一个 seq(history.js `projectionBlock()` 直接搬 `snapshot.asOfSeq`;`follow()` 里没开投影时
83
+ * 更是直接写 `{asOfSeq: cursor}`,证明它和会话 cursor 同尺度)。
84
+ * ② `session/follow` 首帧的 `cursor`(= 该会话当时**最后一条已提交事件**的 seq),
85
+ * 以及其后每条 `event.seq`;本模块顺手记进 `#cursors`。
86
+ * 两者都 ≤ 会话当前 cursor,所以取**较大者**只会让这一页更完整、不会越过 cursor。
87
+ * `pageSession` **每次都重新拉一次 `session/list`**(投影缓存可能落后于实时流,
88
+ * 缓存只用于「list 失败」时兜底);两处都拿不到就**显式失败** `through-seq-unavailable`,
89
+ * 绝不猜一个魔法数字。
90
+ * ⚠️ 不用描述符允许的 `throughSeq: -1`:history.js `paginate()` 会把它算成
91
+ * `end = min(-1+1, …) = 0` → 返回**空页**(不是「最新一页」)。口径为负,故不采用。
92
+ *
93
+ * 助手结论文本的真实形状(`session/page` → `value.records[]`)
94
+ * `record = {type:'event', event:{type,seq,time,data}}`;助手消息是 `event.type === 'assistant/message'`,
95
+ * `event.data = {turn,step,message:{id,role:'assistant',source:{kind:'model',…},content:[…]},stream,usage?}`,
96
+ * 可见文本块是 `{type:'text',text}`(`reasoning`/`tool-call`/`tool-result` 都**不是**结论)。
97
+ * 证据:dsh-session/lib/types/types.d.ts:309(`SessionEventMap['assistant/message']`)
98
+ * + dsh-llm/lib/types/types.d.ts:39(`TextBlock`)。只挂 usage 的空 `content` 消息会被跳过,
99
+ * 取**最后一条非空**文本;形状不认识就返回 `""`(永不抛出)。
100
+ *
101
+ * 硬性质(每一个都有测试兜着)
102
+ * ① 审批**有寿命**:只在 turn 开着时有效,绑在请求的 AbortSignal 上。
103
+ * `cancel` 帧一到就标记 expired,**再回执一律拒绝**;「没回复」绝不等于同意(fail-closed)。
104
+ * ② `$events` 的 emit **不重放**:重连后不假装连续,发 `{kind:'gap',from,to}` 让上层
105
+ * 如实告诉用户「通知可能漏了」。
106
+ * ③ 401 自愈只存在于 HTTP 路径(bridge 的 WS 路径没有):mux 握手被 401/403 拒绝时
107
+ * **停止重连**并 emit `auth-error`,由上层换 Cookie 后调 `retry()`。
108
+ *
109
+ * @module dsh-events
110
+ */
111
+
112
+ import { randomUUID } from "node:crypto";
113
+ import { EventEmitter } from "node:events";
114
+ import WebSocket from "ws";
115
+
116
+ // ============================================================
117
+ // 常量(与 DSH 源码一一对应)
118
+ // ============================================================
119
+
120
+ /** mux WebSocket 路由(REMOTE_STREAM_MUX_PATH,已含 `/api`)。 */
121
+ export const MUX_PATH = "/api/remote.mux";
122
+ /** RPC 通道前缀(dsh-client-connection 的 `API_PATH`)。 */
123
+ export const API_CHANNEL = "/api";
124
+ /** 全局转发事件流**逻辑端点名**(REMOTE_EVENT_STREAM_ENDPOINT),不是 URL。 */
125
+ export const EVENTS_ENDPOINT = "$events";
126
+ /** 回执的**逻辑端点名**(REMOTE_EVENT_RESULT_ENDPOINT),用作 envelope 的 `method`。 */
127
+ export const RESULT_ENDPOINT = "$events/result";
128
+ /**
129
+ * 回执的 HTTP 路由 = 通道 + 端点:`POST {upstream}/api/$events/result`。
130
+ * ⚠️ 逻辑端点名不带前导 `/`,直接拼在 upstream 后面会得到 `http://host$events/result`(实测会
131
+ * `Failed to parse URL`)—— 这是本模块踩过的坑,规格 §4 只写了逻辑名。
132
+ */
133
+ export const RESULT_PATH = `${API_CHANNEL}/${RESULT_ENDPOINT}`;
134
+ /** 打开 $events 的空 payload(REMOTE_EVENT_STREAM_PAYLOAD)。 */
135
+ export const EVENTS_PAYLOAD = Object.freeze({ args: {} });
136
+ /**
137
+ * 会话发现源 ①:`session/list`(**一元**远端方法,走 HTTP,不能开流)。
138
+ * 返回 `{items: SessionSummary[]}`,每项带真实 `running: boolean` —— 这是「接进来时已经在跑的会话」
139
+ * 唯一**确定性**的发现方式(`$events` 的 `api-session/status` 是边沿事件,接进来之前的那次永远不会补发)。
140
+ * ⚠️ 线参名是 `_request`(不是 `request`):实测送 `request` 会得到
141
+ * `gateway/arguments-invalid: missing "_request"; unexpected "request"`。
142
+ */
143
+ export const SESSION_LIST_ENDPOINT = "session/list";
144
+ /** 会话发现源 ②:`session/control`(流):baseline 给出实时会话集合,其后按会话推 queue/jobs/projection。 */
145
+ export const SESSION_CONTROL_ENDPOINT = "session/control";
146
+ /** `session/list` 的线参名(见上)。 */
147
+ export const SESSION_LIST_WIRE_ARG = "_request";
148
+ /**
149
+ * 除 `session/list` 外**所有** `session/*` 一元方法的线参名都是 `request`
150
+ * (typert.host.js 的 TYPERT.invocations 里每个都是 `{name:'request', wire:'request'}`)。
151
+ */
152
+ export const SESSION_JSON_ARG = "request";
153
+ /** 新建会话(`{request:{workspaceId?,cwd?,sessionId?,agentPreset?}}` → `{sessionId,agentPreset?}`)。 */
154
+ export const SESSION_CREATE_ENDPOINT = "session/create";
155
+ /** 发一条消息(`{request:{requestId,sessionId,mode,content,clientTimeZone?}}` → `{accepted:true}`)。 */
156
+ export const SESSION_PROMPT_ENDPOINT = "session/prompt";
157
+ /** 读会话历史(`{request:{address,throughSeq,beforeSeq?,maxMessages?}}` → `{records,hasMore}`)。 */
158
+ export const SESSION_PAGE_ENDPOINT = "session/page";
159
+ /** 取消当前 turn(`{request:{sessionId}}` → `{accepted:true}`)。⚠️ 全 DSH 没有 delete/dispose 方法。 */
160
+ export const SESSION_CANCEL_ENDPOINT = "session/cancel";
161
+ /** 改会话标题(`{request:{sessionId,title}}` → `{title,seq}`)。 */
162
+ export const SESSION_RENAME_ENDPOINT = "session/rename";
163
+ /** 一元 RPC 的默认超时(ms):网关卡住也不能把通知循环拖死。 */
164
+ export const DEFAULT_RPC_TIMEOUT_MS = 15_000;
165
+ /** `#cursors`(throughSeq 兜底缓存)的条数上限。 */
166
+ const CURSOR_LIMIT = 512;
167
+
168
+ /** 分类后的节点 kind。前 4 个是规格 §5 的 P0,后 3 个是 P1 的本地钩子。 */
169
+ export const NODE_KINDS = Object.freeze({
170
+ /** 1 要你拍板(工具放行)—— 可回执 */
171
+ APPROVAL_REQUEST: "approval-request",
172
+ /** 2 在等你回答(agent 提问)—— 可回执 */
173
+ USER_QUESTION: "user-question",
174
+ /** 2 计划模式待批(`intent.kind === 'plan-review'`)—— 可回执,文案与普通提问区分 */
175
+ PLAN_REVIEW: "plan-review",
176
+ /** 3 任务报错 */
177
+ SESSION_ERROR: "session-error",
178
+ /** 4 任务停止(来自 session/follow 的 turn/end,带精确 reason) */
179
+ TURN_END: "turn-end",
180
+ /** 附加:审批/提问已作废(规格 §6① 要求可显示「已过期」) */
181
+ EVENT_EXPIRED: "event-expired",
182
+ /** 附加:重连窗口,期间的通知可能已经漏了(规格 §6②) */
183
+ GAP: "gap",
184
+ /** 5 每日简报(本地定时触发) */
185
+ DIGEST_DUE: "digest-due",
186
+ /** 6 额度将尽 / 被限流 */
187
+ QUOTA_LOW: "quota-low",
188
+ /** 7 会员即将过期 / 已过期 */
189
+ MEMBERSHIP_EXPIRING: "membership-expiring",
190
+ /** 非致命故障(会话不存在 / 子代理被拒 / 流结束等) */
191
+ FAULT: "fault",
192
+ });
193
+
194
+ /** `approval/request` 的合法回执值(DSH OUTCOMES 里属于「人做的决定」的两项)。 */
195
+ export const APPROVAL_OUTCOMES = Object.freeze(["allowed-once", "rejected"]);
196
+
197
+ /** 节点 1 的选项文案(编号选项由微信侧渲染)。 */
198
+ export const APPROVAL_CHOICES = Object.freeze([
199
+ Object.freeze({ value: "allowed-once", label: "允许一次" }),
200
+ Object.freeze({ value: "rejected", label: "拒绝" }),
201
+ ]);
202
+
203
+ /** `turn/end.reason.kind` 闭集。 */
204
+ export const TURN_END_REASONS = Object.freeze([
205
+ "completed",
206
+ "aborted",
207
+ "blocked",
208
+ "error",
209
+ "max-tokens",
210
+ "interrupted",
211
+ ]);
212
+
213
+ /** 会话 id 的持久前缀。 */
214
+ const SESSION_PREFIX = "session-";
215
+
216
+ // ============================================================
217
+ // 小工具
218
+ // ============================================================
219
+
220
+ /**
221
+ * 补上 `session-` 前缀的「规范持久形式」。
222
+ * @param {unknown} id - 会话 id。
223
+ * @returns {string} 带前缀形式;非字符串/空返回空串。
224
+ */
225
+ export function toDurableSessionId(id) {
226
+ if (typeof id !== "string") return "";
227
+ const trimmed = id.trim();
228
+ if (trimmed === "") return "";
229
+ return trimmed.startsWith(SESSION_PREFIX) ? trimmed : `${SESSION_PREFIX}${trimmed}`;
230
+ }
231
+
232
+ /**
233
+ * 一条会话 id 的**候选线上形式**,按可信度排序:**第一个永远是原样**。
234
+ *
235
+ * 🔴 真机实测(2026-09-21,本机 dsh web)推翻了规格 §4 的说法:
236
+ * · 本会话(dsh web 建的根会话)的日志 id **是** `session-<uuid>`;
237
+ * · 但子代理会话的**真实日志 id 是裸 uuid**(`b7632f14-…`):
238
+ * 用裸 id 开 follow → `session/agent-busy`(说明**会话找到了**,只是子代理要按父地址取);
239
+ * 用 `session-<uuid>` 开 → `session/not-found`(这个 id 根本不存在)。
240
+ * 所以「一律加前缀」是错的(会让非前缀 id 的会话永远 follow 不上),
241
+ * 「一律原样」则对 `session-<uuid>` 的写法毫无容错。
242
+ * → 主形式用**事件里报什么就发什么**,另一种形式只在 `session/not-found` 时兜底试一次。
243
+ *
244
+ * @param {unknown} id - 事件里报的会话 id。
245
+ * @returns {string[]} `[原样, 另一种形式]`;空 id 返回 `[]`。
246
+ */
247
+ export function sessionIdCandidates(id) {
248
+ if (typeof id !== "string") return [];
249
+ const trimmed = id.trim();
250
+ if (trimmed === "") return [];
251
+ const alternate = trimmed.startsWith(SESSION_PREFIX)
252
+ ? trimmed.slice(SESSION_PREFIX.length)
253
+ : `${SESSION_PREFIX}${trimmed}`;
254
+ return alternate === "" || alternate === trimmed ? [trimmed] : [trimmed, alternate];
255
+ }
256
+
257
+ /** 回执失败的类型化错误:服务端 `{ok:false,error}`、HTTP 状态、或本地 dedupe 拒绝。 */
258
+ export class EventAnswerError extends Error {
259
+ /**
260
+ * @param {string} code - 服务端 error.code,或本地码:`already-answered` | `event-expired`
261
+ * | `not-connected` | `bad-response` | `rpc-mismatch` | `http-<status>` | `network`。
262
+ * @param {string} message - 面向人的说明(可直接转发给用户)。
263
+ * @param {object} [extra] - `{details?, status?, rpcId?, eventId?}`。
264
+ */
265
+ constructor(code, message, extra = {}) {
266
+ super(message);
267
+ this.name = "EventAnswerError";
268
+ this.code = code;
269
+ this.details = extra.details;
270
+ this.status = extra.status;
271
+ this.rpcId = extra.rpcId;
272
+ this.eventId = extra.eventId;
273
+ }
274
+
275
+ /** 是否「服务端明确告诉我们这事已经了结」——上层可以静默吞掉。 */
276
+ get settled() {
277
+ return this.code === "already-answered" || this.code === "event-expired";
278
+ }
279
+ }
280
+
281
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
282
+
283
+ /** 「已了结」集合的上限:daemon 长跑时不能无限涨(Map 插入序 = 由旧到新淘汰)。 */
284
+ const SETTLED_LIMIT = 4096;
285
+
286
+ /**
287
+ * 记下一个已了结的 eventId。
288
+ * @param {Map<string, true>} settled - 目标集合。
289
+ * @param {string} eventId - 事件 id。
290
+ */
291
+ function markSettled(settled, eventId) {
292
+ settled.set(eventId, true);
293
+ while (settled.size > SETTLED_LIMIT) settled.delete(settled.keys().next().value);
294
+ }
295
+
296
+ /** 从 Cordis emit 的 args 里取一个可展示的字符串。 */
297
+ function messageOf(value) {
298
+ if (typeof value === "string") return value;
299
+ if (value === undefined || value === null) return "";
300
+ try {
301
+ return JSON.stringify(value);
302
+ } catch {
303
+ return String(value);
304
+ }
305
+ }
306
+
307
+ // ============================================================
308
+ // 一元 RPC 的小工具(纯函数,方便单测)
309
+ // ============================================================
310
+
311
+ /**
312
+ * 类型化失败对象:**所有**一元方法失败时都返回这个形状(绝不抛异常)。
313
+ * @param {string} code - 服务端 error.code 或本地码(`invalid-arguments` / `bad-response` /
314
+ * `timeout` / `network` / `through-seq-unavailable` / `http-<status>` / `rpc-mismatch` …)。
315
+ * @param {string} message - 面向人的说明(可直接转发给用户)。
316
+ * @param {unknown} [details] - 服务端给的细节(原样带出)。
317
+ * @returns {{ok:false, code:string, message:string, details?:unknown}}
318
+ */
319
+ function typedFailure(code, message, details) {
320
+ return details === undefined ? { ok: false, code, message } : { ok: false, code, message, details };
321
+ }
322
+
323
+ /** 本地参数校验失败(不会发出任何请求)。 */
324
+ function invalidArguments(message) {
325
+ return typedFailure("invalid-arguments", message);
326
+ }
327
+
328
+ /** 把抛出的东西(EventAnswerError / TypeError / 任意值)折成类型化失败。 */
329
+ function failureOf(error) {
330
+ if (error instanceof EventAnswerError) {
331
+ return typedFailure(error.code, error.message, error.details);
332
+ }
333
+ return typedFailure("internal", `未预期的错误:${messageOf(error) || String(error)}`);
334
+ }
335
+
336
+ /** 非空字符串? */
337
+ function isNonEmptyString(value) {
338
+ return typeof value === "string" && value.trim() !== "";
339
+ }
340
+
341
+ /** `session/list` 一项里的 `projections.asOfSeq`(拿不到返回 undefined)。 */
342
+ function asOfSeqOf(value, sessionId) {
343
+ const items = isRecord(value) && Array.isArray(value.items) ? value.items : [];
344
+ for (const item of items) {
345
+ if (!isRecord(item) || item.sessionId !== sessionId) continue;
346
+ const seq = isRecord(item.projections) ? item.projections.asOfSeq : undefined;
347
+ if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0) return seq;
348
+ return undefined;
349
+ }
350
+ return undefined;
351
+ }
352
+
353
+ /**
354
+ * 从 `session/page` 的 `records` 里取**最后一条非空助手文本**。
355
+ *
356
+ * 真实形状(见模块头):`record = {type:'event', event:{type:'assistant/message',
357
+ * data:{turn,step,message:{id,role:'assistant',content:[{type:'text',text},…]}}}}`。
358
+ * 只认 `{type:'text'}` 块:`reasoning` / `tool-call` / `tool-result` 都不是给用户看的结论。
359
+ * 空 `content` 的 assistant/message(只挂 usage 的结算事件)会被跳过,因此结果是「最后一条**有字**的」。
360
+ * 形状不认识 / 不是数组 / 什么都没有 → `""`(**永不抛出**,这是给通知循环用的)。
361
+ *
362
+ * @param {unknown} records - `pageSession().records`。
363
+ * @returns {string} 结论文本;没有就空串。
364
+ */
365
+ export function extractLastAssistantText(records) {
366
+ if (!Array.isArray(records)) return "";
367
+ let text = "";
368
+ for (const record of records) {
369
+ const event = isRecord(record) ? record.event : null;
370
+ if (!isRecord(event) || event.type !== "assistant/message") continue;
371
+ const data = isRecord(event.data) ? event.data : null;
372
+ if (data === null) continue;
373
+ // 主形状:data.message.content[];兜底:data.content[](防御式,字段名不假设唯一)。
374
+ const message = isRecord(data.message) ? data.message : null;
375
+ if (message !== null && message.role !== undefined && message.role !== "assistant") continue;
376
+ const content = Array.isArray(message?.content) ? message.content : Array.isArray(data.content) ? data.content : null;
377
+ if (content === null) continue;
378
+ const parts = [];
379
+ for (const block of content) {
380
+ if (typeof block === "string") {
381
+ parts.push(block);
382
+ continue;
383
+ }
384
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
385
+ }
386
+ const joined = parts.join("\n");
387
+ if (joined.trim() !== "") text = joined; // 空文本不覆盖上一条(跳过只挂 usage 的结算消息)
388
+ }
389
+ return text;
390
+ }
391
+
392
+ // ============================================================
393
+ // 订阅器
394
+ // ============================================================
395
+
396
+ const DEFAULT_RECONNECT = Object.freeze({ minMs: 500, maxMs: 15_000, factor: 2, jitter: 0.2 });
397
+
398
+ /**
399
+ * 一个 DSH 事件订阅器 = 一条 mux socket($events 全局流)+ N 条 session/follow 流。
400
+ *
401
+ * 事件(EventEmitter;**不发 `'error'`**,避免无监听者时抛出):
402
+ * - `'ready'` `{clientId, host, at, generation}`:$events 就绪,clientId 已拿到
403
+ * - `'event'` 分类后的节点(见 classify*),微信侧唯一入口
404
+ * - `'gap'` `{kind:'gap', from, to, reason, at}`:断线窗口,通知可能漏了
405
+ * - `'fault'` `{kind:'fault', code, message, expected, sessionId?, at}`:非致命故障
406
+ * - `'auth-error'` `{status, message, surface:'ws'|'http', at}`:需要换 Cookie
407
+ * - `'follow-opened'` `{sessionId, streamId, at}`
408
+ * - `'follow-closed'` `{sessionId, streamId, reason, at}`
409
+ * - `'follow-snapshot'` `{sessionId, header, cursor, recordCount, at}`
410
+ * - `'session-state'` `{sessionId, state:'running'|'idle'|'removed', at}`
411
+ * - `'sessions-discovered'` `{running: string[], total, at}`:`session/list` 发现结果
412
+ * - `'control-sessions'` `{sessions: string[], at}`:`session/control` baseline 的实时会话集合
413
+ * - `'control-activity'` `{sessionId, type, key?, at}`:某会话有 queue/jobs/projection 更新(= 活着)
414
+ * - `'closed'` `{}`:主动 close() 完成
415
+ */
416
+ class EventSubscriber extends EventEmitter {
417
+ #opts;
418
+ #WebSocketImpl;
419
+ #fetchImpl;
420
+
421
+ #state = "idle";
422
+ #closed = false;
423
+ #ws = null;
424
+ #attempt = 0;
425
+ #reconnectTimer = null;
426
+
427
+ #clientId = null;
428
+ #ready = null;
429
+ #readyWaiters = [];
430
+
431
+ /** streamId -> { endpoint, kind:'events'|'follow', sessionId? } */
432
+ #streams = new Map();
433
+ /** sessionId -> { streamId, openedAt, sawTurnEnd, settleTimer } */
434
+ #follows = new Map();
435
+ /** 当前认为「正在跑」的会话(api-session/status、api-session/added 的 running、session/list 的 running)。 */
436
+ #running = new Set();
437
+ /** sessionId -> 最近一次「实时 running 凭据」的时间(用于对账时不与刚来的边沿事件打架)。 */
438
+ #runningLiveAt = new Map();
439
+ /** `session/control` 观察到的实时会话集合(含未在跑的)。 */
440
+ #controlSessions = new Set();
441
+ #controlStreamId = null;
442
+ #discoverTimer = null;
443
+ /** 已就 `(sessionId, seq)` 报过的 turn/end,避免 snapshot 补报与实时帧重复。 */
444
+ #reportedTurnEnds = new Map();
445
+ /** 本代内开流失败过的会话:不再重试,避免 session/not-found 打转。重连后清空。 */
446
+ #followBlocked = new Set();
447
+
448
+ /** 已回执过的 eventId(一次有效,绝不重发);用 Map 保持插入序以便有界淘汰。 */
449
+ #answered = new Map();
450
+ /** 已作废的 eventId(收到 cancel 帧);再回执一律拒绝。 */
451
+ #expired = new Map();
452
+ /** eventId -> 分类节点(供上层按 eventId 查文案)。 */
453
+ #pending = new Map();
454
+ /**
455
+ * sessionId -> 已知的最大会话 seq(`session/follow` 的 snapshot.cursor 与每条 event.seq,
456
+ * 以及 `session/list` 的 projections.asOfSeq)。`session/page` 的 `throughSeq` 用它兜底。
457
+ */
458
+ #cursors = new Map();
459
+
460
+ #seq = 0;
461
+ #lastFrameAt = 0;
462
+ #authStatus = null;
463
+ #lastSocketError = null;
464
+ #now;
465
+
466
+ constructor(options = {}) {
467
+ super();
468
+ if (typeof options.upstream !== "string" || options.upstream.trim() === "") {
469
+ throw new TypeError("dsh-events: options.upstream is required (e.g. http://127.0.0.1:3080)");
470
+ }
471
+ this.#opts = {
472
+ upstream: options.upstream.replace(/\/+$/, ""),
473
+ cookie: options.cookie ?? "",
474
+ muxPath: options.muxPath ?? MUX_PATH,
475
+ /** HTTP 路由(默认 `/api/$events/result`)。 */
476
+ resultPath: options.resultPath ?? RESULT_PATH,
477
+ /** envelope 里的逻辑方法名(默认 `$events/result`)。 */
478
+ resultEndpoint: options.resultEndpoint ?? RESULT_ENDPOINT,
479
+ reconnect: { ...DEFAULT_RECONNECT, ...(options.reconnect ?? {}) },
480
+ /** 会话停止运行后,再等这么久收尾 turn/end 帧,然后关流(防止 socket 泄漏)。 */
481
+ followSettleMs: options.followSettleMs ?? 1_500,
482
+ /** 同时打开的 follow 流上限(子代理会话会被拒,上限也保护 DSH)。 */
483
+ maxFollowStreams: options.maxFollowStreams ?? 8,
484
+ /** 是否管理 follow 订阅集(false = 只订阅 $events)。 */
485
+ follow: options.follow !== false,
486
+ /** 用 `session/list` 发现「接进来时已经在跑」的会话(默认开)。 */
487
+ discover: options.discover !== false,
488
+ /**
489
+ * 对账间隔(ms):周期性核对已 follow 的会话是否还在跑,不在跑就关流(防泄漏)。
490
+ * 0 = 关闭周期对账(仍会在每次连接时发现一次)。
491
+ * 只在**手上有 follow 流**时才真的发这一次请求(没有流就没有可对账的东西)。
492
+ */
493
+ discoverIntervalMs: options.discoverIntervalMs ?? 120_000,
494
+ /** 是否订阅 `session/control`(实时会话集合 + 活跃度;默认开)。 */
495
+ control: options.control !== false,
496
+ /**
497
+ * 一元 RPC 的超时(ms)。0/负数/非有限值 = 不设超时(不建议)。
498
+ * 超时既 abort 掉这次 fetch,也让 promise 立刻以 `code:'timeout'` 结算,
499
+ * 所以即使注入的 fetchImpl 不认 signal,也**不会**把调用方挂住。
500
+ */
501
+ rpcTimeoutMs: options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
502
+ log: typeof options.log === "function" ? options.log : null,
503
+ };
504
+ this.#WebSocketImpl = options.WebSocketImpl ?? WebSocket;
505
+ this.#fetchImpl = options.fetchImpl ?? ((...args) => globalThis.fetch(...args));
506
+ this.#now = typeof options.now === "function" ? options.now : () => Date.now();
507
+ }
508
+
509
+ // ---------- 只读状态 ----------
510
+
511
+ get state() {
512
+ return this.#state;
513
+ }
514
+
515
+ /** 当前 $events 世代的 clientId(回执必需),未就绪时为 null。 */
516
+ get clientId() {
517
+ return this.#clientId;
518
+ }
519
+
520
+ /** 当前已打开的 follow 会话 id 列表(测试「无 socket 泄漏」用)。 */
521
+ sessions() {
522
+ return [...this.#follows.keys()];
523
+ }
524
+
525
+ /** 当前持有的事件 id 列表(未结算的 waterfall 事件)。 */
526
+ pendingEvents() {
527
+ return [...this.#pending.keys()];
528
+ }
529
+
530
+ // ---------- 生命周期 ----------
531
+
532
+ /** 开始连接(幂等)。就绪请 await `whenReady()` 或监听 `'ready'`。 */
533
+ start() {
534
+ if (this.#closed) throw new Error("dsh-events: subscriber is closed");
535
+ if (this.#state !== "idle") return this;
536
+ this.#connect();
537
+ return this;
538
+ }
539
+
540
+ /** 等下一个(或当前这一代的)ready。 */
541
+ whenReady() {
542
+ if (this.#ready !== null) return Promise.resolve(this.#ready);
543
+ return new Promise((resolve) => {
544
+ this.#readyWaiters.push(resolve);
545
+ });
546
+ }
547
+
548
+ /** 401/403 之后:Cookie 已换新,手动重试(会重新读 cookie,可以是函数)。 */
549
+ retry() {
550
+ if (this.#closed) return this;
551
+ if (this.#reconnectTimer !== null) {
552
+ clearTimeout(this.#reconnectTimer);
553
+ this.#reconnectTimer = null;
554
+ }
555
+ if (this.#state === "auth-failed" || this.#state === "idle") {
556
+ this.#authStatus = null;
557
+ this.#attempt = 0;
558
+ if (this.#ws !== null && this.#ws.readyState === this.#WebSocketImpl.OPEN) return this;
559
+ this.#connect();
560
+ }
561
+ return this;
562
+ }
563
+
564
+ /** 关闭:取消所有流、断开 socket、清定时器。之后不可再 start()。 */
565
+ close() {
566
+ if (this.#closed) return;
567
+ this.#closed = true;
568
+ this.#state = "closed";
569
+ if (this.#reconnectTimer !== null) {
570
+ clearTimeout(this.#reconnectTimer);
571
+ this.#reconnectTimer = null;
572
+ }
573
+ if (this.#discoverTimer !== null) {
574
+ clearTimeout(this.#discoverTimer);
575
+ this.#discoverTimer = null;
576
+ }
577
+ for (const [sessionId, entry] of this.#follows) {
578
+ if (entry.settleTimer !== null) clearTimeout(entry.settleTimer);
579
+ this.emit("follow-closed", {
580
+ sessionId,
581
+ streamId: entry.streamId,
582
+ reason: "closed",
583
+ at: this.#now(),
584
+ });
585
+ }
586
+ this.#follows.clear();
587
+ this.#streams.clear();
588
+ this.#controlStreamId = null;
589
+ const ws = this.#ws;
590
+ this.#ws = null;
591
+ if (ws !== null) {
592
+ try {
593
+ ws.removeAllListeners();
594
+ // ⚠️ 先挂一个吞掉的 error 监听:**握手中的**连接被 close() 时,ws 会**异步** emit
595
+ // 'error'("WebSocket was closed before the connection was established")。
596
+ // 上面刚 removeAllListeners(),于是它成了**未捕获错误** —— 真机表现是「解绑」
597
+ // 这一步偶发把流程打挂(测试里是一条 e2e 偶发红,2026-09-23 定位)。
598
+ ws.on("error", () => { /* 关闭途中的错误不该冒泡到业务 */ });
599
+ if (ws.readyState === this.#WebSocketImpl.CONNECTING) {
600
+ // 还没握手完 → terminate():立刻中止,且不产生上面那个错误
601
+ ws.terminate();
602
+ } else {
603
+ ws.close(1000, "subscriber closed");
604
+ }
605
+ } catch {
606
+ /* 关不掉就等 GC */
607
+ }
608
+ }
609
+ this.emit("closed", {});
610
+ }
611
+
612
+ // ---------- 连接 ----------
613
+
614
+ #log(level, message, meta) {
615
+ if (this.#opts.log === null) return;
616
+ try {
617
+ this.#opts.log(level, message, meta);
618
+ } catch {
619
+ /* 日志失败不得影响业务 */
620
+ }
621
+ }
622
+
623
+ #cookieValue() {
624
+ const raw = this.#opts.cookie;
625
+ try {
626
+ const value = typeof raw === "function" ? raw() : raw;
627
+ return typeof value === "string" ? value.trim() : "";
628
+ } catch (error) {
629
+ this.#log("warn", "cookie getter threw", { error: String(error) });
630
+ return "";
631
+ }
632
+ }
633
+
634
+ #muxUrl() {
635
+ const url = new URL(this.#opts.upstream);
636
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
637
+ url.pathname = this.#opts.muxPath;
638
+ url.search = "";
639
+ url.hash = "";
640
+ return url.href;
641
+ }
642
+
643
+ #connect() {
644
+ if (this.#closed) return;
645
+ this.#state = "connecting";
646
+ let url;
647
+ try {
648
+ url = this.#muxUrl();
649
+ } catch (error) {
650
+ this.#state = "auth-failed";
651
+ this.#emitAuthError(0, `upstream 不是合法 URL: ${String(error)}`, "ws");
652
+ return;
653
+ }
654
+ // Host 显式写成上游 authority(loopback 围栏),Cookie 复用 bridge 已持有的浏览器会话。
655
+ const headers = { Host: new URL(this.#opts.upstream).host };
656
+ const cookie = this.#cookieValue();
657
+ if (cookie !== "") headers.Cookie = cookie;
658
+
659
+ let ws;
660
+ try {
661
+ ws = new this.#WebSocketImpl(url, { headers, perMessageDeflate: false });
662
+ } catch (error) {
663
+ this.#lastSocketError = String(error);
664
+ this.#scheduleReconnect("socket-construct-failed");
665
+ return;
666
+ }
667
+ this.#ws = ws;
668
+ this.#authStatus = null;
669
+
670
+ ws.on("open", () => {
671
+ if (this.#ws !== ws || this.#closed) return;
672
+ this.#state = "ready-pending";
673
+ this.#lastFrameAt = this.#now();
674
+ this.#openStream(EVENTS_ENDPOINT, EVENTS_PAYLOAD, { kind: "events" });
675
+ });
676
+
677
+ ws.on("message", (data, isBinary) => {
678
+ if (this.#ws !== ws) return;
679
+ if (isBinary) return; // mux 只发文本;二进制帧忽略
680
+ this.#onTextFrame(data);
681
+ });
682
+
683
+ ws.on("unexpected-response", (req, res) => {
684
+ this.#authStatus = res.statusCode;
685
+ this.#lastSocketError = `HTTP ${res.statusCode} ${res.statusMessage ?? ""}`.trim();
686
+ try {
687
+ res.resume();
688
+ } catch {
689
+ /* 已消费 */
690
+ }
691
+ try {
692
+ ws.terminate();
693
+ } catch {
694
+ /* 已断 */
695
+ }
696
+ });
697
+
698
+ ws.on("error", (error) => {
699
+ this.#lastSocketError = error?.message ?? String(error);
700
+ });
701
+
702
+ ws.on("close", (code, reason) => {
703
+ if (this.#ws !== ws) return;
704
+ this.#ws = null;
705
+ this.#onSocketClosed(code, reason);
706
+ });
707
+ }
708
+
709
+ #emitFault(code, message, extra = {}) {
710
+ this.emit("fault", {
711
+ kind: NODE_KINDS.FAULT,
712
+ code,
713
+ message,
714
+ expected: extra.expected === true,
715
+ sessionId: extra.sessionId,
716
+ ...(extra.details === undefined ? {} : { details: extra.details }),
717
+ at: this.#now(),
718
+ });
719
+ }
720
+
721
+ #emitAuthError(status, message, surface) {
722
+ this.emit("auth-error", { status, message, surface, at: this.#now() });
723
+ }
724
+
725
+ #onSocketClosed(code, reasonText) {
726
+ const wasReady = this.#ready !== null;
727
+ const from = this.#lastFrameAt !== 0 ? this.#lastFrameAt : this.#now();
728
+
729
+ // 这一代的所有流都随 socket 消失:静默清空(gap 标记负责「漏了什么」的诚实)。
730
+ for (const [sessionId, entry] of this.#follows) {
731
+ if (entry.settleTimer !== null) clearTimeout(entry.settleTimer);
732
+ this.emit("follow-closed", {
733
+ sessionId,
734
+ streamId: entry.streamId,
735
+ reason: "socket-closed",
736
+ at: this.#now(),
737
+ });
738
+ }
739
+ this.#follows.clear();
740
+ this.#streams.clear();
741
+ this.#controlStreamId = null;
742
+ this.#clientId = null;
743
+ this.#ready = null;
744
+ this.#pending.clear();
745
+ this.#followBlocked.clear();
746
+
747
+ if (this.#closed) return;
748
+
749
+ const authStatus = this.#authStatus;
750
+ if (authStatus === 401 || authStatus === 403) {
751
+ this.#state = "auth-failed";
752
+ this.#emitAuthError(
753
+ authStatus,
754
+ `mux 握手被拒(HTTP ${authStatus}):$events 的 WS 路径没有 401 自愈,请换个 Cookie 后调 retry()`,
755
+ "ws",
756
+ );
757
+ return;
758
+ }
759
+
760
+ if (wasReady) {
761
+ this.emit("gap", {
762
+ kind: NODE_KINDS.GAP,
763
+ from,
764
+ to: this.#now(),
765
+ reason: `socket-closed(${code}${reasonText && reasonText.length ? ":" + reasonText.toString() : ""})`,
766
+ at: this.#now(),
767
+ });
768
+ } else {
769
+ this.#emitFault("connect-failed", `mux 连接未能就绪:${this.#lastSocketError ?? `close ${code}`}`);
770
+ }
771
+ this.#scheduleReconnect("socket-closed");
772
+ }
773
+
774
+ #scheduleReconnect(reason) {
775
+ if (this.#closed || this.#reconnectTimer !== null) return;
776
+ const { minMs, maxMs, factor, jitter } = this.#opts.reconnect;
777
+ const base = Math.min(maxMs, minMs * factor ** this.#attempt);
778
+ const delay = Math.max(0, Math.round(base * (1 - jitter + Math.random() * jitter * 2)));
779
+ this.#attempt += 1;
780
+ this.#state = "reconnecting";
781
+ this.#log("info", "mux 重连中", { reason, delay, attempt: this.#attempt });
782
+ const timer = setTimeout(() => {
783
+ this.#reconnectTimer = null;
784
+ this.#connect();
785
+ }, delay);
786
+ timer.unref?.();
787
+ this.#reconnectTimer = timer;
788
+ }
789
+
790
+ #send(frame) {
791
+ const ws = this.#ws;
792
+ if (ws === null || ws.readyState !== this.#WebSocketImpl.OPEN) return false;
793
+ try {
794
+ ws.send(JSON.stringify(frame));
795
+ return true;
796
+ } catch (error) {
797
+ this.#log("warn", "mux 发送失败", { error: String(error) });
798
+ return false;
799
+ }
800
+ }
801
+
802
+ // ---------- mux 流 ----------
803
+
804
+ #openStream(endpoint, payload, meta) {
805
+ const streamId = `s${++this.#seq}`;
806
+ // 自带的 streamId 让「这一帧属于哪条流」在回调里可判(迟到帧比对用)。
807
+ this.#streams.set(streamId, { streamId, endpoint, ...meta });
808
+ this.#send({ type: "open", streamId, endpoint, payload });
809
+ return streamId;
810
+ }
811
+
812
+ #cancelStream(streamId) {
813
+ if (!this.#streams.has(streamId)) return;
814
+ this.#streams.delete(streamId);
815
+ this.#send({ type: "cancel", streamId });
816
+ }
817
+
818
+ #onTextFrame(data) {
819
+ let frame;
820
+ try {
821
+ frame = JSON.parse(typeof data === "string" ? data : data.toString("utf8"));
822
+ } catch {
823
+ this.#log("warn", "mux 帧不是 JSON,已忽略");
824
+ return;
825
+ }
826
+ if (!isRecord(frame) || typeof frame.type !== "string") return;
827
+ this.#lastFrameAt = this.#now();
828
+
829
+ const streamId = typeof frame.streamId === "string" ? frame.streamId : "";
830
+ const stream = this.#streams.get(streamId);
831
+
832
+ if (frame.type === "item") {
833
+ if (stream === undefined) return; // 已取消流的迟到帧
834
+ if (stream.kind === "events") this.#onEventsValue(frame.value);
835
+ else if (stream.kind === "control") this.#onControlValue(frame.value);
836
+ else this.#onFollowValue(stream, frame.value);
837
+ return;
838
+ }
839
+ /**
840
+ * 防御分支:**顶层 waterfall 帧**。
841
+ *
842
+ * 🔴 更正(2026-09-20):这里原先的注释写着「对真实 DSH 实测到的形状」—— **那是错的**,
843
+ * 我当时是被自己写错的假上游误导(假 mux 既发顶层帧、又用了不存在的 streamId),
844
+ * 于是把"我的假上游不对"误判成了"协议是顶层形态"。
845
+ *
846
+ * **真实形状是包在 `item` 里的**(证据,均查过源码):
847
+ * · `dsh-api-gateway/lib/index.js` 的 `pump()`:
848
+ * `for await (const value of source) await this.send({type:"item",streamId,value})`
849
+ * —— **每个**生成器产出都被这层包住。
850
+ * · 生产者在同文件把 `{type:"waterfall",…}` 推进 per-client queue,queue 产出即该 `value`。
851
+ * · DSH **自己的浏览器客户端** `dsh-api-gateway/lib/client.js:729` 也在
852
+ * `value.type === "waterfall"` 上解析 —— 若真机发顶层帧,DSH 自己的 UI 会把每次审批
853
+ * 都丢掉,而审批正是那个 UI 在答的。这是最硬的反证。
854
+ * · `dsh-client-connection/lib/client.js:5599` 的顶层字面量是**测试 fixture**
855
+ * (`approvalInvocation`,reason「fixture 常驻审批」),不是协议。
856
+ *
857
+ * 所以**主路径是 `#onEventsValue` 里的内嵌形态**(见 `value.type === "waterfall"`),
858
+ * 端到端测试 test/wechat-e2e.test.mjs 用的就是真实形状。本分支仅作**未观测形态的兜底**,
859
+ * 万一服务端将来改包裹方式不至于静默丢审批;它由同文件的一条用例覆盖,不是死代码。
860
+ */
861
+ if (frame.type === "waterfall") {
862
+ const node = classifyWaterfall(frame, this.#now());
863
+ if (node === null) return;
864
+ this.#pending.set(node.eventId, node);
865
+ this.emit("event", node);
866
+ return;
867
+ }
868
+ if (frame.type === "error") {
869
+ this.#onStreamError(streamId, stream, frame.error);
870
+ return;
871
+ }
872
+ if (frame.type === "end") {
873
+ this.#onStreamEnd(streamId, stream);
874
+ }
875
+ }
876
+
877
+ // ---------- $events ----------
878
+
879
+ #onEventsValue(value) {
880
+ if (!isRecord(value) || typeof value.type !== "string") return;
881
+
882
+ if (value.type === "ready") {
883
+ if (typeof value.clientId !== "string" || value.clientId === "") {
884
+ // 没有 clientId 就永远回不了执;如实报故障,不假装就绪。
885
+ this.#emitFault("ready-without-client-id", "$events 的 ready 帧没有 clientId,无法回执任何事件");
886
+ return;
887
+ }
888
+ this.#clientId = value.clientId;
889
+ const ready = {
890
+ clientId: value.clientId,
891
+ host: isRecord(value.host) ? value.host : undefined,
892
+ at: this.#now(),
893
+ generation: this.#attempt + 1,
894
+ };
895
+ this.#ready = ready;
896
+ this.#state = "ready";
897
+ this.#attempt = 0;
898
+ for (const resolve of this.#readyWaiters.splice(0)) resolve(ready);
899
+ this.emit("ready", ready);
900
+ this.#openControlStream();
901
+ this.#reconcileFollows();
902
+ // 「接进来时已经在跑」的会话只能靠 session/list 发现(status 是边沿事件,不会补发)。
903
+ void this.discoverRunningSessions().catch(() => {});
904
+ this.#scheduleDiscovery();
905
+ return;
906
+ }
907
+
908
+ if (value.type === "cancel") {
909
+ const eventId = typeof value.eventId === "string" ? value.eventId : "";
910
+ if (eventId === "") return;
911
+ this.#settleEvent(eventId, "cancelled-by-host");
912
+ return;
913
+ }
914
+
915
+ if (value.type === "emit") {
916
+ this.#onEmitFrame(value);
917
+ return;
918
+ }
919
+
920
+ if (value.type === "waterfall") {
921
+ const node = classifyWaterfall(value, this.#now());
922
+ if (node === null) return;
923
+ this.#pending.set(node.eventId, node);
924
+ this.emit("event", node);
925
+ }
926
+ }
927
+
928
+ #onEmitFrame(value) {
929
+ const event = typeof value.event === "string" ? value.event : "";
930
+ const args = Array.isArray(value.args) ? value.args : [];
931
+ this.#onSessionStateEvent(event, args);
932
+ const node = classifyEmit(event, args, this.#now());
933
+ if (node !== null) this.emit("event", node);
934
+ }
935
+
936
+ /**
937
+ * 用 added / removed / status 对账 follow 订阅集(规格 §3「管理订阅集」)。
938
+ * status 是边沿事件,所以 added(带 running)也要参与对账。
939
+ */
940
+ #onSessionStateEvent(event, args) {
941
+ if (event === "api-session/status") {
942
+ const sessionId = args[0];
943
+ if (typeof sessionId !== "string" || sessionId === "") return;
944
+ const running = args[1] === true;
945
+ const entry = this.#follows.get(sessionId);
946
+ if (running) {
947
+ this.#running.add(sessionId);
948
+ this.#runningLiveAt.set(sessionId, this.#now());
949
+ if (entry !== undefined) entry.liveAt = this.#now();
950
+ if (entry !== undefined && entry.settleTimer !== null) {
951
+ clearTimeout(entry.settleTimer);
952
+ entry.settleTimer = null;
953
+ }
954
+ this.#ensureFollow(sessionId);
955
+ } else {
956
+ this.#running.delete(sessionId);
957
+ this.#scheduleFollowClose(sessionId, "idle");
958
+ }
959
+ this.emit("session-state", { sessionId, state: running ? "running" : "idle", at: this.#now() });
960
+ return;
961
+ }
962
+ if (event === "api-session/removed") {
963
+ const sessionId = args[0];
964
+ if (typeof sessionId !== "string" || sessionId === "") return;
965
+ this.#running.delete(sessionId);
966
+ this.#runningLiveAt.delete(sessionId);
967
+ this.#followBlocked.delete(sessionId);
968
+ this.#closeFollow(sessionId, "removed");
969
+ this.emit("session-state", { sessionId, state: "removed", at: this.#now() });
970
+ return;
971
+ }
972
+ // api-session/added:summary 自带 `running`(真机字段:sessionId/updatedAt/running/blank/…)。
973
+ // `api-session/status` 是**边沿**事件 —— 我们接进来之前就已经在跑的会话不会再发 status:true,
974
+ // 所以这里用 added 的 running 兜住「在我们眼皮底下新建且立即开跑」的会话。
975
+ if (event === "api-session/added") {
976
+ const summary = isRecord(args[0]) ? args[0] : null;
977
+ const sessionId = typeof summary?.sessionId === "string" ? summary.sessionId : "";
978
+ if (sessionId === "" || summary?.running !== true) return;
979
+ this.#running.add(sessionId);
980
+ this.#runningLiveAt.set(sessionId, this.#now());
981
+ this.#ensureFollow(sessionId);
982
+ }
983
+ }
984
+
985
+ /**
986
+ * 会话停下后关流。
987
+ * 已经收到过这条会话的 turn/end → 立即关(后面不会再有本轮的帧);
988
+ * 否则等 `followSettleMs` 收尾那条可能还在路上的 turn/end,避免漏报停止原因。
989
+ */
990
+ #scheduleFollowClose(sessionId, reason) {
991
+ const entry = this.#follows.get(sessionId);
992
+ if (entry === undefined) return;
993
+ const settleMs = Math.max(0, this.#opts.followSettleMs);
994
+ if (entry.settleTimer !== null) clearTimeout(entry.settleTimer);
995
+ if (entry.sawTurnEnd) {
996
+ this.#closeFollow(sessionId, "turn-end");
997
+ return;
998
+ }
999
+ if (settleMs === 0) {
1000
+ this.#closeFollow(sessionId, reason);
1001
+ return;
1002
+ }
1003
+ const timer = setTimeout(() => {
1004
+ const current = this.#follows.get(sessionId);
1005
+ if (current !== entry) return;
1006
+ entry.settleTimer = null;
1007
+ this.#closeFollow(sessionId, reason);
1008
+ }, settleMs);
1009
+ timer.unref?.();
1010
+ entry.settleTimer = timer;
1011
+ }
1012
+
1013
+ #reconcileFollows() {
1014
+ if (!this.#opts.follow) return;
1015
+ for (const sessionId of this.#running) this.#ensureFollow(sessionId);
1016
+ }
1017
+
1018
+ // ---------- 会话发现($events 的补洞) ----------
1019
+
1020
+ /**
1021
+ * 开 `session/control`:baseline 给出实时会话集合,其后按会话推 queue/jobs/projection。
1022
+ * 它**没有 running 字段**(baseline 只有 queues/jobs/projections 三个键),所以它负责
1023
+ * 「谁存在 + 谁在活跃」,而「谁在跑」由 `session/list` 的 running 决定。
1024
+ */
1025
+ #openControlStream() {
1026
+ if (!this.#opts.control || this.#controlStreamId !== null) return;
1027
+ this.#controlStreamId = this.#openStream(SESSION_CONTROL_ENDPOINT, EVENTS_PAYLOAD, { kind: "control" });
1028
+ }
1029
+
1030
+ /**
1031
+ * 用一元 `session/list` 发现正在跑的会话,并做一次对账。
1032
+ * 每次连接后调用一次;`discoverIntervalMs > 0` 时还会周期性调用(只在我们手上确实有 follow 流时)。
1033
+ * @returns {Promise<string[]>} 本次发现的 running 会话 id(按服务端顺序)。
1034
+ */
1035
+ async discoverRunningSessions() {
1036
+ if (!this.#opts.discover || this.#closed || this.#state !== "ready") return [];
1037
+ const startedAt = this.#now();
1038
+ let value;
1039
+ try {
1040
+ ({ value } = await this.#rpc(SESSION_LIST_ENDPOINT, { [SESSION_LIST_WIRE_ARG]: {} }));
1041
+ } catch (error) {
1042
+ this.#emitFault("discovery-failed", `session/list 失败,无法发现已在跑的会话:${error?.message ?? error}`, {
1043
+ details: error?.details,
1044
+ });
1045
+ return [];
1046
+ }
1047
+ if (this.#closed) return [];
1048
+ const items = isRecord(value) && Array.isArray(value.items) ? value.items : [];
1049
+ const running = [];
1050
+ const runningIds = new Set();
1051
+ for (const item of items) {
1052
+ if (!isRecord(item) || typeof item.sessionId !== "string" || item.sessionId === "") continue;
1053
+ if (item.running !== true) continue;
1054
+ running.push(item.sessionId);
1055
+ runningIds.add(item.sessionId);
1056
+ }
1057
+
1058
+ // 对账:列表说不在跑、且**没有**比本次列表「不更晚」的实时 running 凭据 → 关流(防泄漏)。
1059
+ // `liveAt` 只在 status:true / added(running) 时更新,避免和「刚开跑」的边沿事件打架。
1060
+ //
1061
+ // 🔴 这里是 `>=` 而不是 `>`:两个时间戳都是 **ms 精度** 的 `Date.now()`。连接时
1062
+ // `#onEventsValue` 在一个同步块里依次做「resolve whenReady」、「开 control」、
1063
+ // 「`discoverRunningSessions()`(= 此刻记 startedAt,随后才发 HTTP)」,
1064
+ // 而上层拿到 ready 后立刻推来的 `status:true` 极可能**落在同一毫秒** —— 于是
1065
+ // `entry.liveAt === startedAt`,写成 `>` 就会把「刚刚才报在跑」的会话当成过期列表的
1066
+ // 牺牲品关掉(真机后果:那一轮的 turn/end 静默丢失,正是本模块最怕的 P0 丢通知)。
1067
+ // 取 `>=` = 「凭据不早于我开始列表」就信凭据;真已停跑的会话会在**下一轮**对账
1068
+ // (startedAt 严格更大)里被关掉,所以不会泄漏 follow 流。
1069
+ for (const sessionId of [...this.#follows.keys()]) {
1070
+ if (runningIds.has(sessionId)) continue;
1071
+ const entry = this.#follows.get(sessionId);
1072
+ if (entry === undefined || entry.liveAt >= startedAt) continue;
1073
+ this.#closeFollow(sessionId, "not-running");
1074
+ }
1075
+
1076
+ for (const sessionId of running) {
1077
+ this.#running.add(sessionId);
1078
+ this.#ensureFollow(sessionId, undefined, { reconcile: true });
1079
+ }
1080
+ this.emit("sessions-discovered", {
1081
+ running,
1082
+ total: items.length,
1083
+ at: this.#now(),
1084
+ });
1085
+ return running;
1086
+ }
1087
+
1088
+ #scheduleDiscovery() {
1089
+ if (this.#discoverTimer !== null) clearTimeout(this.#discoverTimer);
1090
+ const interval = Number(this.#opts.discoverIntervalMs);
1091
+ if (!this.#opts.discover || !Number.isFinite(interval) || interval <= 0) return;
1092
+ const timer = setTimeout(() => {
1093
+ this.#discoverTimer = null;
1094
+ if (this.#closed || this.#state !== "ready") return;
1095
+ // 没有 follow 流就没有可对账的东西;但**每代**至少发现过一次(连接时就做过了)。
1096
+ if (this.#follows.size > 0) void this.discoverRunningSessions().catch(() => {});
1097
+ this.#scheduleDiscovery();
1098
+ }, interval);
1099
+ timer.unref?.();
1100
+ this.#discoverTimer = timer;
1101
+ }
1102
+
1103
+ /**
1104
+ * 显式 follow 一条会话(bridge 想在发现机制之外自己指定时用)。
1105
+ * @param {string} sessionId - 事件/面板给的会话 id(原样发送为主形式)。
1106
+ * @returns {string|null} streamId;未就绪/被跳过时为 null。
1107
+ */
1108
+ followSession(sessionId) {
1109
+ if (typeof sessionId !== "string" || sessionId === "") return null;
1110
+ this.#running.add(sessionId);
1111
+ this.#ensureFollow(sessionId);
1112
+ const entry = this.#follows.get(sessionId);
1113
+ return entry === undefined ? null : entry.streamId;
1114
+ }
1115
+
1116
+ /** `session/control` 观察到的实时会话集合(含未在跑的)。 */
1117
+ controlSessions() {
1118
+ return [...this.#controlSessions];
1119
+ }
1120
+
1121
+ /**
1122
+ * 为一条会话开 follow 流。
1123
+ * @param {string} sessionId - 事件里报的会话 id(作为 map key)。
1124
+ * @param {string[]} [candidates] - 线上候选 id,默认 `sessionIdCandidates(sessionId)`。
1125
+ * `candidates[0]` 是主形式(原样),其余只在 `session/not-found` 时兜底各试一次。
1126
+ */
1127
+ #ensureFollow(sessionId, candidates = sessionIdCandidates(sessionId), options = {}) {
1128
+ if (!this.#opts.follow || this.#closed || this.#state !== "ready") return;
1129
+ if (candidates.length === 0) return;
1130
+ if (this.#follows.has(sessionId)) return;
1131
+ if (this.#followBlocked.has(sessionId)) return; // 本代已失败过,不重试打转
1132
+ if (this.#follows.size >= this.#opts.maxFollowStreams) {
1133
+ this.#emitFault("follow-limit", `follow 流已达上限 ${this.#opts.maxFollowStreams},跳过 ${sessionId}`, {
1134
+ sessionId,
1135
+ });
1136
+ return;
1137
+ }
1138
+ const [wireId, ...alternates] = candidates;
1139
+ // ⚠️ 线上 payload 必须是**恰好一个** `args` 字段(gateway remoteRequest 校验,见
1140
+ // dsh-api-gateway/lib/index.js:929),`request` 是 args 里的具名线参 ——
1141
+ // 规格 §4 给的是 args 内层对象,直接当 payload 发会被服务端
1142
+ // `gateway/internal: Remote payload must contain exactly one plain-object args field` 拒掉(实测)。
1143
+ const streamId = this.#openStream(
1144
+ "session/follow",
1145
+ { args: { request: { address: { kind: "session", sessionId: wireId } } } },
1146
+ { kind: "follow", sessionId },
1147
+ );
1148
+ const entry = {
1149
+ streamId,
1150
+ wireId,
1151
+ alternates,
1152
+ openedAt: this.#now(),
1153
+ sawTurnEnd: false,
1154
+ settleTimer: null,
1155
+ /** 最近一次「实时 running 凭据」的时间(status:true / added(running));0 = 没有。 */
1156
+ liveAt: this.#runningLiveAt.get(sessionId) ?? 0,
1157
+ /** 由 session/list 发现而开的流:其 snapshot 里的尾部 turn/end 可以补报(见 #onFollowValue)。 */
1158
+ reconcile: options.reconcile === true,
1159
+ };
1160
+ this.#follows.set(sessionId, entry);
1161
+ this.emit("follow-opened", { sessionId, streamId, wireId, at: this.#now() });
1162
+ }
1163
+
1164
+ #closeFollow(sessionId, reason) {
1165
+ const entry = this.#follows.get(sessionId);
1166
+ if (entry === undefined) return;
1167
+ if (entry.settleTimer !== null) clearTimeout(entry.settleTimer);
1168
+ this.#follows.delete(sessionId);
1169
+ this.#cancelStream(entry.streamId);
1170
+ this.emit("follow-closed", { sessionId, streamId: entry.streamId, reason, at: this.#now() });
1171
+ }
1172
+
1173
+ // ---------- session/control(实时会话集合 + 活跃度) ----------
1174
+
1175
+ /**
1176
+ * 处理一条 `SessionControlFrame`(键集与实现见 dsh-api-session-controller
1177
+ * `lib/types/types.d.ts:523` + `lib/index.js control():1042`):
1178
+ * - `{type:'baseline', value:{queues,jobs,projections}}`:**没有 sessions 键**,
1179
+ * 会话 id 就是这三个 map 的键(实测三者的键集完全一致)。
1180
+ * - `{type:'queue'|'jobs', sessionId, …}` / `{type:'projection', sessionId, key, value, seq}`:
1181
+ * 按会话推的增量帧 —— 一条会话在写 projection 就说明它活着(在跑),可作为 follow 的触发。
1182
+ */
1183
+ #onControlValue(value) {
1184
+ if (!isRecord(value) || typeof value.type !== "string") return;
1185
+ if (value.type === "baseline") {
1186
+ const block = isRecord(value.value) ? value.value : {};
1187
+ const ids = new Set();
1188
+ for (const key of ["queues", "jobs", "projections"]) {
1189
+ const map = block[key];
1190
+ if (!isRecord(map)) continue;
1191
+ for (const sessionId of Object.keys(map)) if (sessionId !== "") ids.add(sessionId);
1192
+ }
1193
+ this.#controlSessions = ids;
1194
+ this.emit("control-sessions", { sessions: [...ids], at: this.#now() });
1195
+ return;
1196
+ }
1197
+ const sessionId = typeof value.sessionId === "string" ? value.sessionId : "";
1198
+ if (sessionId === "") return;
1199
+ this.#controlSessions.add(sessionId);
1200
+ this.emit("control-activity", {
1201
+ sessionId,
1202
+ type: value.type,
1203
+ key: typeof value.key === "string" ? value.key : undefined,
1204
+ at: this.#now(),
1205
+ });
1206
+ // 活跃 = 在跑;$events 的 status 是边沿事件,这条是电平补充(幂等,已 follow 就直接返回)。
1207
+ this.#ensureFollow(sessionId);
1208
+ }
1209
+
1210
+ // ---------- session/follow ----------
1211
+
1212
+ #onFollowValue(stream, value) {
1213
+ if (!isRecord(value) || typeof value.type !== "string") return;
1214
+ const sessionId = stream.sessionId;
1215
+
1216
+ if (value.type === "snapshot") {
1217
+ const header = isRecord(value.header) ? value.header : {};
1218
+ const records = Array.isArray(value.records) ? value.records : [];
1219
+ // snapshot.cursor = 该会话**最后一条已提交事件**的 seq → `session/page` 的 throughSeq 可用它。
1220
+ this.#rememberCursor(sessionId, value.cursor);
1221
+ this.emit("follow-snapshot", {
1222
+ sessionId,
1223
+ header,
1224
+ cursor: value.cursor,
1225
+ recordCount: records.length,
1226
+ at: this.#now(),
1227
+ });
1228
+ // 一般**不**把 snapshot.records 当通知会被历史重播;唯一例外是「补报」流:
1229
+ // reconcile 流是为「接进来时已经在跑的会话」开的,若它的最后一条记录就是 turn/end,
1230
+ // 说明这一轮在我们挂上之前刚好结束 —— 那正是 §6② 里「会静默丢失」的那条停止通知。
1231
+ // 只在**最后一条**是 turn/end 时才补(会话若还在第二轮里跑,最后一条就不会是 turn/end)。
1232
+ const entry = this.#follows.get(sessionId);
1233
+ const last = records.at(-1);
1234
+ const lastEvent = isRecord(last) && isRecord(last.event) ? last.event : null;
1235
+ if (entry?.reconcile === true && (entry.reconcileUsed ?? false) === false && lastEvent?.type === "turn/end") {
1236
+ entry.reconcileUsed = true;
1237
+ const node = classifyTurnEnd(sessionId, lastEvent, this.#now());
1238
+ if (this.#markTurnEnd(node)) this.emit("event", { ...node, fromSnapshot: true });
1239
+ }
1240
+ return;
1241
+ }
1242
+
1243
+ if (value.type !== "event") return; // assistant-stream 等忽略
1244
+ const event = isRecord(value.event) ? value.event : null;
1245
+ this.#rememberCursor(sessionId, event?.seq); // 每条事件都把 throughSeq 的水位往前推
1246
+ if (event === null || event.type !== "turn/end") return;
1247
+
1248
+ const entry = this.#follows.get(sessionId);
1249
+ if (entry !== undefined && entry.streamId === stream.streamId) entry.sawTurnEnd = true;
1250
+
1251
+ const node = classifyTurnEnd(sessionId, event, this.#now());
1252
+ if (this.#markTurnEnd(node)) this.emit("event", node);
1253
+ if (!this.#running.has(sessionId)) this.#closeFollow(sessionId, "turn-end");
1254
+ }
1255
+
1256
+ /**
1257
+ * 记下一条 turn/end,重复的(同一 sessionId + seq)不重复上报。
1258
+ * snapshot 补报与实时帧可能指向同一条。
1259
+ * @param {object} node - `classifyTurnEnd` 的结果。
1260
+ * @returns {boolean} 是否值得上报。
1261
+ */
1262
+ #markTurnEnd(node) {
1263
+ const key = `${node.sessionId}#${String(node.seq ?? "?")}#${String(node.turn ?? "?")}`;
1264
+ if (this.#reportedTurnEnds.has(key)) return false;
1265
+ this.#reportedTurnEnds.set(key, true);
1266
+ while (this.#reportedTurnEnds.size > SETTLED_LIMIT) {
1267
+ this.#reportedTurnEnds.delete(this.#reportedTurnEnds.keys().next().value);
1268
+ }
1269
+ return true;
1270
+ }
1271
+
1272
+ // ---------- 流级错误/结束 ----------
1273
+
1274
+ #onStreamError(streamId, stream, error) {
1275
+ this.#streams.delete(streamId);
1276
+ const code = isRecord(error) && typeof error.code === "string" ? error.code : "gateway/internal";
1277
+ const message = isRecord(error) && typeof error.message === "string" ? error.message : "unknown stream error";
1278
+ const details = isRecord(error) ? error.details : undefined;
1279
+
1280
+ if (stream === undefined) {
1281
+ this.#emitFault(code, message);
1282
+ return;
1283
+ }
1284
+ if (stream.kind === "follow") {
1285
+ const sessionId = stream.sessionId;
1286
+ const entry = this.#follows.get(sessionId);
1287
+ if (entry !== undefined && entry.settleTimer !== null) clearTimeout(entry.settleTimer);
1288
+ this.#follows.delete(sessionId);
1289
+ // `session/not-found` 且还有候选 id 形式 → 换个形式再试一次(各形式最多一次,不会打转)。
1290
+ // 真机实测:子代理会话的真实 id 是裸 uuid,`session-<uuid>` 会 not-found —— 反过来也可能。
1291
+ if (code === "session/not-found" && entry !== undefined && entry.alternates.length > 0) {
1292
+ this.#emitFault(code, message, { expected: true, sessionId, details });
1293
+ this.emit("follow-closed", { sessionId, streamId, reason: `error:${code}`, at: this.#now() });
1294
+ this.#ensureFollow(sessionId, entry.alternates);
1295
+ return;
1296
+ }
1297
+ // 子代理会话必然被拒(session/agent-busy),这是**预期**的非致命条件;
1298
+ // 会话刚被删的 session/not-found 同理。都只标记本代不再重试,绝不重试打转。
1299
+ const expected = code === "session/agent-busy" || code === "session/not-found";
1300
+ this.#followBlocked.add(sessionId);
1301
+ this.#emitFault(code, message, { expected, sessionId, details });
1302
+ this.emit("follow-closed", { sessionId, streamId, reason: `error:${code}`, at: this.#now() });
1303
+ return;
1304
+ }
1305
+ if (stream.kind === "control") {
1306
+ // control 只是「发现/活跃度」的冗余来源,掉了不致命($events 仍在)。下次重连会重开。
1307
+ this.#controlStreamId = null;
1308
+ this.#emitFault(code, message, { details });
1309
+ return;
1310
+ }
1311
+ // $events 流本身报错 = 观察能力没了:记故障并断开重连(不假装连续)。
1312
+ this.#emitFault(code, message, { details });
1313
+ try {
1314
+ this.#ws?.terminate();
1315
+ } catch {
1316
+ /* 已断 */
1317
+ }
1318
+ }
1319
+
1320
+ #onStreamEnd(streamId, stream) {
1321
+ this.#streams.delete(streamId);
1322
+ if (stream === undefined) return;
1323
+ if (stream.kind === "follow") {
1324
+ const sessionId = stream.sessionId;
1325
+ const entry = this.#follows.get(sessionId);
1326
+ if (entry !== undefined && entry.settleTimer !== null) clearTimeout(entry.settleTimer);
1327
+ this.#follows.delete(sessionId);
1328
+ this.emit("follow-closed", { sessionId, streamId, reason: "end", at: this.#now() });
1329
+ return;
1330
+ }
1331
+ if (stream.kind === "control") {
1332
+ this.#controlStreamId = null;
1333
+ this.#emitFault("control-stream-ended", "session/control 被服务端结束(发现/活跃度冗余失效,$events 仍在)");
1334
+ return;
1335
+ }
1336
+ // $events 正常结束是不正常的:服务端 registerRemoteEvents 消失才会这样。
1337
+ this.#emitFault("events-stream-ended", "$events 流被服务端结束,将重连(其间事件可能已漏)");
1338
+ try {
1339
+ this.#ws?.terminate();
1340
+ } catch {
1341
+ /* 已断 */
1342
+ }
1343
+ }
1344
+
1345
+ // ---------- 回执(HTTP,不是 socket) ----------
1346
+
1347
+ /**
1348
+ * 回执一个事件。**每个 eventId 只发一次**:第二次直接抛 `already-answered`,
1349
+ * 已作废(收到 cancel 帧)的事件抛 `event-expired`。
1350
+ * @param {string} eventId - waterfall 帧上的 eventId。
1351
+ * @param {object} outcome - 线上 outcome:`{kind:'result', value:…}`。
1352
+ * @returns {Promise<{ok:true, eventId:string, rpcId:string, status:number}>}
1353
+ * @throws {EventAnswerError} 服务端拒绝 / 未连接 / 重复回执。
1354
+ */
1355
+ async answer(eventId, outcome) {
1356
+ if (typeof eventId !== "string" || eventId === "") {
1357
+ throw new TypeError("dsh-events: answer(eventId, outcome) requires a non-empty eventId");
1358
+ }
1359
+ if (!isRecord(outcome) || typeof outcome.kind !== "string") {
1360
+ throw new TypeError("dsh-events: outcome must be an object with a kind");
1361
+ }
1362
+ if (this.#expired.has(eventId)) {
1363
+ throw new EventAnswerError("event-expired", "这条请求已经作废(过期或已被取消),不能回执", { eventId });
1364
+ }
1365
+ if (this.#answered.has(eventId)) {
1366
+ throw new EventAnswerError("already-answered", "这条请求已经回执过,不会重复发送", { eventId });
1367
+ }
1368
+ const clientId = this.#clientId;
1369
+ if (typeof clientId !== "string" || clientId === "" || this.#state !== "ready") {
1370
+ throw new EventAnswerError("not-connected", "$events 未就绪(没有 clientId),无法回执", { eventId });
1371
+ }
1372
+
1373
+ // 先记账再发:并发两次回执也只会有一次真正发出。
1374
+ markSettled(this.#answered, eventId);
1375
+ this.#pending.delete(eventId);
1376
+
1377
+ const { rpcId, status } = await this.#rpc(this.#opts.resultEndpoint, { clientId, eventId, outcome }, eventId);
1378
+ return { ok: true, eventId, rpcId, status };
1379
+ }
1380
+
1381
+ /**
1382
+ * 一次一元远端 RPC(HTTP,不是 socket):`POST {upstream}/api/<method>`。
1383
+ * **每次调用都带超时**(`rpcTimeoutMs`):网关卡住时先 abort 掉请求,
1384
+ * 并让这个 promise 以 `code:'timeout'` 结算 —— 通知循环永远不会被一次 HTTP 拖死。
1385
+ * @param {string} method - 逻辑端点名(如 `$events/result` / `session/list`)。
1386
+ * @param {object} args - 线参对象(放进 envelope 的 `payload.args`)。
1387
+ * @param {string} [eventId] - 仅用于错误归属。
1388
+ * @returns {Promise<{value:unknown, rpcId:string, status:number}>} 成功时返回 `result.value`。
1389
+ * @throws {EventAnswerError} 超时 / 网络 / HTTP / `{ok:false,error}` 全部变成类型化失败。
1390
+ */
1391
+ async #rpc(method, args, eventId) {
1392
+ const rpcId = randomUUID();
1393
+ const envelope = { type: "client-request", rpcId, method, payload: { args } };
1394
+ const url = `${this.#opts.upstream}${API_CHANNEL}/${method}`;
1395
+
1396
+ const timeoutMs = Number(this.#opts.rpcTimeoutMs);
1397
+ const budgeted = Number.isFinite(timeoutMs) && timeoutMs > 0;
1398
+ const controller = budgeted ? new AbortController() : null;
1399
+ let timedOut = false;
1400
+ let timer = null;
1401
+ /** 超时哨兵:即使 fetchImpl 不认 signal,也能让下面这个 race 立刻结算。 */
1402
+ const budget = budgeted
1403
+ ? new Promise((_, reject) => {
1404
+ timer = setTimeout(() => {
1405
+ timedOut = true;
1406
+ try {
1407
+ controller.abort();
1408
+ } catch {
1409
+ /* abort 失败无所谓,下面的 reject 才是保证 */
1410
+ }
1411
+ reject(
1412
+ new EventAnswerError("timeout", `${method} 超过 ${timeoutMs}ms 没有返回,已放弃这一次一元调用`, {
1413
+ eventId,
1414
+ rpcId,
1415
+ }),
1416
+ );
1417
+ }, timeoutMs);
1418
+ timer.unref?.();
1419
+ })
1420
+ : null;
1421
+ budget?.catch(() => {}); // 先挂一个处理器:race 提前结算时不留未处理拒绝
1422
+
1423
+ let response;
1424
+ try {
1425
+ const request = this.#fetchImpl(url, {
1426
+ method: "POST",
1427
+ headers: { "content-type": "application/json", ...this.#cookieHeader() },
1428
+ body: JSON.stringify(envelope),
1429
+ ...(controller === null ? {} : { signal: controller.signal }),
1430
+ });
1431
+ response = budget === null ? await request : await Promise.race([request, budget]);
1432
+ } catch (error) {
1433
+ if (timedOut || error instanceof EventAnswerError) {
1434
+ throw error instanceof EventAnswerError
1435
+ ? error
1436
+ : new EventAnswerError("timeout", `${method} 超时(${timeoutMs}ms)`, { eventId, rpcId });
1437
+ }
1438
+ throw new EventAnswerError("network", `${method} 请求失败:${String(error?.message ?? error)}`, {
1439
+ eventId,
1440
+ rpcId,
1441
+ });
1442
+ } finally {
1443
+ if (timer !== null) clearTimeout(timer);
1444
+ }
1445
+
1446
+ const status = Number(response?.status ?? 0);
1447
+ let text = "";
1448
+ try {
1449
+ text = await response.text();
1450
+ } catch {
1451
+ text = "";
1452
+ }
1453
+ let body = null;
1454
+ try {
1455
+ body = text === "" ? null : JSON.parse(text);
1456
+ } catch {
1457
+ body = null;
1458
+ }
1459
+
1460
+ if (status === 401 || status === 403) {
1461
+ this.#emitAuthError(status, `${method} 返回 HTTP ${status},需要换 Cookie`, "http");
1462
+ }
1463
+ if (body === null || !isRecord(body)) {
1464
+ throw new EventAnswerError(`http-${status}`, `${method} 响应不可解析(HTTP ${status}):${text.slice(0, 200)}`, {
1465
+ eventId,
1466
+ rpcId,
1467
+ status,
1468
+ });
1469
+ }
1470
+ if (body.rpcId !== rpcId) {
1471
+ throw new EventAnswerError("rpc-mismatch", `${method} 响应 rpcId 不匹配(HTTP ${status})`, {
1472
+ eventId,
1473
+ rpcId,
1474
+ status,
1475
+ });
1476
+ }
1477
+ const result = isRecord(body.result) ? body.result : null;
1478
+ if (result === null) {
1479
+ throw new EventAnswerError("bad-response", `${method} 响应缺少 result(HTTP ${status})`, {
1480
+ eventId,
1481
+ rpcId,
1482
+ status,
1483
+ });
1484
+ }
1485
+ if (result.ok !== true) {
1486
+ const error = isRecord(result.error) ? result.error : {};
1487
+ throw new EventAnswerError(
1488
+ typeof error.code === "string" ? error.code : "gateway/internal",
1489
+ typeof error.message === "string" ? error.message : `${method} 被服务端拒绝`,
1490
+ { eventId, rpcId, status, details: error.details },
1491
+ );
1492
+ }
1493
+ return { value: result.value, rpcId, status };
1494
+ }
1495
+
1496
+ /**
1497
+ * 一元方法的统一出口:成功 → `project(value)` 的字段;失败 → 类型化失败(**永不抛出**)。
1498
+ * @param {string} method - 逻辑端点名。
1499
+ * @param {object} args - `payload.args`。
1500
+ * @param {(value:unknown)=>(object|null|undefined)} project - 成功投影;返回 null/undefined
1501
+ * = 响应形状不是预期的(→ `bad-response`)。
1502
+ * @returns {Promise<{ok:true, [k:string]:unknown}|{ok:false, code:string, message:string, details?:unknown}>}
1503
+ */
1504
+ async #unary(method, args, project) {
1505
+ let value;
1506
+ try {
1507
+ ({ value } = await this.#rpc(method, args));
1508
+ } catch (error) {
1509
+ return failureOf(error);
1510
+ }
1511
+ let projected;
1512
+ try {
1513
+ projected = project(value);
1514
+ } catch (error) {
1515
+ return typedFailure("bad-response", `${method} 响应无法解析:${messageOf(error)}`);
1516
+ }
1517
+ if (projected === null || projected === undefined || typeof projected !== "object") {
1518
+ return typedFailure("bad-response", `${method} 响应形状不是预期的(缺必需字段)`);
1519
+ }
1520
+ return { ok: true, ...projected };
1521
+ }
1522
+
1523
+ /**
1524
+ * 记下某会话「已知的最大 seq」(只增不减;有界,防长跑内存涨)。
1525
+ * @param {unknown} sessionId
1526
+ * @param {unknown} seq
1527
+ */
1528
+ #rememberCursor(sessionId, seq) {
1529
+ if (!isNonEmptyString(sessionId)) return;
1530
+ if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return;
1531
+ const known = this.#cursors.get(sessionId);
1532
+ if (known !== undefined && known >= seq) return;
1533
+ this.#cursors.set(sessionId, seq);
1534
+ while (this.#cursors.size > CURSOR_LIMIT) this.#cursors.delete(this.#cursors.keys().next().value);
1535
+ }
1536
+
1537
+ /**
1538
+ * `session/page` 必填的 `throughSeq`:取「`session/list` 的 `projections.asOfSeq`」与
1539
+ * 「follow 流见过的最新 seq」中的**较大者**(两者都 ≤ 会话当前 cursor,见模块头)。
1540
+ * 每次都重新拉一次 `session/list`(投影缓存可能落后);它失败时才用缓存兜底。
1541
+ * @param {string} sessionId
1542
+ * @returns {Promise<{ok:true, throughSeq:number}|{ok:false, code:string, message:string, details?:unknown}>}
1543
+ */
1544
+ async #throughSeqFor(sessionId) {
1545
+ const cached = this.#cursors.get(sessionId);
1546
+ let listed;
1547
+ let listFailure = null;
1548
+ try {
1549
+ const { value } = await this.#rpc(SESSION_LIST_ENDPOINT, { [SESSION_LIST_WIRE_ARG]: {} });
1550
+ listed = asOfSeqOf(value, sessionId);
1551
+ if (listed !== undefined) this.#rememberCursor(sessionId, listed);
1552
+ } catch (error) {
1553
+ listFailure = failureOf(error);
1554
+ }
1555
+ const candidates = [cached, listed].filter(
1556
+ (seq) => typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0,
1557
+ );
1558
+ if (candidates.length > 0) return { ok: true, throughSeq: Math.max(...candidates) };
1559
+ // 缓存也没有:把 list 的真实原因(401 / timeout / 会话不存在…)如实带出去,而不是编一个数字。
1560
+ if (listFailure !== null) return listFailure;
1561
+ return typedFailure(
1562
+ "through-seq-unavailable",
1563
+ `会话 ${sessionId} 的 throughSeq 无法确定:session/list 里没有它的 projections.asOfSeq,` +
1564
+ `本地也没有它的 follow cursor。先 listSessions() 或等它出现在 follow 快照里再翻页。`,
1565
+ );
1566
+ }
1567
+
1568
+ /**
1569
+ * 审批回执:`value` ∈ `allowed-once | rejected`。
1570
+ * async:参数校验失败也会变成 rejection(调用方统一 `try { await … } catch`)。
1571
+ */
1572
+ async answerApproval(eventId, value) {
1573
+ if (!APPROVAL_OUTCOMES.includes(value)) {
1574
+ throw new TypeError(
1575
+ `dsh-events: approval outcome must be one of ${APPROVAL_OUTCOMES.join(" | ")},收到 ${JSON.stringify(value)}`,
1576
+ );
1577
+ }
1578
+ return this.answer(eventId, { kind: "result", value });
1579
+ }
1580
+
1581
+ /**
1582
+ * 提问回执。
1583
+ * @param {string} eventId
1584
+ * @param {Array<{id:string, selected:string[], custom?:string}>} answers
1585
+ */
1586
+ async answerQuestion(eventId, answers) {
1587
+ if (!Array.isArray(answers)) throw new TypeError("dsh-events: answers must be an array");
1588
+ const normalized = answers.map((answer) => {
1589
+ if (!isRecord(answer) || typeof answer.id !== "string" || answer.id === "") {
1590
+ throw new TypeError("dsh-events: each answer needs a non-empty id");
1591
+ }
1592
+ if (!Array.isArray(answer.selected)) {
1593
+ throw new TypeError(`dsh-events: answer ${answer.id} needs a selected array`);
1594
+ }
1595
+ const selected = answer.selected.map((label) => {
1596
+ if (typeof label !== "string") throw new TypeError(`dsh-events: answer ${answer.id} labels must be strings`);
1597
+ return label;
1598
+ });
1599
+ return answer.custom === undefined
1600
+ ? { id: answer.id, selected }
1601
+ : { id: answer.id, selected, custom: String(answer.custom) };
1602
+ });
1603
+ return this.answer(eventId, { kind: "result", value: { answers: normalized } });
1604
+ }
1605
+
1606
+ // ---------- 会话控制(一元 RPC;全部返回类型化结果,永不抛出) ----------
1607
+
1608
+ /**
1609
+ * 列出会话(`session/list`,线参名是 **`_request`**)。
1610
+ * @returns {Promise<{ok:true, sessions:Array<{sessionId:string,title:string,running:boolean,
1611
+ * blank:boolean, cwd?:string, updatedAt:number}>}
1612
+ * |{ok:false, code:string, message:string, details?:unknown}>}
1613
+ * `title` 取 `projections.values.title`(可空 → `""`);`cwd` 线上可能缺席。
1614
+ */
1615
+ async listSessions() {
1616
+ const result = await this.#unary(SESSION_LIST_ENDPOINT, { [SESSION_LIST_WIRE_ARG]: {} }, (value) => {
1617
+ const items = isRecord(value) && Array.isArray(value.items) ? value.items : null;
1618
+ if (items === null) return null;
1619
+ const sessions = [];
1620
+ const cursors = [];
1621
+ for (const item of items) {
1622
+ if (!isRecord(item) || !isNonEmptyString(item.sessionId)) continue;
1623
+ const values = isRecord(item.projections) && isRecord(item.projections.values) ? item.projections.values : {};
1624
+ sessions.push({
1625
+ sessionId: item.sessionId,
1626
+ title: typeof values.title === "string" ? values.title : "",
1627
+ running: item.running === true,
1628
+ blank: item.blank === true,
1629
+ ...(typeof item.cwd === "string" ? { cwd: item.cwd } : {}),
1630
+ updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : 0,
1631
+ });
1632
+ const seq = isRecord(item.projections) ? item.projections.asOfSeq : undefined;
1633
+ if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0) cursors.push([item.sessionId, seq]);
1634
+ }
1635
+ return { sessions, cursors };
1636
+ });
1637
+ if (!result.ok) return result;
1638
+ for (const [sessionId, seq] of result.cursors) this.#rememberCursor(sessionId, seq);
1639
+ return { ok: true, sessions: result.sessions };
1640
+ }
1641
+
1642
+ /**
1643
+ * 新建会话(`session/create`)。只转发 `cwd` / `agentPreset`(其余字段由 DSH 默认)。
1644
+ * @param {{cwd?:string, agentPreset?:string}} [options]
1645
+ * @returns {Promise<{ok:true, sessionId:string, agentPreset?:string}|{ok:false, code:string, message:string, details?:unknown}>}
1646
+ */
1647
+ async createSession({ cwd, agentPreset } = {}) {
1648
+ const request = {};
1649
+ if (cwd !== undefined) {
1650
+ if (typeof cwd !== "string" || cwd === "") return invalidArguments("cwd 必须是非空字符串(或省略)");
1651
+ request.cwd = cwd;
1652
+ }
1653
+ if (agentPreset !== undefined) {
1654
+ if (typeof agentPreset !== "string" || agentPreset === "") {
1655
+ return invalidArguments("agentPreset 必须是非空字符串(或省略)");
1656
+ }
1657
+ request.agentPreset = agentPreset;
1658
+ }
1659
+ return this.#unary(SESSION_CREATE_ENDPOINT, { [SESSION_JSON_ARG]: request }, (value) => {
1660
+ if (!isRecord(value) || !isNonEmptyString(value.sessionId)) return null;
1661
+ return {
1662
+ sessionId: value.sessionId,
1663
+ ...(typeof value.agentPreset === "string" ? { agentPreset: value.agentPreset } : {}),
1664
+ };
1665
+ });
1666
+ }
1667
+
1668
+ /**
1669
+ * 给会话发一条文本消息(`session/prompt`)。
1670
+ *
1671
+ * ⚠️ 线上 `request` 的每个字段都是**必填**(`{request:{}}` 会被边界校验拒掉):
1672
+ * `requestId` / `sessionId` / `mode` / `content` 一个都不能少。`requestId` 不给就本地
1673
+ * 用 `randomUUID()` 生成(DSH 自己的客户端也是这么做的),并在结果里回给调用方。
1674
+ *
1675
+ * @param {{sessionId:string, text:string, mode?:"queue"|"steer", requestId?:string,
1676
+ * clientTimeZone?:string}} options
1677
+ * @returns {Promise<{ok:true, accepted:true, requestId:string}|{ok:false, code:string, message:string, details?:unknown}>}
1678
+ * 本地参数不合法 → `{ok:false, code:'invalid-arguments'}`,**不发请求**。
1679
+ */
1680
+ async promptSession({ sessionId, text, mode = "queue", requestId, clientTimeZone } = {}) {
1681
+ if (!isNonEmptyString(sessionId)) return invalidArguments("sessionId 必须是非空字符串");
1682
+ if (typeof text !== "string" || text.trim() === "") return invalidArguments("text 必须是非空字符串");
1683
+ if (mode !== "queue" && mode !== "steer") {
1684
+ return invalidArguments(`mode 只能是 "queue" 或 "steer"(收到 ${JSON.stringify(mode)})`);
1685
+ }
1686
+ if (requestId !== undefined && !isNonEmptyString(requestId)) {
1687
+ return invalidArguments("requestId 给了就必须是非空字符串");
1688
+ }
1689
+ if (clientTimeZone !== undefined && !isNonEmptyString(clientTimeZone)) {
1690
+ return invalidArguments("clientTimeZone 给了就必须是非空字符串(IANA 名或 UTC)");
1691
+ }
1692
+ const request = {
1693
+ // DSH 自己的客户端也用 randomUUID()(dsh-api-session-controller/lib/client.js:1660)。
1694
+ requestId: requestId ?? randomUUID(),
1695
+ sessionId,
1696
+ mode,
1697
+ content: [{ type: "text", text }],
1698
+ ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
1699
+ };
1700
+ const result = await this.#unary(SESSION_PROMPT_ENDPOINT, { [SESSION_JSON_ARG]: request }, (value) => {
1701
+ if (!isRecord(value) || value.accepted !== true) return null;
1702
+ return { accepted: true };
1703
+ });
1704
+ return result.ok ? { ok: true, accepted: true, requestId: request.requestId } : result;
1705
+ }
1706
+
1707
+ /**
1708
+ * 读会话历史的一页(`session/page`)。
1709
+ *
1710
+ * `throughSeq` 由 `#throughSeqFor()` 解析(`session/list` 的 `projections.asOfSeq`
1711
+ * 与本地 follow cursor 取大者);解析不出来就显式失败 `through-seq-unavailable`。
1712
+ *
1713
+ * @param {{sessionId:string, maxMessages?:number}} options
1714
+ * @returns {Promise<{ok:true, records:Array<object>, hasMore:boolean}|{ok:false, code:string, message:string, details?:unknown}>}
1715
+ */
1716
+ async pageSession({ sessionId, maxMessages = 40 } = {}) {
1717
+ if (!isNonEmptyString(sessionId)) return invalidArguments("sessionId 必须是非空字符串");
1718
+ if (!Number.isSafeInteger(maxMessages) || maxMessages <= 0) {
1719
+ return invalidArguments("maxMessages 必须是正整数");
1720
+ }
1721
+ const seq = await this.#throughSeqFor(sessionId);
1722
+ if (!seq.ok) return seq;
1723
+ return this.#unary(
1724
+ SESSION_PAGE_ENDPOINT,
1725
+ {
1726
+ [SESSION_JSON_ARG]: {
1727
+ address: { kind: "session", sessionId },
1728
+ throughSeq: seq.throughSeq,
1729
+ maxMessages,
1730
+ },
1731
+ },
1732
+ (value) => {
1733
+ if (!isRecord(value) || !Array.isArray(value.records)) return null;
1734
+ return { records: value.records, hasMore: value.hasMore === true };
1735
+ },
1736
+ );
1737
+ }
1738
+
1739
+ /**
1740
+ * 会话的**最后一条助手文本**(= 「结论」)。内部就是 `pageSession()` + 文本抽取,
1741
+ * 任何失败都折成 `{ok:false, …}`,**永不抛出**。
1742
+ * @param {{sessionId:string, maxMessages?:number}} options
1743
+ * @returns {Promise<{ok:true, text:string}|{ok:false, code:string, message:string, details?:unknown, text:string}>}
1744
+ */
1745
+ async lastAssistantText({ sessionId, maxMessages = 40 } = {}) {
1746
+ const page = await this.pageSession({ sessionId, maxMessages });
1747
+ if (!page.ok) return { ...page, text: "" };
1748
+ return { ok: true, text: extractLastAssistantText(page.records) };
1749
+ }
1750
+
1751
+ /**
1752
+ * 取消会话当前的 turn(`session/cancel`,`{request:{sessionId}}` → `{accepted:true}`)。
1753
+ * ⚠️ 这是**请求**不是命令:本地状态不在这里改,仍由 `$events` 的 `status` 与 follow 的
1754
+ * `turn/end` 说了算。全 DSH **没有** delete/dispose 方法。
1755
+ * @param {{sessionId:string}} options
1756
+ * @returns {Promise<{ok:true}|{ok:false, code:string, message:string, details?:unknown}>}
1757
+ */
1758
+ async cancelSession({ sessionId } = {}) {
1759
+ if (!isNonEmptyString(sessionId)) return invalidArguments("sessionId 必须是非空字符串");
1760
+ return this.#unary(SESSION_CANCEL_ENDPOINT, { [SESSION_JSON_ARG]: { sessionId } }, (value) => {
1761
+ if (!isRecord(value) || value.accepted !== true) return null;
1762
+ return {};
1763
+ });
1764
+ }
1765
+
1766
+ /**
1767
+ * 改会话标题(`session/rename`,`{request:{sessionId,title}}` → `{title,seq}`)。
1768
+ * `seq` 也顺带更新本地 throughSeq 缓存。
1769
+ * @param {{sessionId:string, title:string}} options
1770
+ * @returns {Promise<{ok:true, title:string, seq?:number}|{ok:false, code:string, message:string, details?:unknown}>}
1771
+ */
1772
+ async renameSession({ sessionId, title } = {}) {
1773
+ if (!isNonEmptyString(sessionId)) return invalidArguments("sessionId 必须是非空字符串");
1774
+ if (typeof title !== "string" || title.trim() === "") return invalidArguments("title 必须是非空字符串");
1775
+ const result = await this.#unary(
1776
+ SESSION_RENAME_ENDPOINT,
1777
+ { [SESSION_JSON_ARG]: { sessionId, title } },
1778
+ (value) => {
1779
+ if (!isRecord(value) || typeof value.title !== "string") return null;
1780
+ return {
1781
+ title: value.title,
1782
+ ...(typeof value.seq === "number" && Number.isSafeInteger(value.seq) && value.seq >= 0
1783
+ ? { seq: value.seq }
1784
+ : {}),
1785
+ };
1786
+ },
1787
+ );
1788
+ if (result.ok && typeof result.seq === "number") this.#rememberCursor(sessionId, result.seq);
1789
+ return result;
1790
+ }
1791
+
1792
+ #cookieHeader() {
1793
+ const cookie = this.#cookieValue();
1794
+ return cookie === "" ? {} : { cookie };
1795
+ }
1796
+
1797
+ /** 事件已了结:从待答集合移除并标记作废(迟到回执会被拒)。 */
1798
+ #settleEvent(eventId, reason) {
1799
+ const node = this.#pending.get(eventId);
1800
+ this.#pending.delete(eventId);
1801
+ markSettled(this.#expired, eventId);
1802
+ this.emit("event", {
1803
+ kind: NODE_KINDS.EVENT_EXPIRED,
1804
+ eventId,
1805
+ node: node?.kind,
1806
+ reason,
1807
+ at: this.#now(),
1808
+ });
1809
+ }
1810
+
1811
+ // ---------- P1 本地钩子(规格 §5 节点 5/6/7:只暴露触发点,不实现调度策略) ----------
1812
+
1813
+ /** 节点 5 每日简报:上层自定时机调用(兼作 24h 推送窗口的心跳)。 */
1814
+ digestDue(detail = {}) {
1815
+ return this.#emitLocal({ kind: NODE_KINDS.DIGEST_DUE, specNode: 5, ...detail });
1816
+ }
1817
+
1818
+ /** 节点 6 额度将尽 / 被限流。 */
1819
+ quotaLow(detail = {}) {
1820
+ const state = detail.state === "throttled" ? "throttled" : "low";
1821
+ return this.#emitLocal({ kind: NODE_KINDS.QUOTA_LOW, specNode: 6, state, ...detail });
1822
+ }
1823
+
1824
+ /** 节点 7 会员即将过期 / 已过期。 */
1825
+ membershipExpiring(detail = {}) {
1826
+ const state = detail.state === "expired" ? "expired" : "expiring";
1827
+ return this.#emitLocal({ kind: NODE_KINDS.MEMBERSHIP_EXPIRING, specNode: 7, state, ...detail });
1828
+ }
1829
+
1830
+ #emitLocal(node) {
1831
+ const withTime = { at: this.#now(), ...node };
1832
+ this.emit("event", withTime);
1833
+ return withTime;
1834
+ }
1835
+ }
1836
+
1837
+ // ============================================================
1838
+ // 分类(纯函数,独立导出便于单测与复用)
1839
+ // ============================================================
1840
+
1841
+ /**
1842
+ * 把一条 `emit` 帧分类成产品节点(不是产品节点的返回 null)。
1843
+ * @param {string} event - 事件名。
1844
+ * @param {unknown[]} args - Cordis 监听参数。
1845
+ * @param {number} at - 本地时间戳(epoch ms)。
1846
+ * @returns {object|null}
1847
+ */
1848
+ export function classifyEmit(event, args, at) {
1849
+ if (event === "api-session/error") {
1850
+ // dsh-api-session-controller/lib/index.js:2759 emit(agent.id, errorChain(error))
1851
+ // :2781 emit(sessionId, result.error.message)
1852
+ const id = typeof args[0] === "string" ? args[0] : "";
1853
+ return {
1854
+ kind: NODE_KINDS.SESSION_ERROR,
1855
+ specNode: 3,
1856
+ sessionId: id,
1857
+ agentId: id,
1858
+ message: messageOf(args[1]),
1859
+ at,
1860
+ };
1861
+ }
1862
+ return null;
1863
+ }
1864
+
1865
+ /**
1866
+ * 把一条 `waterfall` 帧分类成产品节点。
1867
+ * @param {object} frame - `{type:'waterfall',event,eventId,agentId,request}`。
1868
+ * @param {number} at - 本地时间戳(epoch ms)。
1869
+ * @returns {object|null}
1870
+ */
1871
+ export function classifyWaterfall(frame, at) {
1872
+ const event = typeof frame.event === "string" ? frame.event : "";
1873
+ const eventId = typeof frame.eventId === "string" ? frame.eventId : "";
1874
+ const agentId = typeof frame.agentId === "string" ? frame.agentId : "";
1875
+ if (eventId === "") return null;
1876
+ const request = isRecord(frame.request) ? frame.request : {};
1877
+ // DSH 里 agent 身份即会话身份(`ctx.emit("api-session/status", agent.id, …)`),
1878
+ // waterfall 帧只给 agentId,故 sessionId 由它派生。
1879
+ const sessionId = agentId;
1880
+
1881
+ if (event === "approval/request") {
1882
+ // 线上 request = ApprovalRequest 去掉 agent/signal:{toolName, callId?, reason?}
1883
+ return {
1884
+ kind: NODE_KINDS.APPROVAL_REQUEST,
1885
+ specNode: 1,
1886
+ answerShape: "approval",
1887
+ answerable: true,
1888
+ sessionId,
1889
+ agentId,
1890
+ eventId,
1891
+ toolName: typeof request.toolName === "string" ? request.toolName : "",
1892
+ callId: typeof request.callId === "string" ? request.callId : undefined,
1893
+ reason: typeof request.reason === "string" ? request.reason : undefined,
1894
+ options: APPROVAL_CHOICES.map((choice) => ({ ...choice })),
1895
+ at,
1896
+ };
1897
+ }
1898
+
1899
+ if (event === "user-questions/request") {
1900
+ // 线上 request = AskUserQuestionRequestEvent 去掉 agent/signal:{questions:[…]}
1901
+ const questions = Array.isArray(request.questions) ? request.questions : [];
1902
+ const first = questions[0];
1903
+ const intent = isRecord(first) && isRecord(first.intent) ? first.intent : undefined;
1904
+ const isPlanReview = intent?.kind === "plan-review";
1905
+ const options = [];
1906
+ for (const question of questions) {
1907
+ if (!isRecord(question)) continue;
1908
+ const questionId = typeof question.id === "string" ? question.id : "";
1909
+ const labels = Array.isArray(question.options) ? question.options : [];
1910
+ for (const option of labels) {
1911
+ if (!isRecord(option) || typeof option.label !== "string") continue;
1912
+ options.push({
1913
+ questionId,
1914
+ id: questionId,
1915
+ label: option.label,
1916
+ description: typeof option.description === "string" ? option.description : undefined,
1917
+ });
1918
+ }
1919
+ }
1920
+ return {
1921
+ kind: isPlanReview ? NODE_KINDS.PLAN_REVIEW : NODE_KINDS.USER_QUESTION,
1922
+ specNode: 2,
1923
+ answerShape: "question",
1924
+ answerable: true,
1925
+ planReview: isPlanReview,
1926
+ sessionId,
1927
+ agentId,
1928
+ eventId,
1929
+ questions,
1930
+ options,
1931
+ intent: isPlanReview ? { kind: "plan-review", approve: String(intent.approve ?? "") } : undefined,
1932
+ detail: isRecord(first) && typeof first.detail === "string" ? first.detail : undefined,
1933
+ at,
1934
+ };
1935
+ }
1936
+
1937
+ return null;
1938
+ }
1939
+
1940
+ /**
1941
+ * 把一条 follow 流上的 `turn/end` 会话事件分类成产品节点(精确停止原因)。
1942
+ * @param {string} sessionId - 会话 id。
1943
+ * @param {object} event - `{type:'turn/end',seq,time,data:{turn,reason}}`。
1944
+ * @param {number} at - 本地时间戳(epoch ms)。
1945
+ * @returns {object}
1946
+ */
1947
+ export function classifyTurnEnd(sessionId, event, at) {
1948
+ const data = isRecord(event.data) ? event.data : {};
1949
+ const reason = isRecord(data.reason) ? data.reason : {};
1950
+ const kind = typeof reason.kind === "string" ? reason.kind : "unknown";
1951
+ return {
1952
+ kind: NODE_KINDS.TURN_END,
1953
+ specNode: 4,
1954
+ sessionId,
1955
+ turn: typeof data.turn === "number" ? data.turn : undefined,
1956
+ reason: kind,
1957
+ known: TURN_END_REASONS.includes(kind),
1958
+ detail: reason,
1959
+ seq: typeof event.seq === "number" ? event.seq : undefined,
1960
+ at: typeof event.time === "number" && Number.isFinite(event.time) ? event.time : at,
1961
+ };
1962
+ }
1963
+
1964
+ /**
1965
+ * 创建一个订阅器。
1966
+ * @param {{
1967
+ * upstream: string,
1968
+ * cookie?: string|(() => string),
1969
+ * muxPath?: string,
1970
+ * resultPath?: string,
1971
+ * resultEndpoint?: string,
1972
+ * reconnect?: {minMs?:number,maxMs?:number,factor?:number,jitter?:number},
1973
+ * followSettleMs?: number,
1974
+ * maxFollowStreams?: number,
1975
+ * follow?: boolean,
1976
+ * discover?: boolean,
1977
+ * discoverIntervalMs?: number,
1978
+ * control?: boolean,
1979
+ * rpcTimeoutMs?: number,
1980
+ * log?: (level:string, message:string, meta?:object) => void,
1981
+ * now?: () => number,
1982
+ * WebSocketImpl?: any,
1983
+ * fetchImpl?: typeof fetch,
1984
+ * }} options
1985
+ * @returns {EventSubscriber}
1986
+ */
1987
+ export function createEventSubscriber(options) {
1988
+ return new EventSubscriber(options);
1989
+ }
1990
+
1991
+ export { EventSubscriber };