@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.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.
Files changed (111) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +405 -0
  33. package/backend/lib/proc.js +278 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,851 @@
1
+ /**
2
+ * dsh 状态事件中枢(`dshevents.py` 532 行的 Node 对应物,Node 后端 M2 第四块)。
3
+ *
4
+ * 一条 SSE 连接订阅插件的全局状态流(`GET /touchstone-agent/events?scope=state`),把状态帧
5
+ * 折进进程内的实时态注册表,并唤醒订阅者(看板调和器、server 的 SSE 广播等)。
6
+ *
7
+ * **不变量(方案 §3.4;Python 模块头逐条照抄,改代码前必读)**:
8
+ *
9
+ * 1. **断连 = 未知**:链路断开时 `connected()` 为 false,`get()` 一律返回 `null`——绝不把
10
+ * 「读不到」推断成 `idle`。调用方(看板调和器)按「未知则保持现状」处置。归档集同理:
11
+ * 断连时 `archived()`/`archivedSet()` 返回 `null`(未知),看板不得据此搬列。
12
+ * 2. **重连只做一次对齐**:重连带 `since=<last_seq>` 续传环内帧,补发结束后再做**一次**
13
+ * `/live` 快照对齐 + **一次** `/archived` 归档集对齐,此后纯事件驱动(静置期零请求)。
14
+ * 3. **快照可信判定**:非空快照 = 权威整表;空快照只有插件声明 `complete === true` 才算
15
+ * 权威空,否则「保留旧表 + `aligned()===false`」(热重载后驱动实例重建的已知形态,见
16
+ * `doc_ai/bug_report/20261008_1935_*`)。`aligned()` = 在线 ∧ 可信:false ⇒ 一律不据
17
+ * 实况写列;true ⇒ 会话不在注册表才可判「已结束」。
18
+ * 4. **注册表是缓存不是权威**:权威仍是 dsh 会话本身与平台 DB;丢帧/漂移由下一次对齐兜底。
19
+ *
20
+ * ── Python 名 ↔ Node 名(导出面) ─────────────────────────────────────────────────────
21
+ * 方法/函数一律 camelCase;**数据字段与 `stats()` 的键逐字保持 Python 原样**(snake_case),
22
+ * 因为它们是跨语言契约的一部分(注册表条目直接替换 board 的 dsh 状态读口):
23
+ *
24
+ * Python(模块级 / 方法) Node
25
+ * dshevents.EventHub EventHub
26
+ * dshevents.HUB HUB
27
+ * start() start() (同步,返回 boolean)
28
+ * stop(timeout=2.0) stop(timeoutMs=2000) (异步,返回 Promise)
29
+ * connected() / aligned() connected() / aligned()
30
+ * get(sid) get(sid)
31
+ * session_effort(sid) sessionEffort(sid) (同时是 EventHub 方法)
32
+ * session_permission_mode(sid) sessionPermissionMode(sid) (同时是 EventHub 方法)
33
+ * snapshot() snapshot()
34
+ * archived(sid) / archived_set() archived(sid) / archivedSet()
35
+ * stats() stats()
36
+ * wait(timeout) wait(timeoutMs) → Promise<boolean>
37
+ * wait_connected(timeout) waitConnected(timeoutMs) → Promise<boolean>
38
+ * subscribe(cb) / unsubscribe(cb) subscribe(cb) / unsubscribe(cb)
39
+ * _run / _align / _on_frame _run / _align / _onFrame
40
+ * _set_connected / _set_aligned _setConnected / _setAligned
41
+ * _merge_permission(live, old) mergePermission(live, old) (模块私有,不导出)
42
+ * RECONNECT_MIN=1.0 / RECONNECT_MAX=10.0 同名同值(秒)
43
+ * STREAM_IDLE=60.0 同名同值(秒);构造参数 streamIdleMs 用毫秒
44
+ *
45
+ * 注册表条目字段(`get()`/`snapshot()`):`session_id/status/cwd/task/owned/origin/
46
+ * interaction/last_turn_reason/last_seq/usage/model/permission/inbox/updated_at`;
47
+ * `stats()` 键:`connected/sessions/aligned/last_seq/frames/reconnects/changed/archived/thread`。
48
+ *
49
+ * ── 与 Python 的语义差异(逐条,均为形态差异或已注明的实现选择) ──────────────────────
50
+ * ① **异步**:`_run`/`_align`/`stop`/`wait`/`waitConnected` 全返回 Promise(Python 用线程 +
51
+ * 条件变量阻塞);`_onFrame`/`_setConnected`/`_setAligned` 仍是同步方法(SSE 回调里跑)。
52
+ * 调用方必须 `await`;`start()` 保持同步(只读一次 `configured()` 并起后台循环)。
53
+ * ② **等待口 Promise 化**:Python `wait(timeout)` / `wait_connected(timeout)` 阻塞在
54
+ * `threading.Condition` 上;Node 用「等待者集合 + 定时器」:`wait` 在**变更**时 resolve
55
+ * true、超时(或 `stop()` 的唤醒)resolve false——与 Python 的 `_changed != before` 同口径;
56
+ * `waitConnected` 就绪 resolve true、超时 false(迟到连接会立刻唤醒,不等满超时)。
57
+ * ③ **订阅口**:Python `subscribe(cb)` 返回 `cb`、配套 `unsubscribe(cb)`;Node `subscribe(cb)`
58
+ * 返回**退订函数** `() => unsubscribe(cb)`,**同时**导出 `unsubscribe(cb)` 以对齐 Python
59
+ * 调用点(两种写法都可用,见测试「订阅者」一节)。
60
+ * ④ **退避口径=Python 的实际行为(恒为基数 1s,不翻倍)**:Python `_run` 的
61
+ * `finally: backoff = RECONNECT_MIN`(`dshevents.py:240-241`)在 `except` 分支的
62
+ * `continue` 上也会执行 ⇒ 翻倍被立刻复位,**实测每次重连前都是 1.0s**;docstring 的
63
+ * 「1s→10s 封顶」是落空的意图,属 **Python 侧待议缺陷——要改就两侧一起改**,M2 冻结期
64
+ * 不在 Node 单侧「顺手改进」(方案 §十一 红线 3:两版行为必须等价)。故本文件每次重连
65
+ * 尝试前 sleep 基数,`RECONNECT_MAX` 仍导出以对照 Python 常量但**不参与计算**。退避等待
66
+ * 不发请求,且可被 `stop()` 打断(Node 形态差异,不改变间隔口径)。
67
+ * ⑤ **线程语义**:`stats().thread` = 消费循环是否在跑(`_running`);`stop(timeoutMs)` 最多等
68
+ * 这么久(Node 不能阻塞事件循环,故返回 Promise)。`stop()` 会唤醒 `wait`/`waitConnected`/
69
+ * 退避睡眠的等待者(对齐 Python `notify_all()` 的唤醒效果),并**无条件复位运行标记**
70
+ * (对齐 Python `stop()` 末尾的 `self._thread = None`:超时也允许立刻 `start()`)。重启
71
+ * 安全靠**代际号**:旧循环只认自己那一代,既不会再建流(`_run` 在对齐落地后复核 stop,
72
+ * 消除 stop 后的 `connected=true` 抖动),也不会清掉新循环的在线态(旧代不得与新代并行消费)。
73
+ * ⑥ **错误类型**:内部「流结束(对端关闭)」用普通 `Error` 抛出(不额外 import
74
+ * `DshDriverError`;该异常与本文件其余一切异常同路:断连 → 退避 → 重连)。
75
+ * ⑦ **宽容读**:非对象行/非数值字段按缺省跳过或归零(Python 会抛 `AttributeError`/`ValueError`
76
+ * 并因此走重连);正常帧形状下两者逐字一致。
77
+ * ⑧ **集合类型**:Python `set` → Node `Set`;`archivedSet()` 返回副本(修改副本不影响内部)。
78
+ * ⑨ **日志**:`[dshevents] …` 留痕走 `console.log`(Python 是 `print(..., flush=True)`),
79
+ * 文案逐字相同——插件形态下由薄壳落 `<库目录>/plugin-backend.log`。
80
+ *
81
+ * 只 import `dshdriver.js` 的五个冻结导出:`configured` / `liveSnapshot` / `archived` /
82
+ * `stateStream` / `PRESET_MODES`(最后一个承载 `session_permission_mode` 的 preset→三档反查,
83
+ * Python 侧用的就是 `dshdriver.PRESET_MODES`;`driverUrl`/`authHeaders` 由 dshdriver 内部使用,
84
+ * 本文件不直接调)。契约测试见 `backend/test/dshevents.test.mjs`。
85
+ */
86
+ import {
87
+ PRESET_MODES,
88
+ archived as driverArchived,
89
+ configured as driverConfigured,
90
+ liveSnapshot as driverLiveSnapshot,
91
+ stateStream as driverStateStream,
92
+ } from './dshdriver.js';
93
+
94
+ // ---------- 常量(同名同值,单位秒;毫秒口径走构造参数) ----------
95
+
96
+ /**
97
+ * 重连退避基数(秒):每次重连尝试前等待这么久;退避等待不发请求。
98
+ *
99
+ * ⚠️ Python 的 `RECONNECT_MAX` 翻倍逻辑实际被 `_run` 的 `finally` 复位吃掉(见文件头差异 ④),
100
+ * 故**有效间隔恒为该基数**;上游 docstring 的「首次 1s,翻倍到 10s 封顶」是落空的意图。
101
+ */
102
+ export const RECONNECT_MIN = 1.0;
103
+ /** 重连退避上限(秒):与 Python 同名同值导出**仅供对照**,本实现不参与计算(口径见上) */
104
+ export const RECONNECT_MAX = 10.0;
105
+ /** 读空闲上限(秒):插件每 15s 发一次 keepalive 注释帧,这里放宽到 4 倍留抖动余量 */
106
+ export const STREAM_IDLE = 60.0;
107
+
108
+ // ---------- 小工具(逐条对齐 Python 表达式) ----------
109
+
110
+ /** Python `str(x or "")`:假值(null/undefined/""/0/false)一律得空串,其余转字符串 */
111
+ function textOr(value) {
112
+ return value ? String(value) : '';
113
+ }
114
+
115
+ /** Python `int(x)` 的宽容版:可转数值则截断取整,否则 0(Python 抛 ValueError ⇒ 走重连) */
116
+ function pyInt(value) {
117
+ const n = Number(value);
118
+ return Number.isFinite(n) ? Math.trunc(n) : 0;
119
+ }
120
+
121
+ /** `obj` 是普通对象才认;数组/标量/空一律当空对象(对齐 `isinstance(x, dict)`) */
122
+ function objectOr(value) {
123
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
124
+ }
125
+
126
+ /** 自身属性判定:挡下 `toString` 这类原型键(对齐 Python dict 的缺失语义) */
127
+ function hasOwn(obj, key) {
128
+ return obj !== null && typeof obj === 'object'
129
+ && Object.prototype.hasOwnProperty.call(obj, key);
130
+ }
131
+
132
+ /** Python `row.get(key, fallback)`:键存在即取该值(含 null),缺失才回落 fallback */
133
+ function getWithDefault(obj, key, fallback) {
134
+ return hasOwn(obj, key) ? obj[key] : fallback;
135
+ }
136
+
137
+ /** `x or []` 的数组口径:非数组一律空表(正常帧恒为数组) */
138
+ function arrayOr(value) {
139
+ return Array.isArray(value) ? value : [];
140
+ }
141
+
142
+ /** Python 的 `time.time()`(秒,浮点)——`updated_at` 与之同口径 */
143
+ function nowSec() {
144
+ return Date.now() / 1000;
145
+ }
146
+
147
+ /**
148
+ * 权限折叠(`/live` 对齐用,对齐 Python `_merge_permission`):preset 以宿主实况为准,
149
+ * mode 以平台记下的为准。两者缺一都可能(mode 只有平台切过才有、preset 回读可能为空)——
150
+ * 任一有值就保留一项,都空返回 `null`(=未知)。
151
+ */
152
+ function mergePermission(live, old) {
153
+ const row = objectOr(live);
154
+ const prev = objectOr(old);
155
+ const preset = textOr(row.preset || prev.preset || '');
156
+ const mode = textOr(row.mode || prev.mode || '');
157
+ if (!preset && !mode) return null;
158
+ return { mode, preset };
159
+ }
160
+
161
+ /** 宿主 preset → 平台三档反查(对齐 `dshdriver.PRESET_MODES.get(key, "")`,缺省空串=未知) */
162
+ function presetMode(preset) {
163
+ return hasOwn(PRESET_MODES, preset) ? String(PRESET_MODES[preset]) : '';
164
+ }
165
+
166
+ /** 有限 ms 的等待(`stop()` 的超时用;Promise 竞争,超时后不取消原 Promise) */
167
+ function raceTimeout(promise, timeoutMs) {
168
+ const ms = Number(timeoutMs);
169
+ if (!Number.isFinite(ms)) return promise;
170
+ return new Promise((resolve) => {
171
+ const timer = setTimeout(() => resolve(), Math.max(0, ms));
172
+ const done = () => {
173
+ clearTimeout(timer);
174
+ resolve();
175
+ };
176
+ promise.then(done, done);
177
+ });
178
+ }
179
+
180
+ // ---------- 中枢 ----------
181
+
182
+ /**
183
+ * dsh 全局状态流的单连接消费器 + 进程内实时态注册表(对齐 `dshevents.EventHub`)。
184
+ *
185
+ * 构造参数只服务测试与运行期调参(Python 侧靠 monkeypatch 模块常量):
186
+ * `reconnectMinMs` 重连退避基数(缺省 1000;口径=恒基数,见文件头差异 ④)
187
+ * `streamIdleMs` 状态流空闲超时(缺省 60000)
188
+ */
189
+ export class EventHub {
190
+ constructor({
191
+ reconnectMinMs = RECONNECT_MIN * 1000,
192
+ streamIdleMs = STREAM_IDLE * 1000,
193
+ } = {}) {
194
+ this._reconnectMinMs = Math.max(0, Number(reconnectMinMs));
195
+ this._streamIdleMs = Math.max(0, Number(streamIdleMs));
196
+ this._sessions = new Map(); // sid -> 实时态(字段见文件头;插入序=快照序)
197
+ // 宿主归档集(看板「已完成」双向同步):null=未知(断连/未对齐),Set=已知整表。
198
+ this._archived = null;
199
+ this._connected = false;
200
+ // 快照对齐是否**可信**:只有「(重)连时拿到过权威快照」才置 true;
201
+ // 空快照且插件未声明完整时保留旧表但置 false(见 `_align`)。
202
+ this._aligned = false;
203
+ this._lastSeq = 0;
204
+ this._frames = 0;
205
+ this._reconnects = 0;
206
+ this._changed = 0; // 变更计数(测试/诊断用;每次变更 +1)
207
+ this._subscribers = [];
208
+ this._waiters = new Set(); // wait() 的等待者
209
+ this._connWaiters = new Set(); // waitConnected() 的等待者
210
+ this._sleeps = new Set(); // 退避睡眠(可被 stop() 打断)
211
+ this._running = false; // 消费循环是否在跑(stats().thread)
212
+ this._runPromise = null; // 消费循环的 Promise(stop() 等它收尾)
213
+ this._gen = 0; // 循环代际号:每次 start() +1(旧循环只认自己那一代)
214
+ this._stop = false;
215
+ }
216
+
217
+ // ---------- 生命周期 ----------
218
+
219
+ /**
220
+ * 启动消费循环(幂等)。未配置驱动(独立形态)时不起循环,返回 false(对齐 Python `start`)。
221
+ *
222
+ * Node 侧「起后台循环」=挂一个 Promise,故本方法是**同步**的:返回是否真的起了循环。
223
+ * 每次调用发放一个**代际号**:`stop()` 超时返回后若立刻再 `start()`(对齐 Python 的
224
+ * `self._thread = None` 语义),旧循环仍在收尾——它只认自己那一代,不会覆盖新循环状态,
225
+ * 也不会再建流(见 `_run`)。
226
+ */
227
+ start() {
228
+ if (!driverConfigured()) return false;
229
+ if (this._running) return false;
230
+ this._gen += 1;
231
+ const gen = this._gen;
232
+ this._stop = false;
233
+ this._running = true;
234
+ const done = () => {
235
+ // 只清理「自己仍是当前那一代」的状态:stop() 超时后立刻重启的场景里,
236
+ // 旧循环收尾不得把新循环的 `_running`/`_runPromise` 清掉
237
+ if (this._gen === gen) {
238
+ this._running = false;
239
+ this._runPromise = null;
240
+ }
241
+ };
242
+ this._runPromise = this._run(gen).then(done, done);
243
+ return true;
244
+ }
245
+
246
+ /**
247
+ * 停消费循环(退出/测试用):置标志 + 唤醒所有等待者 + 打断退避睡眠,最多等 `timeoutMs`。
248
+ *
249
+ * 与 Python `stop(timeout=2.0)` 同口径(超时后不再等,且**无条件**复位运行标记——
250
+ * Python 是 `self._thread = None`):超时返回后立刻 `start()` 必须可用;若旧循环仍在收尾,
251
+ * 它因代际号不匹配而不会覆盖新循环(见 `start`/`_run`)。Node 不能阻塞事件循环 ⇒ 返回 Promise。
252
+ *
253
+ * @param {number} [timeoutMs]
254
+ * @returns {Promise<void>}
255
+ */
256
+ async stop(timeoutMs = 2000) {
257
+ this._stop = true;
258
+ this._wakeWaiters(); // 对齐 notify_all:wait() 被唤醒但值未变 ⇒ resolve false
259
+ this._wakeConnWaiters();
260
+ this._wakeSleeps(); // Node 增补:退避睡眠可打断(Python 的 join 只能干等)
261
+ const run = this._runPromise;
262
+ if (run !== null) await raceTimeout(run, timeoutMs);
263
+ // 无条件复位(对齐 Python `stop()` 末尾的 `self._thread = None`):**超时也允许立刻重启**。
264
+ this._running = false;
265
+ this._runPromise = null;
266
+ }
267
+
268
+ // ---------- 读口(全部本地、无网络) ----------
269
+
270
+ /** 链路是否在线(false ⇒ 所有状态读口返回 null=未知) */
271
+ connected() {
272
+ return this._connected;
273
+ }
274
+
275
+ /**
276
+ * 注册表是否**可信**(= 链路在线 ∧ 曾用插件权威快照对齐过)。
277
+ *
278
+ * false ⇒ 一律不据实况写列(未知 ≠ 空闲,同「断连=未知」不变量);
279
+ * true ⇒ 会话不在注册表即可判「已结束」(`board._iw_once` 的补口)。
280
+ */
281
+ aligned() {
282
+ return Boolean(this._connected && this._aligned);
283
+ }
284
+
285
+ /**
286
+ * 单会话实时态;未连接、sid 为空或未知会话返回 `null`(=未知,不是空闲)。
287
+ *
288
+ * 返回浅拷贝(对齐 Python `dict(item)`):改顶层字段不影响内部注册表。
289
+ *
290
+ * @param {string} sid
291
+ */
292
+ get(sid) {
293
+ if (!sid) return null;
294
+ if (!this._connected) return null;
295
+ const item = this._sessions.get(String(sid));
296
+ return item ? { ...item } : null;
297
+ }
298
+
299
+ /**
300
+ * 会话当前**思考等级**(宿主 reasoningEffort,取不到返回空串)。
301
+ *
302
+ * 数据源=注册表里 `model` 项(`driver/model` / `driver/attached` 帧折入)。读不到
303
+ * (未连接/未见过该会话)返回 `''`,调用方按「未知」处理,不猜。
304
+ *
305
+ * @param {string} sid
306
+ */
307
+ sessionEffort(sid) {
308
+ const st = this.get(sid) || {};
309
+ const model = st.model || {};
310
+ return textOr(model.reasoningEffort || '');
311
+ }
312
+
313
+ /**
314
+ * 会话当前**权限档**(平台三档 manual/yolo/auto,取不到返回空串)。
315
+ *
316
+ * 平台切过的 `mode` 优先(宿主 preset 到三档是多对一,只有 mode 能区分 yolo/auto);
317
+ * 没有 mode 时按宿主 preset 反查近似档位(外部直跑后接管的会话)。两者皆无返回 `''`。
318
+ *
319
+ * @param {string} sid
320
+ */
321
+ sessionPermissionMode(sid) {
322
+ const st = this.get(sid) || {};
323
+ const perm = st.permission || {};
324
+ const mode = textOr(perm.mode || '');
325
+ if (mode) return mode;
326
+ return presetMode(textOr(perm.preset || ''));
327
+ }
328
+
329
+ /** 全部已知会话的实时态快照(未连接时返回 `{}`;值为浅拷贝) */
330
+ snapshot() {
331
+ if (!this._connected) return {};
332
+ const out = {};
333
+ for (const [sid, item] of this._sessions) {
334
+ // 走 defineProperty 而不是 `out[sid] = …`:sid 是外部字符串,`__proto__` 这类键
335
+ // 直接赋值会改原型而不是加属性(Python dict 无此坑)
336
+ Object.defineProperty(out, sid, {
337
+ value: { ...item }, enumerable: true, writable: true, configurable: true,
338
+ });
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * 会话是否被宿主归档(True/False;**未知返回 `null`**:未连接、尚未对齐、sid 为空)——
345
+ * 调用方按「未知不动作」处置,绝不把读不到当成「未归档」。
346
+ *
347
+ * @param {string} sid
348
+ */
349
+ archived(sid) {
350
+ if (!sid) return null;
351
+ if (this._archived === null) return null;
352
+ return this._archived.has(String(sid));
353
+ }
354
+
355
+ /** 归档集副本(未知返回 `null`);整表口径,供批量对账(如「done 卡绑定会话」扫描) */
356
+ archivedSet() {
357
+ return this._archived === null ? null : new Set(this._archived);
358
+ }
359
+
360
+ /** 诊断信息(`/api` 健康与测试断言用;键与 Python 逐字相同) */
361
+ stats() {
362
+ return {
363
+ connected: this._connected,
364
+ sessions: this._sessions.size,
365
+ aligned: Boolean(this._connected && this._aligned),
366
+ last_seq: this._lastSeq,
367
+ frames: this._frames,
368
+ reconnects: this._reconnects,
369
+ changed: this._changed,
370
+ archived: this._archived === null ? null : this._archived.size,
371
+ thread: this._running,
372
+ };
373
+ }
374
+
375
+ // ---------- 订阅(推变更;回调在消费循环里跑,必须短小) ----------
376
+
377
+ /**
378
+ * 注册变更回调,返回**退订函数**(Node 形态;Python 返回 `callback` 本体)。
379
+ * 配套的 `unsubscribe(cb)` 同时导出,两种写法等价。
380
+ *
381
+ * 回调收到**原始帧**;回调抛异常被吞(不影响消费与其他订阅者)。
382
+ *
383
+ * @param {(frame: object) => void} callback
384
+ * @returns {() => void}
385
+ */
386
+ subscribe(callback) {
387
+ this._subscribers.push(callback);
388
+ return () => this.unsubscribe(callback);
389
+ }
390
+
391
+ /** 退订(重复退订/退订未注册的回调静默——对齐 Python 吞 `ValueError`) */
392
+ unsubscribe(callback) {
393
+ const idx = this._subscribers.indexOf(callback);
394
+ if (idx >= 0) this._subscribers.splice(idx, 1);
395
+ }
396
+
397
+ /**
398
+ * 等一次变更:变更 resolve `true`(事件到达即醒),超时 resolve `false`。
399
+ *
400
+ * 看板调和器的驱动口:醒后读的是**本地注册表**(不是远端状态)——超时只作对账兜底。
401
+ * `timeoutMs` 省略/非有限值=一直等(对齐 Python 的条件变量 `wait(None)`)。
402
+ * `stop()` 的唤醒不算变更 ⇒ resolve `false`(对齐 Python `_changed != before` 口径)。
403
+ *
404
+ * @param {number} [timeoutMs]
405
+ * @returns {Promise<boolean>}
406
+ */
407
+ wait(timeoutMs) {
408
+ const before = this._changed;
409
+ return new Promise((resolve) => {
410
+ const entry = { before, resolve, timer: null };
411
+ const ms = Number(timeoutMs);
412
+ if (Number.isFinite(ms)) {
413
+ entry.timer = setTimeout(() => this._settleWaiter(entry), Math.max(0, ms));
414
+ }
415
+ this._waiters.add(entry);
416
+ });
417
+ }
418
+
419
+ /**
420
+ * 等(首)次连接就绪:就绪 resolve `true`,超时 resolve `false`。
421
+ *
422
+ * 启动补跑用(server 在 `start()` 之后等它就绪,再对账 ext 行)——未配置驱动/宿主不可达时
423
+ * 至多等满 `timeoutMs`;**绝不把「未连接」当成就绪**(不变量 1)。
424
+ *
425
+ * @param {number} timeoutMs
426
+ * @returns {Promise<boolean>}
427
+ */
428
+ waitConnected(timeoutMs) {
429
+ if (this._connected) return Promise.resolve(true);
430
+ const limit = Math.max(0, Number(timeoutMs) || 0);
431
+ const deadline = Date.now() + limit;
432
+ return new Promise((resolve) => {
433
+ const entry = {
434
+ resolve,
435
+ timer: null,
436
+ // 唤醒/到期复核:已连接 ⇒ true;剩余时间为 0 ⇒ false;否则续等剩余时间
437
+ check: () => {
438
+ if (entry.timer !== null) {
439
+ clearTimeout(entry.timer);
440
+ entry.timer = null;
441
+ }
442
+ if (this._connected) {
443
+ this._connWaiters.delete(entry);
444
+ resolve(true);
445
+ return;
446
+ }
447
+ const remain = deadline - Date.now();
448
+ if (remain <= 0) {
449
+ this._connWaiters.delete(entry);
450
+ resolve(false);
451
+ return;
452
+ }
453
+ entry.timer = setTimeout(entry.check, remain);
454
+ },
455
+ };
456
+ this._connWaiters.add(entry);
457
+ entry.check();
458
+ });
459
+ }
460
+
461
+ // ---------- 内部:连接与折叠 ----------
462
+
463
+ /**
464
+ * 消费循环:每次(重)连前做一次对齐(首连的初始快照 + 补断连期间的增删),
465
+ * 然后挂 `since=last_seq` 的状态流;任何异常都走「断连=未知 → 退避 → 重连」。
466
+ *
467
+ * **退避口径=Python 的实际行为(恒为基数)**:每次重连尝试前 sleep 基数,不翻倍——
468
+ * Python `dshevents.py:240-241` 的 `finally: backoff = RECONNECT_MIN` 会把 `except` 分支
469
+ * `continue` 前的翻倍立刻复位,实测每次都是 1.0s(见文件头差异 ④)。`RECONNECT_MAX` 只导出
470
+ * 对照、不参与计算;**要改成真退避必须两侧一起改**。
471
+ *
472
+ * **代际守卫**(`gen`):`stop()` 超时返回后旧循环可能还在收尾(正在等的 `/live` 才返回),
473
+ * 而调用方已 `start()` 了新循环。故本函数内一切「我是否还应继续/是否可改状态」的判据都走
474
+ * `stopped()`/`current()`:旧代不得建流(否则 stop 之后还会出现一次 `connected=true` 抖动)、
475
+ * 不得清掉新循环的在线态。
476
+ */
477
+ async _run(gen = this._gen) {
478
+ const current = () => this._gen === gen; // 自己仍是当前那一代
479
+ const stopped = () => this._stop || !current();
480
+ const backoff = this._reconnectMinMs; // 恒基数:翻倍逻辑被 Python 的 finally 吃掉
481
+ try {
482
+ while (!stopped()) {
483
+ try {
484
+ await this._align();
485
+ // stop(或换代)在对齐期间到达:不再建流——否则 stop 之后还会出现一次
486
+ // connected=true 抖动(实测轨迹:[301,false],[1403,true],[1704,false])
487
+ if (stopped()) break;
488
+ this._setConnected(true);
489
+ await driverStateStream((frame) => this._onFrame(frame), {
490
+ since: this._lastSeq,
491
+ stop: stopped,
492
+ idleTimeoutMs: this._streamIdleMs,
493
+ });
494
+ // 状态流正常返回只可能是「被 stop 主动断开」(sse.js 契约):对端关闭一律以异常回来
495
+ if (stopped()) break;
496
+ throw new Error('状态流结束(对端关闭)');
497
+ } catch {
498
+ if (current()) this._setConnected(false);
499
+ if (stopped()) break;
500
+ await this._sleep(backoff);
501
+ if (stopped()) break;
502
+ }
503
+ }
504
+ } finally {
505
+ // 只有仍是当前那一代才置断连:旧代收尾不得把新循环已建立的链路置回未知
506
+ if (current()) this._setConnected(false);
507
+ }
508
+ }
509
+
510
+ /**
511
+ * 一次性 `/live` + `/archived` 快照对齐:以插件视角覆盖注册表(权威快照)。
512
+ *
513
+ * 只在(重)连时调用——静置期不产生任何请求(两条各一次)。失败语义:
514
+ * - `/live` 取不到 ⇒ 保留旧表 + `_aligned=false` + 留痕(宁可留旧值,不可误判空闲);
515
+ * - `/archived` 取不到 ⇒ 归档集保持「未知」(null),调用方一律不动作;
516
+ * - 空快照且插件未声明 `complete === true` ⇒ 保留旧表 + 不可信(不变量 3)。
517
+ *
518
+ * Node 形态差异:异步(两条读口都是 Promise)。
519
+ */
520
+ async _align() {
521
+ let rows = [];
522
+ let complete = false;
523
+ try {
524
+ // 用 liveSnapshot()(原始 dict)而不是 live()(行表 list)——旧写法把行表当 dict 取
525
+ // `.sessions`,`/live` 非空时必抛 ⇒ 中枢永久未对齐(2026-10-08 修正,Python 同)。
526
+ const resp = (await driverLiveSnapshot()) || {};
527
+ rows = arrayOr(resp.sessions);
528
+ complete = resp.complete === true;
529
+ } catch (error) {
530
+ this._setAligned(false); // 取不到快照=不能确认任何事
531
+ console.log(`[dshevents] /live 对齐失败(${(error && error.message) || error}):`
532
+ + `保留 ${this._sessions.size} 条既有会话、标记未对齐`);
533
+ return;
534
+ }
535
+ let archived = null;
536
+ try {
537
+ archived = new Set(arrayOr(await driverArchived()).filter((s) => s).map((s) => String(s)));
538
+ } catch {
539
+ archived = null; // 归档集缺省=未知(旧插件没这端点也不得推断)
540
+ }
541
+ const now = nowSec();
542
+ const fresh = new Map();
543
+ for (const raw of rows) {
544
+ const row = objectOr(raw);
545
+ const sid = textOr(row.session_id);
546
+ if (!sid) continue;
547
+ const old = this._sessions.get(sid) || {};
548
+ fresh.set(sid, {
549
+ session_id: sid,
550
+ status: textOr(row.status || old.status || ''),
551
+ cwd: row.cwd || old.cwd || '',
552
+ task: row.task || old.task || '',
553
+ owned: Boolean(getWithDefault(row, 'owned', getWithDefault(old, 'owned', false))),
554
+ // origin 是会话固有属性(subagent / 空=未知):快照没带就保留事件里学到的值,
555
+ // 别把已认出的子代理会话在重连对齐时又变成未知
556
+ origin: textOr(row.origin || old.origin || ''),
557
+ // /live 的 interaction 可能为 null(此刻无等待);已挂起的 interaction 若快照里
558
+ // 没带,保留事件里学到的值,避免抖动
559
+ interaction: getWithDefault(row, 'interaction', getWithDefault(old, 'interaction', null)),
560
+ permission: mergePermission(row.permission, old.permission),
561
+ last_turn_reason: getWithDefault(row, 'last_turn_reason',
562
+ getWithDefault(old, 'last_turn_reason', null)),
563
+ last_seq: row.last_seq !== undefined && row.last_seq !== null
564
+ ? pyInt(row.last_seq) : pyInt(old.last_seq || 0),
565
+ usage: old.usage === undefined ? null : old.usage,
566
+ model: old.model === undefined ? null : old.model,
567
+ inbox: old.inbox || [],
568
+ updated_at: now,
569
+ });
570
+ }
571
+ // —— 快照可信判定 ——
572
+ // 非空快照 = 权威整表;空快照只有插件声明完整(complete)才算权威空。其余情况
573
+ // (热重载后驱动实例重建、不重枚举已有会话 ⇒ 空表)=**未知**:旧表原样保留
574
+ // (宁可多等不可误放行),只把可信标志置 false。
575
+ const trusted = fresh.size > 0 || complete;
576
+ if (trusted) this._sessions = fresh;
577
+ const kept = this._sessions.size;
578
+ this._aligned = trusted;
579
+ if (archived !== null) this._archived = archived;
580
+ this._touch();
581
+ if (!trusted && kept) {
582
+ // 留痕:这是可诊断的降级态,不是静默——旧表保留期间看板列不做忙/闲写入,
583
+ // 直到下一次权威快照。
584
+ console.log(`[dshevents] /live 空快照且插件未声明完整:保留 ${kept} 条既有会话、`
585
+ + '标记未对齐(热重载后驱动实例重建的已知形态,见 bug_report/20261008_1935)');
586
+ }
587
+ }
588
+
589
+ /** 置「快照可信」标志(变更才唤醒订阅者,对齐 Python `_set_aligned`) */
590
+ _setAligned(value) {
591
+ const flag = Boolean(value);
592
+ if (this._aligned !== flag) {
593
+ this._aligned = flag;
594
+ this._touch();
595
+ }
596
+ }
597
+
598
+ /** 置「链路在线」标志(变更才唤醒;断连时归档集一并作废为未知,对齐 `_set_connected`) */
599
+ _setConnected(value) {
600
+ const flag = Boolean(value);
601
+ if (this._connected !== flag) {
602
+ this._connected = flag;
603
+ if (flag) {
604
+ this._reconnects += 1;
605
+ } else {
606
+ // 断连=未知:归档集同样作废,绝不让陈旧快照驱动卡片搬列
607
+ this._archived = null;
608
+ }
609
+ this._touch();
610
+ this._wakeConnWaiters();
611
+ }
612
+ }
613
+
614
+ /**
615
+ * 折叠一帧状态帧进注册表并唤醒订阅者(跑在消费循环里)。
616
+ *
617
+ * 帧类型与字段(逐条对齐 Python `_on_frame`;注意 last-wins 与「只在该状态下才改」):
618
+ * `agent/status` status
619
+ * `turn/start` status=running;`event_seq` → 会话 last_seq
620
+ * `turn/end` last_turn_reason;`event_seq` → 会话 last_seq;
621
+ * **仅当 status==='running' 才置 idle**
622
+ * `driver/attached` owned=true(帧是权威标记);cwd/task 非空才覆盖;model 非空才覆盖
623
+ * `driver/model` 整对象覆盖 `data`
624
+ * `driver/permission` `{mode, preset}`(都转字符串)
625
+ * `driver/inbox` 整表覆盖 `items`
626
+ * `driver/detached` owned=false
627
+ * `driver/interaction` `state==='asked'` ⇒ 存 `interaction`,否则置空
628
+ * `usage` 四个 int 字段 input/output/total/cache_read
629
+ * `session/created` cwd + `origin`(缺字段保持旧值/空串=未知,绝不推断)
630
+ * `session/disposed` 删该会话
631
+ * `driver/archived` **无 sid 的全局帧**:整表覆盖归档集(不进会话注册表)
632
+ *
633
+ * @param {object} frame
634
+ */
635
+ _onFrame(frame) {
636
+ const typ = textOr(frame && frame.type);
637
+ const sid = textOr(frame && frame.session_id);
638
+ const data = objectOr(frame && frame.data);
639
+ const seq = frame ? frame.seq : undefined;
640
+ if (typ === 'driver/archived') {
641
+ // 归档集整表快照(无 session_id 的全局帧):整表覆盖,不做增量合并——插件侧每次
642
+ // 变化都发全量,平台按「最后一次」为准,丢帧由重连对齐兜底。
643
+ if (Number.isInteger(seq) && seq > this._lastSeq) this._lastSeq = seq;
644
+ this._frames += 1;
645
+ this._archived = new Set(arrayOr(data.archived).filter((s) => s).map((s) => String(s)));
646
+ this._touch();
647
+ this._callSubscribers(frame);
648
+ return;
649
+ }
650
+ if (Number.isInteger(seq) && seq > this._lastSeq) this._lastSeq = seq;
651
+ this._frames += 1;
652
+ if (sid) {
653
+ let item = this._sessions.get(sid);
654
+ if (item === undefined) {
655
+ item = {
656
+ session_id: sid, status: '', cwd: '', task: '', owned: false, origin: '',
657
+ interaction: null, last_turn_reason: null, last_seq: 0,
658
+ usage: null, permission: null, inbox: [], updated_at: nowSec(),
659
+ };
660
+ this._sessions.set(sid, item);
661
+ }
662
+ item.updated_at = nowSec();
663
+ if (typ === 'agent/status') {
664
+ item.status = textOr(data.status);
665
+ } else if (typ === 'turn/start') {
666
+ // 起轮即忙:agent/status 随后到达也会给 running,两者一致
667
+ item.status = 'running';
668
+ if (Number.isInteger(data.event_seq)) item.last_seq = data.event_seq;
669
+ } else if (typ === 'turn/end') {
670
+ item.last_turn_reason = getWithDefault(data, 'reason', null);
671
+ if (Number.isInteger(data.event_seq)) item.last_seq = data.event_seq;
672
+ // 轮次收口即闲(排队项会再发 turn/start;agent/status 亦会纠正)
673
+ if (item.status === 'running') item.status = 'idle';
674
+ } else if (typ === 'driver/attached') {
675
+ item.owned = true;
676
+ item.cwd = data.cwd || item.cwd;
677
+ item.task = data.task || item.task;
678
+ if (data.model) item.model = data.model;
679
+ } else if (typ === 'driver/model') {
680
+ item.model = data;
681
+ } else if (typ === 'driver/permission') {
682
+ // 会话级权限:{mode: 平台三档, preset: 宿主实况}(会话窗「权限」控件的数据源)
683
+ item.permission = { mode: textOr(data.mode), preset: textOr(data.preset) };
684
+ } else if (typ === 'driver/inbox') {
685
+ // 宿主 inbox 快照:整表覆盖,平台会话窗据此渲染排队行
686
+ item.inbox = arrayOr(data.items).slice();
687
+ } else if (typ === 'driver/detached') {
688
+ item.owned = false;
689
+ } else if (typ === 'driver/interaction') {
690
+ if (textOr(data.state) === 'asked') {
691
+ item.interaction = getWithDefault(data, 'interaction', null);
692
+ } else {
693
+ item.interaction = null;
694
+ }
695
+ } else if (typ === 'usage') {
696
+ // 上下文用量:dsh 没有窗口上限,故只有 used(前端按「无 max 不画环」消费)
697
+ item.usage = {
698
+ input: pyInt(data.input || 0),
699
+ output: pyInt(data.output || 0),
700
+ total: pyInt(data.total || 0),
701
+ cache_read: pyInt(data.cache_read || 0),
702
+ };
703
+ } else if (typ === 'session/created') {
704
+ item.cwd = data.cwd || item.cwd;
705
+ // 子代理会话标记:缺字段保持旧值/空串=未知,绝不推断
706
+ item.origin = textOr(data.origin || item.origin || '');
707
+ } else if (typ === 'session/disposed') {
708
+ this._sessions.delete(sid);
709
+ }
710
+ }
711
+ this._touch();
712
+ this._callSubscribers(frame);
713
+ }
714
+
715
+ // ---------- 内部:唤醒原语 ----------
716
+
717
+ /** 变更计数 +1 并唤醒 `wait()` 的等待者(对齐 `self._changed += 1; notify_all()`) */
718
+ _touch() {
719
+ this._changed += 1;
720
+ this._wakeWaiters();
721
+ }
722
+
723
+ /** 唤醒全部 `wait()` 等待者:按各自入场时的计数判定真假(未被唤醒者等自己的超时) */
724
+ _wakeWaiters() {
725
+ for (const entry of [...this._waiters]) this._settleWaiter(entry);
726
+ }
727
+
728
+ /** 结算一个 `wait()` 等待者(幂等;返回 `_changed != before`,与 Python 同口径) */
729
+ _settleWaiter(entry) {
730
+ if (!this._waiters.delete(entry)) return;
731
+ if (entry.timer !== null) clearTimeout(entry.timer);
732
+ entry.resolve(this._changed !== entry.before);
733
+ }
734
+
735
+ /** 唤醒 `waitConnected()` 等待者(复核连接态/剩余时间) */
736
+ _wakeConnWaiters() {
737
+ for (const entry of [...this._connWaiters]) entry.check();
738
+ }
739
+
740
+ /** 订阅者回调(锁外调用、逐个 try):回调抛异常不影响消费与其他订阅者 */
741
+ _callSubscribers(frame) {
742
+ for (const callback of [...this._subscribers]) {
743
+ try {
744
+ callback(frame);
745
+ } catch {
746
+ // 订阅者异常不影响消费
747
+ }
748
+ }
749
+ }
750
+
751
+ /** 可被 `stop()` 打断的退避睡眠(Python 是 `time.sleep(backoff)`+join 干等) */
752
+ _sleep(ms) {
753
+ return new Promise((resolve) => {
754
+ const entry = { resolve, timer: null };
755
+ entry.timer = setTimeout(() => {
756
+ this._sleeps.delete(entry);
757
+ resolve();
758
+ }, Math.max(0, ms));
759
+ this._sleeps.add(entry);
760
+ });
761
+ }
762
+
763
+ /** 唤醒全部退避睡眠(`stop()` 用:不必等满 backoff 才收尾) */
764
+ _wakeSleeps() {
765
+ for (const entry of [...this._sleeps]) {
766
+ this._sleeps.delete(entry);
767
+ clearTimeout(entry.timer);
768
+ entry.resolve();
769
+ }
770
+ }
771
+ }
772
+
773
+ // 模块级单例:进程内唯一订阅(server 启动时 start(),退出时 stop())
774
+ export const HUB = new EventHub();
775
+
776
+ // ---------- 模块级薄封装(与 Python 同名函数的调用点一一对应) ----------
777
+
778
+ /** 启动全局中枢(幂等;独立形态下返回 false 且不起循环) */
779
+ export function start() {
780
+ return HUB.start();
781
+ }
782
+
783
+ /** 停全局中枢(返回 Promise;最多等 timeoutMs) */
784
+ export function stop(timeoutMs = 2000) {
785
+ return HUB.stop(timeoutMs);
786
+ }
787
+
788
+ /** 链路是否在线 */
789
+ export function connected() {
790
+ return HUB.connected();
791
+ }
792
+
793
+ /** 注册表可信读口(在线 ∧ 曾用权威快照对齐);未对齐 ⇒ 调用方不写列、不推断 */
794
+ export function aligned() {
795
+ return HUB.aligned();
796
+ }
797
+
798
+ /** 单会话实时态(未知 ⇒ null) */
799
+ export function get(sid) {
800
+ return HUB.get(sid);
801
+ }
802
+
803
+ /** 会话当前思考等级(取不到 ⇒ '') */
804
+ export function sessionEffort(sid) {
805
+ return HUB.sessionEffort(sid);
806
+ }
807
+
808
+ /** 会话当前权限档(取不到 ⇒ '') */
809
+ export function sessionPermissionMode(sid) {
810
+ return HUB.sessionPermissionMode(sid);
811
+ }
812
+
813
+ /** 全部已知会话的实时态快照(未连接 ⇒ {}) */
814
+ export function snapshot() {
815
+ return HUB.snapshot();
816
+ }
817
+
818
+ /** 会话是否被宿主归档(未知 ⇒ null) */
819
+ export function archived(sid) {
820
+ return HUB.archived(sid);
821
+ }
822
+
823
+ /** 归档集副本(未知 ⇒ null) */
824
+ export function archivedSet() {
825
+ return HUB.archivedSet();
826
+ }
827
+
828
+ /** 诊断信息 */
829
+ export function stats() {
830
+ return HUB.stats();
831
+ }
832
+
833
+ /** 等一次变更(变更 true / 超时 false) */
834
+ export function wait(timeoutMs) {
835
+ return HUB.wait(timeoutMs);
836
+ }
837
+
838
+ /** 等(首)次连接就绪(就绪 true / 超时 false) */
839
+ export function waitConnected(timeoutMs) {
840
+ return HUB.waitConnected(timeoutMs);
841
+ }
842
+
843
+ /** 注册变更回调,返回退订函数(配套 `unsubscribe` 同时导出) */
844
+ export function subscribe(callback) {
845
+ return HUB.subscribe(callback);
846
+ }
847
+
848
+ /** 退订(与 `subscribe` 返回值等价) */
849
+ export function unsubscribe(callback) {
850
+ return HUB.unsubscribe(callback);
851
+ }