@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,1148 @@
1
+ /**
2
+ * Touchstone 的 dsh 插件进程内 agent 驱动客户端(`dshdriver.py` 771 行的 Node 对应物,M2)。
3
+ *
4
+ * 单族世界(与 Python 侧同):本模块**不拉起任何进程**——dsh 插件
5
+ * (`dsh-plugin/lib/agent-driver.js`,权威契约)跑在 dsh 宿主 Node 进程内,把平台任务映射成
6
+ * 进程内常驻 agent 会话,并把会话事件经 SSE **推**回本进程。连接信息由插件经子进程环境变量
7
+ * 下发:`TS_AGENT_DRIVER_URL` / `TS_AGENT_DRIVER_TOKEN`(独立形态下两者皆空 ⇒ 驱动不可用)。
8
+ *
9
+ * 零 npm 依赖:HTTP 用内建 `fetch`,SSE 消费复用 `backend/lib/sse.js`(**不重复实现**,
10
+ * 该文件是 `_stream_events` 的对应物,含 stop 唤醒/空闲超时/对端关闭三条坑的处置)。
11
+ *
12
+ * ── Python 名 ↔ Node 名(导出面;Node 侧一律 camelCase) ──────────────────────────────
13
+ * URL_ENV / TOKEN_ENV 同名
14
+ * driver_url() / configured() driverUrl() / configured()
15
+ * _headers() authHeaders()
16
+ * _split_url() splitDriverUrl() (三元组 → {host,port,basePath})
17
+ * _request(...) request(...) (timeout 秒 → timeoutMs 毫秒)
18
+ * health / available / live_snapshot health / available / liveSnapshot
19
+ * live / status / media / models live / status / media / models
20
+ * presets / archived presets / archived
21
+ * create_session / resume_session createSession / resumeSession
22
+ * ensure_session / prompt / steer ensureSession / prompt / steer
23
+ * cancel / dispose / compact / fork cancel / dispose / compact / fork
24
+ * rename / set_model / set_permission rename / setModel / setPermission
25
+ * answer_approval / answer_question answerApproval / answerQuestion
26
+ * archive / unarchive / watch_session archive / unarchive / watchSession
27
+ * apply_session_defaults applySessionDefaults
28
+ * split_model splitModel
29
+ * turn_exit_code(reason) turnExitCode(reason)
30
+ * parse_stream_line / parse_sse_* parseStreamLine / parseSseBlock(再导出 sse.js)
31
+ * feed_into / stream / state_stream feedInto / stream / stateStream
32
+ * TurnWaiter.wait_turn(timeout) TurnWaiter.wait(timeoutMs)
33
+ * PERMISSION_PRESETS / PRESET_MODES / EFFORT_LEVELS / ARCHIVE_UNKNOWN_SESSION /
34
+ * WATCH_UNSUPPORTED / TURN_END / INTERACTION / REQUEST_TIMEOUT / HEALTH_TIMEOUT /
35
+ * STREAM_IDLE_TIMEOUT / WATCH_TIMEOUT 全部同名同值(秒级常量保持 Python 数值)
36
+ * _stop_watcher / STOP_WATCH_INTERVAL 由 sse.js 的 STOP_WATCH_INTERVAL_MS 承担(再导出)
37
+ *
38
+ * ── 与 Python 的语义差异(逐条,均为形态差异而非行为差异) ────────────────────────────
39
+ * ① **异步**:Python 同步阻塞,Node 全部返回 Promise ⇒ 调用方必须 `await`(含
40
+ * `applySessionDefaults`:Python 同步顺序下发,Node 顺序 await 同一顺序)。
41
+ * ② **超时口径**:Python 是「秒」位置参数;Node 一律毫秒,走末位 opts 字段 `timeoutMs`
42
+ * (`stream/stateStream` 用 `idleTimeoutMs`,与 sse.js 一致)。秒级常量仍同名导出,
43
+ * 便于逐条对照:`REQUEST_TIMEOUT=120 / HEALTH_TIMEOUT=5 / STREAM_IDLE_TIMEOUT=90 /
44
+ * WATCH_TIMEOUT=2`。
45
+ * ③ **错误**:`DshDriverError` 的 `code` + 中文 `message` 与 Python 一致;Python 读
46
+ * `err.args[0]` 的判据(board 的 `_answer_question_gone` 等)在 Node 侧改读 `err.code`。
47
+ * ④ **`TurnWaiter`**:Python 是 `threading.Event/Condition` 阻塞原语;Node 改为
48
+ * Promise/回调式(`feed()` / `fail()` / `wait(timeoutMs) → Promise<结果>`),
49
+ * **状态字段与返回值口径逐条保留**(含 interaction 的 consume-on-read)。
50
+ * ⑤ **URL 编码**:查询串按 Python `urllib.parse.quote` 口径实现(`quoteBase`/`quoteQuery`)——
51
+ * `/status`·`/presets` 是 `quote(sid)`(`safe='/'`:斜杠不编码),`/media?id=` 是
52
+ * `quote(id, safe="")`(斜杠也编码),且补齐 JS `encodeURIComponent` 不转义的 `!'()*`。
53
+ * 两种写法对端解码后语义相同,这是为了与 Python **逐字节可比**。
54
+ * ⑥ **`request`** 的网络/超时异常都映射 `-2`(Python 的 `URLError/OSError` 同口径),
55
+ * 消息文案按 Node 侧措辞(`驱动不可达: …`);状态码 ≥400 映射该状态码,正文 `error`
56
+ * 字段优先作消息、否则 `驱动返回 HTTP <code>`——与 Python 逐条一致。
57
+ * ⑦ 宽容读:Python 对非 dict 响应取 `.get()` 会 AttributeError,Node 返回空表/空串
58
+ * (`live()` / `archived()` / `liveSnapshot()` 等读口)。**写路径不宽容**:`/session`
59
+ * 回执缺 `session_id` 时 Python 直接下标抛 KeyError(响亮失败),Node 同样抛
60
+ * `DshDriverError(-2, '驱动响应缺少 session_id')`(见 `requireSessionId`)——绝不把
61
+ * `undefined`/空值放给 M4/M5 写库或塞进下一个请求体。
62
+ * ⑧ **请求体字节口径**:`request` 的正文用 `pyJsonDumps`(M1 的 `backend/lib/pyjson.js`,
63
+ * 与 CPython `json.dumps(..., ensure_ascii=False)` 逐字节一致,分隔符 `", "` / `": "`),
64
+ * 而非 `JSON.stringify` 的紧凑形态 ⇒ 两版后端的请求体在对拍里可直接逐字节比。
65
+ * **代价**:`pyJsonDumps` 对 `undefined`/函数/symbol/非普通对象一律抛 TypeError
66
+ * (`JSON.stringify` 会静默丢 `undefined` 键)——这与 Python 侧「无对应物即抛」一致,
67
+ * 属于有意的响亮失败;本模块自建的载荷全部是普通对象 + 已显式设值的字符串/布尔/整数。
68
+ *
69
+ * 红线:令牌不落任何日志;驱动端点仅回环可达(插件侧同时校验来源地址与令牌)。
70
+ */
71
+ import { pyJsonDumps } from './pyjson.js';
72
+ import { SseError, sseUrl, streamEvents } from './sse.js';
73
+
74
+ // SSE 消费本体的再导出:`dshdriver.parse_stream_line` / `streamEvents` 等在原模块里的位置
75
+ export {
76
+ SseError,
77
+ STOP_WATCH_INTERVAL_MS,
78
+ parseSseBlock,
79
+ parseStreamLine,
80
+ sseUrl,
81
+ streamEvents,
82
+ } from './sse.js';
83
+
84
+ // ---------- 常量 ----------
85
+
86
+ /** 由 dsh 插件注入的驱动连接信息(独立形态下两者皆空 → 驱动不可用) */
87
+ export const URL_ENV = 'TS_AGENT_DRIVER_URL';
88
+ export const TOKEN_ENV = 'TS_AGENT_DRIVER_TOKEN';
89
+
90
+ /** 普通请求超时(秒):建/恢复会话要加载持久化日志,给足余量 */
91
+ export const REQUEST_TIMEOUT = 120;
92
+ /** 探活超时(秒):health 必须秒级返回,用于「驱动是否可用」的快速判定 */
93
+ export const HEALTH_TIMEOUT = 5;
94
+ /** SSE 空闲超时(秒):插件每 15s 发一帧 keepalive 注释,超过该值视为链路僵死 */
95
+ export const STREAM_IDLE_TIMEOUT = 90;
96
+ /** 看管声明超时(秒):`/watch` 是驱动侧纯内存写,投递前置闸在 HTTP 线程上同步调它 */
97
+ export const WATCH_TIMEOUT = 2;
98
+ /**
99
+ * stop 唤醒粒度(秒,仅作 Python 名字对照):Python 用它驱动旁路看门线程
100
+ * `_stop_watcher`;Node 侧的等价物是 `sse.js` 的 `STOP_WATCH_INTERVAL_MS`(250ms,已再导出),
101
+ * 本常量不再被任何代码读取。
102
+ */
103
+ export const STOP_WATCH_INTERVAL = 0.25;
104
+
105
+ /** 未配置驱动的统一文案(Python `_request`/`_stream_events` 逐字相同) */
106
+ const UNCONFIGURED_MESSAGE = '未配置 dsh agent 驱动(非插件形态或插件未下发驱动地址)';
107
+
108
+ /** 各类请求的毫秒超时(与 Python 各调用点的秒值一一对应) */
109
+ const HEALTH_TIMEOUT_MS = HEALTH_TIMEOUT * 1000;
110
+ const LIVE_TIMEOUT_MS = 10000; // live_snapshot(timeout=10)
111
+ const STATUS_TIMEOUT_MS = 15000; // status() 的 15
112
+ const SHORT_TIMEOUT_MS = 30000; // media/models/presets/answer/approval/permission/archive
113
+ const DISPOSE_TIMEOUT_MS = 60000; // dispose 的 60
114
+ const MODEL_TIMEOUT_MS = 60000; // set_model 的 60
115
+ const FORK_TIMEOUT_MS = 60000; // fork 的 60
116
+ const WATCH_TIMEOUT_MS = WATCH_TIMEOUT * 1000;
117
+
118
+ // ---------- 错误 ----------
119
+
120
+ /**
121
+ * 驱动/会话调用失败。`code` 为 HTTP 状态码或负值(-1=未配置 / -2=网络/链路异常)。
122
+ *
123
+ * 与 Python `DshDriverError` 的对应:Python 的 `args = (code, message)` 二元组约定,在
124
+ * Node 侧由 `code` + `message` 两个属性承载(既有判据 `err.args[0]` → `err.code`);
125
+ * `String(err)` / `err.message` 一律是**纯中文消息**(不会出现元组串)。
126
+ */
127
+ export class DshDriverError extends Error {
128
+ /**
129
+ * @param {number} code HTTP 状态码,或 -1(未配置)/ -2(网络/链路异常)
130
+ * @param {string} message 中文消息(可直接进日志/给用户)
131
+ */
132
+ constructor(code, message) {
133
+ super(message);
134
+ this.name = 'DshDriverError';
135
+ this.code = code;
136
+ }
137
+
138
+ /**
139
+ * `String(err)` 一律是**纯消息**(不出现 `DshDriverError: ` 前缀)。
140
+ *
141
+ * 对齐 Python 的 `__str__` 覆写:平台侧日志大量是 `f"…:{e}"` 形态,移植到 Node 的模板串
142
+ * `${err}` 会走 `toString()`;不覆写就会多出一截类名,日志口径与 Python 不一致。
143
+ */
144
+ toString() {
145
+ return String(this.message);
146
+ }
147
+ }
148
+
149
+ // ---------- 连接与配置 ----------
150
+
151
+ /** 驱动前缀地址(未配置返回空串)。对齐 `driver_url()`:trim + 去尾部 `/` */
152
+ export function driverUrl() {
153
+ return String(process.env[URL_ENV] || '').trim().replace(/\/+$/, '');
154
+ }
155
+
156
+ /** 本进程是否运行在 dsh 插件形态下(插件已下发驱动地址)。对齐 `configured()` */
157
+ export function configured() {
158
+ return Boolean(driverUrl());
159
+ }
160
+
161
+ /**
162
+ * 统一请求头(对齐 `_headers()`):驱动令牌走自定义头(不回显、不落日志)。
163
+ * 每次调用现取环境变量(与 Python 同口径,测试可运行中改 env)。
164
+ */
165
+ export function authHeaders() {
166
+ const token = process.env[TOKEN_ENV] || '';
167
+ const head = {
168
+ 'content-type': 'application/json; charset=utf-8',
169
+ accept: 'application/json',
170
+ };
171
+ if (token) head['x-ts-driver-token'] = token;
172
+ return head;
173
+ }
174
+
175
+ /**
176
+ * 把驱动地址拆成 `{host, port, basePath}`(对齐 `_split_url()`;Python 返回三元组)。
177
+ *
178
+ * 只接受 `http:`(驱动在宿主进程内、仅回环,无 TLS);未配置/协议不符/无 host 一律抛
179
+ * `DshDriverError(-1, '驱动地址不可用: …')`。
180
+ *
181
+ * @returns {{host: string, port: number, basePath: string}}
182
+ */
183
+ export function splitDriverUrl() {
184
+ const raw = driverUrl();
185
+ let parsed = null;
186
+ try {
187
+ parsed = new URL(raw);
188
+ } catch {
189
+ parsed = null;
190
+ }
191
+ if (parsed === null || parsed.protocol !== 'http:' || !parsed.hostname) {
192
+ throw new DshDriverError(-1, `驱动地址不可用: ${raw || '(空)'}`);
193
+ }
194
+ return {
195
+ host: parsed.hostname,
196
+ port: parsed.port ? Number(parsed.port) : 80,
197
+ basePath: parsed.pathname.replace(/\/+$/, ''),
198
+ };
199
+ }
200
+
201
+ /** 毫秒 → 人类可读(日志/错误文案用):整秒显示 `120s`,否则 `150ms` */
202
+ function msLabel(timeoutMs) {
203
+ const ms = Number(timeoutMs);
204
+ if (!Number.isFinite(ms)) return '∞';
205
+ return ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
206
+ }
207
+
208
+ /** `since` 归一成整数(对齐 Python 的 `int(since)`;坏值按 0) */
209
+ function toSeq(since) {
210
+ const n = Number(since);
211
+ return Number.isFinite(n) ? Math.trunc(n) : 0;
212
+ }
213
+
214
+ /** 从错误响应正文里取 `{"error": "…"}`(取不到返回空串,走通用文案) */
215
+ function httpErrorDetail(raw) {
216
+ try {
217
+ const parsed = JSON.parse(raw);
218
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.error === 'string') {
219
+ return parsed.error;
220
+ }
221
+ } catch {
222
+ // 非 JSON 正文:detail 为空,调用方用「驱动返回 HTTP <code>」
223
+ }
224
+ return '';
225
+ }
226
+
227
+ /** 传输层失败 → `DshDriverError(-2, …)`;`aborted` 表示是我们自己的超时定时器掐的 */
228
+ function transportError(error, aborted, timeoutMs) {
229
+ if (aborted) {
230
+ return new DshDriverError(-2, `驱动不可达: 请求超时(>${msLabel(timeoutMs)} 无响应)`);
231
+ }
232
+ const detail = error && error.message ? error.message : String(error);
233
+ return new DshDriverError(-2, `驱动不可达: ${detail}`);
234
+ }
235
+
236
+ // ---------- HTTP 客户端 ----------
237
+
238
+ /**
239
+ * 向驱动端点发一次 JSON 请求,返回解析后的值(失败抛 `DshDriverError`)。
240
+ *
241
+ * 与 Python `_request` 逐条对齐:
242
+ * - 未配置 ⇒ `-1`;网络失败/超时 ⇒ `-2`;HTTP ≥400 ⇒ 该状态码,正文 `error` 字段优先;
243
+ * - 正文空 ⇒ `{}`;正文非 JSON ⇒ `-2 驱动响应不是 JSON: <前 200 字>`;
244
+ * - 请求体走 `pyJsonDumps`(对应 `json.dumps(..., ensure_ascii=False)`,**与 CPython 逐字节
245
+ * 一致**:分隔符 `", "` / `": "`、非 ASCII 原样;不用 `JSON.stringify` 的紧凑形态)。
246
+ *
247
+ * @param {string} method HTTP 方法
248
+ * @param {string} path 以 `/` 开头的端点路径(查询串调用方拼好)
249
+ * @param {object|null} [payload] 请求体;null/undefined 表示无体(GET 用)
250
+ * @param {number} [timeoutMs] 超时(毫秒)
251
+ * @param {typeof fetch} [fetchImpl] 注入用(测试/特殊运行时)
252
+ * @returns {Promise<any>}
253
+ */
254
+ export async function request(method, path, payload = null, timeoutMs = REQUEST_TIMEOUT * 1000,
255
+ fetchImpl = fetch) {
256
+ if (!configured()) throw new DshDriverError(-1, UNCONFIGURED_MESSAGE);
257
+ const init = { method, headers: authHeaders() };
258
+ if (payload !== null && payload !== undefined) init.body = pyJsonDumps(payload);
259
+ const controller = new AbortController();
260
+ let aborted = false;
261
+ const timer = setTimeout(() => {
262
+ aborted = true;
263
+ controller.abort();
264
+ }, timeoutMs);
265
+ init.signal = controller.signal;
266
+ try {
267
+ let response;
268
+ try {
269
+ response = await fetchImpl(driverUrl() + path, init);
270
+ } catch (error) {
271
+ throw transportError(error, aborted, timeoutMs);
272
+ }
273
+ if (response.status >= 400) {
274
+ let raw = '';
275
+ try {
276
+ raw = await response.text();
277
+ } catch {
278
+ raw = ''; // 错误响应正文读不到:退通用文案(对齐 Python 的 detail='')
279
+ }
280
+ const detail = raw ? httpErrorDetail(raw) : '';
281
+ throw new DshDriverError(response.status, detail || `驱动返回 HTTP ${response.status}`);
282
+ }
283
+ let raw;
284
+ try {
285
+ raw = await response.text();
286
+ } catch (error) {
287
+ throw transportError(error, aborted, timeoutMs);
288
+ }
289
+ if (raw.trim() === '') return {};
290
+ try {
291
+ return JSON.parse(raw);
292
+ } catch {
293
+ throw new DshDriverError(-2, `驱动响应不是 JSON: ${raw.slice(0, 200)}`);
294
+ }
295
+ } finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ // ---------- 探活与状态 ----------
301
+
302
+ /**
303
+ * 探活:返回 `{ok, live, ...}`;驱动未配置/不可达/未就绪一律抛错(调用方按需捕获)。
304
+ * @param {{timeoutMs?: number}} [opts]
305
+ */
306
+ export async function health({ timeoutMs = HEALTH_TIMEOUT_MS } = {}) {
307
+ return request('GET', '/health', null, timeoutMs);
308
+ }
309
+
310
+ /**
311
+ * 驱动是否可用且 agents 服务已就绪(不抛异常的布尔口径,供族分派与 UI 判定)。
312
+ * @param {{timeoutMs?: number}} [opts]
313
+ */
314
+ export async function available({ timeoutMs = HEALTH_TIMEOUT_MS } = {}) {
315
+ if (!configured()) return false;
316
+ try {
317
+ const resp = await health({ timeoutMs });
318
+ return Boolean(resp && resp.ok);
319
+ } catch (error) {
320
+ if (error instanceof DshDriverError) return false;
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * `/live` 的**原始响应**:`{sessions: [...], complete?: bool}`。
327
+ *
328
+ * `complete`(A 批 2026-10-08 起真插件会上报)=插件是否已枚举宿主已有会话:只有它为 true,
329
+ * **空表**才具备「宿主里确实没有会话」的语义(见 dshevents 的 `_align`)。旧装机副本没有该
330
+ * 字段 ⇒ 缺省按未声明处理(空表=未知)。
331
+ *
332
+ * 单列一个读口的原因(Python 侧同):`live()` 返回**行表**,而中枢对齐需要「行表 + 完整性
333
+ * 声明」两件东西;把行表当 dict 取 `.sessions` 会让中枢永久「未对齐」。
334
+ *
335
+ * @param {{timeoutMs?: number}} [opts]
336
+ */
337
+ export async function liveSnapshot({ timeoutMs = LIVE_TIMEOUT_MS } = {}) {
338
+ return (await request('GET', '/live', null, timeoutMs)) || {};
339
+ }
340
+
341
+ /**
342
+ * 当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。
343
+ * 返回**行表数组**(历史契约);需要完整性声明用 `liveSnapshot()`。
344
+ *
345
+ * @param {{timeoutMs?: number}} [opts]
346
+ * @returns {Promise<object[]>}
347
+ */
348
+ export async function live(opts = {}) {
349
+ const resp = await liveSnapshot(opts);
350
+ const rows = resp !== null && typeof resp === 'object' ? resp.sessions : undefined;
351
+ return Array.isArray(rows) ? rows.slice() : [];
352
+ }
353
+
354
+ /**
355
+ * Python `urllib.parse.quote()` 的公共部分(`encodeURIComponent` + 补转义 `!'()*`)。
356
+ *
357
+ * 两处细节:Python 的 quote 会转义 `!'()*`(JS 的 `encodeURIComponent` 不转义);Python 的
358
+ * 「永不转义集」与 JS 相同(字母数字 + `_.-~`),故这一层等价。
359
+ *
360
+ * @param {unknown} value
361
+ * @returns {string}
362
+ */
363
+ function quoteBase(value) {
364
+ return encodeURIComponent(String(value ?? ''))
365
+ .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
366
+ }
367
+
368
+ /** Python `quote(v)`(默认 `safe='/'`:**斜杠不编码**)——`/status`·`/presets` 的查询串口径 */
369
+ function quoteQuery(value) {
370
+ return quoteBase(value).replace(/%2F/g, '/');
371
+ }
372
+
373
+ /**
374
+ * 单会话状态:`{status, last_seq, last_turn_reason, interaction, cwd, task, …}`。
375
+ *
376
+ * 查询串编码对齐 Python `quote(session_id)`(`safe='/'`)⇒ 与 Python 侧逐字节同形
377
+ * (`/` 保持原样,`!'()*` 转义)。
378
+ *
379
+ * @param {string} sessionId
380
+ */
381
+ export async function status(sessionId) {
382
+ return request('GET', `/status?session_id=${quoteQuery(sessionId)}`, null, STATUS_TIMEOUT_MS);
383
+ }
384
+
385
+ /**
386
+ * 读宿主图片附件字节(`GET /media?id=`;插件走 `attachments.imageHostPath`)。
387
+ * 返回 `{content_type, bytes, data(base64)}`;附件不存在/引用非法 ⇒ `DshDriverError(404)`。
388
+ *
389
+ * 编码对齐 Python `quote(str(attachment_id), safe="")`:斜杠也编码(走 `quoteBase`)。
390
+ *
391
+ * @param {string} attachmentId
392
+ */
393
+ export async function media(attachmentId) {
394
+ return request('GET', `/media?id=${quoteBase(attachmentId)}`, null, SHORT_TIMEOUT_MS);
395
+ }
396
+
397
+ /**
398
+ * 读宿主模型目录(`sessionController.modelCatalog`,插件缓存 60s)。
399
+ * 返回 `{default:{provider,model}, groups:[…], routable_providers[], failures[]}`。
400
+ */
401
+ export async function models() {
402
+ return request('GET', '/models', null, SHORT_TIMEOUT_MS);
403
+ }
404
+
405
+ /**
406
+ * 读会话可选权限 preset:`{current, options:[{value,name,description}], default}`。
407
+ * 查询串编码同 `status`(Python `quote(str(session_id))`,`safe='/'`)。
408
+ *
409
+ * @param {string} sessionId
410
+ */
411
+ export async function presets(sessionId) {
412
+ return request('GET', `/presets?session_id=${quoteQuery(sessionId)}`, null, SHORT_TIMEOUT_MS);
413
+ }
414
+
415
+ /**
416
+ * 读宿主归档集整表(`ctx.workspaceRegistry.archivedSessionIds`)。
417
+ * 返回会话 id 数组(宿主无归档 → 空数组);驱动未配置/不可达抛 `DshDriverError`。
418
+ *
419
+ * @param {{timeoutMs?: number}} [opts]
420
+ * @returns {Promise<string[]>}
421
+ */
422
+ export async function archived({ timeoutMs = HEALTH_TIMEOUT_MS } = {}) {
423
+ const resp = await request('GET', '/archived', null, timeoutMs);
424
+ const rows = resp !== null && typeof resp === 'object' ? resp.archived : undefined;
425
+ return Array.isArray(rows) ? rows.slice() : [];
426
+ }
427
+
428
+ // ---------- 会话生命周期 ----------
429
+
430
+ /**
431
+ * 把平台侧模型值拆成 `[provider, model]`,供 `/session` 下传。
432
+ *
433
+ * 平台侧模型值统一是 `provider/模型 id`,而宿主只有 `/model` 端点会拆 `provider/model`——
434
+ * `/session`(建/恢复会话)不拆,provider 前缀会被当成模型名的一部分。故走 `/session` 的
435
+ * 两处调用点(runner 首轮建会话、board 卡片起会话)必须先经本函数拆开。
436
+ *
437
+ * 无前缀的存量值/裸模型名(如 `deepseek-flash`)返回 `provider=''`;空串原样返回
438
+ * `['', '']`。对齐 `split_model`(Python 的 `partition('/')`)。
439
+ *
440
+ * @param {string} model
441
+ * @returns {[string, string]}
442
+ */
443
+ export function splitModel(model) {
444
+ const s = String(model ?? '').trim();
445
+ const idx = s.indexOf('/');
446
+ if (idx > 0 && idx < s.length - 1) return [s.slice(0, idx), s.slice(idx + 1)];
447
+ return ['', s];
448
+ }
449
+
450
+ /**
451
+ * 校验 `/session`(建/恢复会话)回执并取出 session_id——**写路径不宽容**(评审 P7)。
452
+ *
453
+ * 为什么不能像读口那样宽容:Python 是 `_request(...)["session_id"]` **直接下标**,键缺失即抛
454
+ * KeyError(响亮失败);Node 若返回 `undefined`,这个假 sid 会一路传进 M4/M5(写库、拼进下一个
455
+ * 请求体、当成会话标识做 cache key),表现为「看起来成功、后续行为错乱」。
456
+ *
457
+ * 判定比 Python 再收紧一格:除「键缺失/响应不是对象」外,`null`/空串/非字符串同样拒绝
458
+ * (Python 只挡键缺失,空值会照样返回给调用方——那是同一类故障的下游放大器)。
459
+ *
460
+ * @param {unknown} resp `/session` 的 JSON 响应
461
+ * @returns {string} 非空 session_id
462
+ * @throws {DshDriverError} code `-2`,消息「驱动响应缺少 session_id」
463
+ */
464
+ function requireSessionId(resp) {
465
+ const sid = resp !== null && typeof resp === 'object' ? resp.session_id : undefined;
466
+ if (typeof sid !== 'string' || sid === '') {
467
+ throw new DshDriverError(-2, '驱动响应缺少 session_id');
468
+ }
469
+ return sid;
470
+ }
471
+
472
+ /**
473
+ * 新建常驻会话(sid 由插件生成并回执),返回 session_id。
474
+ *
475
+ * 回执缺 `session_id` ⇒ 抛 `DshDriverError(-2, '驱动响应缺少 session_id')`(对齐 Python 直接
476
+ * 下标取键的 KeyError:写路径绝不返回 `undefined` 假 sid)。
477
+ *
478
+ * @param {string} cwd 会话工作目录
479
+ * @param {string} [task] 平台侧标识(任务 id / 卡 id,仅展示与日志)
480
+ * @param {string} [model] 裸模型 id(`provider/model` 需先经 `splitModel`)
481
+ * @param {string} [provider]
482
+ * @returns {Promise<string>}
483
+ */
484
+ export async function createSession(cwd, task = '', model = '', provider = '') {
485
+ const payload = { cwd: cwd ?? '', task: task ?? '' };
486
+ if (model) payload.model = model;
487
+ if (provider) payload.provider = provider;
488
+ return requireSessionId(await request('POST', '/session', payload));
489
+ }
490
+
491
+ /**
492
+ * 恢复已持久化会话(消灭「headless 无 resume、每轮独立会话」),返回 session_id。
493
+ *
494
+ * 回执校验同 `createSession`(缺 `session_id` 抛 `DshDriverError(-2, …)`)。
495
+ *
496
+ * @param {string} sessionId
497
+ * @param {string} [task]
498
+ * @param {string} [cwd]
499
+ * @param {string} [model]
500
+ * @param {string} [provider]
501
+ * @returns {Promise<string>}
502
+ */
503
+ export async function resumeSession(sessionId, task = '', cwd = '', model = '', provider = '') {
504
+ const payload = { session_id: sessionId, task: task ?? '', cwd: cwd ?? '' };
505
+ if (model) payload.model = model;
506
+ if (provider) payload.provider = provider;
507
+ return requireSessionId(await request('POST', '/session', payload));
508
+ }
509
+
510
+ /**
511
+ * 有 sid 则恢复、无则新建的统一入口(runner 首轮/续轮同一出口)。
512
+ *
513
+ * 两条回落链语义不变(有 sid ⇒ `resumeSession`,空 sid ⇒ `createSession`),`session_id`
514
+ * 校验只在最终拿到 `/session` 回执时发生(见 `requireSessionId`)。
515
+ *
516
+ * @param {string} sessionId 空串/空值走新建分支
517
+ * @param {string} cwd
518
+ * @param {string} [task]
519
+ * @param {string} [model]
520
+ * @param {string} [provider]
521
+ * @returns {Promise<string>}
522
+ */
523
+ export async function ensureSession(sessionId, cwd, task = '', model = '', provider = '') {
524
+ if (sessionId) return resumeSession(sessionId, task, cwd, model, provider);
525
+ return createSession(cwd, task, model, provider);
526
+ }
527
+
528
+ /**
529
+ * 投递一轮提示词(followup,立刻返回;轮次结束由 SSE 的 turn/end 帧判定)。
530
+ * @param {string} sessionId
531
+ * @param {string} text
532
+ */
533
+ export async function prompt(sessionId, text) {
534
+ return request('POST', '/prompt', { session_id: sessionId, prompt: text });
535
+ }
536
+
537
+ /**
538
+ * 运行中插话(steer 到最近 step 边界;空闲则直接起一轮)。
539
+ * @param {string} sessionId
540
+ * @param {string} text
541
+ */
542
+ export async function steer(sessionId, text) {
543
+ return request('POST', '/steer', { session_id: sessionId, prompt: text });
544
+ }
545
+
546
+ /**
547
+ * 优雅中断当前 turn(保留已流式交付的文本;keepInbox 连排队项一起保留)。
548
+ * @param {string} sessionId
549
+ * @param {boolean} [keepInbox]
550
+ */
551
+ export async function cancel(sessionId, keepInbox = false) {
552
+ return request('POST', '/cancel', { session_id: sessionId, keep_inbox: Boolean(keepInbox) });
553
+ }
554
+
555
+ /**
556
+ * 释放常驻会话(停轮次 + 移出 registry/会话存储)。
557
+ * @param {string} sessionId
558
+ */
559
+ export async function dispose(sessionId) {
560
+ return request('POST', '/dispose', { session_id: sessionId }, DISPOSE_TIMEOUT_MS);
561
+ }
562
+
563
+ /**
564
+ * 触发宿主侧压缩(插件走 `/compact` 命令)。触发即返回(压缩在 dsh 进程内异步进行,
565
+ * 要过一次模型);真正的失败只落宿主日志——平台侧 compact 语义本就是「发起」。
566
+ * @param {string} sessionId
567
+ */
568
+ export async function compact(sessionId) {
569
+ return request('POST', '/compact', { session_id: sessionId });
570
+ }
571
+
572
+ /**
573
+ * 完整复制会话为新会话(宿主 `sessionController.fork`)。
574
+ *
575
+ * `atSeq` 给定时按该事件 seq 精确切分(缺省=最近一个完整 turn 的前缀);0/空值视为缺省
576
+ * (对齐 Python 的 `if at_seq:`)。返回 `{new_session_id}`——新会话**不在**驱动池里:平台把
577
+ * 它绑到卡片/任务后,下一轮 `/session` 带该 sid 走 resume 自然接进池。
578
+ *
579
+ * @param {string} sessionId
580
+ * @param {number|null} [atSeq]
581
+ */
582
+ export async function fork(sessionId, atSeq = null) {
583
+ const payload = { session_id: sessionId };
584
+ if (atSeq) payload.at_seq = Math.trunc(Number(atSeq));
585
+ return request('POST', '/fork', payload, FORK_TIMEOUT_MS);
586
+ }
587
+
588
+ /**
589
+ * 改会话标题(与 dsh 侧栏同一份标题)。返回 `{title, [external]}`——`title` 是**宿主接受值**
590
+ * (可能被规范化/按 UTF-8 字节预算截断),调用方据此回写卡面可保证两侧逐字一致。
591
+ *
592
+ * `timeoutMs` 可缩短:卡面改名是 best-effort 的同步链路(`PATCH /board/cards/<cid>`),
593
+ * 不能让驱动侧僵死把 HTTP worker 拖满默认 120s。
594
+ *
595
+ * @param {string} sessionId
596
+ * @param {string} title
597
+ * @param {{timeoutMs?: number}} [opts]
598
+ */
599
+ export async function rename(sessionId, title, { timeoutMs = REQUEST_TIMEOUT * 1000 } = {}) {
600
+ return request('POST', '/rename', { session_id: sessionId, title }, timeoutMs);
601
+ }
602
+
603
+ /**
604
+ * 会话级模型切换(宿主 `sessionController.selectModel`)。
605
+ *
606
+ * `model` 支持 `provider/model` 写法(拆分在插件侧);只给模型名时由插件沿用该 agent 当前的
607
+ * provider。`model` 为空而 `reasoningEffort` 非空=**只改思考等级**(插件从会话当前模型选择
608
+ * 回读 provider/model)。返回 `{selected: {provider, model, reasoningEffort?}}`。
609
+ *
610
+ * @param {string} sessionId
611
+ * @param {string} model
612
+ * @param {string} [provider]
613
+ * @param {string} [reasoningEffort]
614
+ */
615
+ export async function setModel(sessionId, model, provider = '', reasoningEffort = '') {
616
+ const payload = { session_id: sessionId, model };
617
+ if (provider) payload.provider = provider;
618
+ if (reasoningEffort) payload.reasoning_effort = reasoningEffort;
619
+ return request('POST', '/model', payload, MODEL_TIMEOUT_MS);
620
+ }
621
+
622
+ // ---------- 审批代答 + 权限 preset ----------
623
+
624
+ /**
625
+ * 平台三档权限 → dsh preset 名。manual 逐条确认 ↔ workspace-write(sandbox=workspace-write +
626
+ * approval=ask;平台随即接管该会话审批代答);yolo 自动通过 / auto 完全自主 ↔
627
+ * danger-full-access(approval=never)。
628
+ *
629
+ * ⚠️ 这是**语义近似**:dsh 的 preset 是 sandbox×approval 组合,与平台三档并不一一对应
630
+ * (yolo 与 auto 落到同一档)。定义收在驱动模块而不是 server:board/runner 起会话时也要按
631
+ * 项目默认值应用权限档,而 server 反向依赖 board(循环导入),只能由最底层的驱动持有。
632
+ */
633
+ export const PERMISSION_PRESETS = Object.freeze({
634
+ manual: 'workspace-write',
635
+ yolo: 'danger-full-access',
636
+ auto: 'danger-full-access',
637
+ });
638
+
639
+ /**
640
+ * 反向近似(宿主 preset → 平台三档):仅用于「平台没记过档位」的会话(用户在 dsh 侧直跑后
641
+ * 被接管的卡会话)。正向映射本就多对一(yolo/auto 同 preset),反查统一取 yolo 作展示。
642
+ */
643
+ export const PRESET_MODES = Object.freeze({
644
+ 'workspace-write': 'manual',
645
+ 'danger-full-access': 'yolo',
646
+ });
647
+
648
+ /** 思考等级(dsh 宿主 reasoningEffort)合法取值:pi-ai 的 ModelThinkingLevel 全集 */
649
+ export const EFFORT_LEVELS = Object.freeze([
650
+ 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max',
651
+ ]);
652
+
653
+ /** 归档端点「会话不存在」的独立状态码(插件侧 `WorkspaceUnknownSessionError` 映射) */
654
+ export const ARCHIVE_UNKNOWN_SESSION = 410;
655
+ /** 旧插件没有 `/watch` 端点时的状态码(未知驱动端点,见插件 `_serve` 的兜底 404) */
656
+ export const WATCH_UNSUPPORTED = 404;
657
+
658
+ /** 取映射值(自身属性才认;挡下 `toString` 这类原型键,对齐 Python dict.get 的缺失语义) */
659
+ function lookupOwn(map, key) {
660
+ return Object.prototype.hasOwnProperty.call(map, key) ? map[key] : '';
661
+ }
662
+
663
+ /** Python `repr()` 的字符串近似(日志文案里 `'manual'` 的单引号形态) */
664
+ function reprText(value) {
665
+ return `'${String(value)}'`;
666
+ }
667
+
668
+ /**
669
+ * 应答会话挂起的**审批**(宿主 `approval/request` waterfall 代答)。
670
+ *
671
+ * 前提:插件已认领该会话的审批(`POST /permission` 接管过,`/status.approval_held=true`),
672
+ * 否则审批由 dsh GUI 作答,这里返回 409。`decision` 用 dsh 原生度:`allowed-once` /
673
+ * `rejected` / `cancelled`。`approvalId` 取自 interaction 标记的 `id`(插件自生成 `ap-<n>`)。
674
+ * 返回 `{outcome}`。
675
+ *
676
+ * @param {string} sessionId
677
+ * @param {string} approvalId
678
+ * @param {string} decision
679
+ */
680
+ export async function answerApproval(sessionId, approvalId, decision) {
681
+ return request('POST', '/approval', {
682
+ session_id: sessionId, approval_id: approvalId, decision,
683
+ }, SHORT_TIMEOUT_MS);
684
+ }
685
+
686
+ /**
687
+ * 切换会话权限 preset(宿主 `permissionPresets.set`)并**把该会话的审批交给平台**。
688
+ *
689
+ * preset 名来自 `/presets` 的 options[].value。preset 的 approval=ask 时平台必须用
690
+ * `answerApproval` 作答(插件据此认领 waterfall);never 时瀑布不会被调用。`mode`(可选)
691
+ * =平台三档 manual/yolo/auto:preset 到三档是多对一,只有平台知道用户点的哪一档,故随写
692
+ * 一起交给驱动记下(后续经 `driver/permission` 状态帧回读)。
693
+ *
694
+ * @param {string} sessionId
695
+ * @param {string} preset 宿主 preset 名
696
+ * @param {string|null} [mode] 平台三档
697
+ */
698
+ export async function setPermission(sessionId, preset, mode = null) {
699
+ const body = { session_id: sessionId, preset };
700
+ if (mode) body.mode = mode;
701
+ return request('POST', '/permission', body, SHORT_TIMEOUT_MS);
702
+ }
703
+
704
+ /**
705
+ * 回答会话挂起的提问(dsh 原生格式)。
706
+ *
707
+ * `answers` 为 `[{id: 题 id, selected: [选项标签…], custom: 自由文本}]`——插件侧转成
708
+ * `ctx.userQuestions.answer(agent, callId, {answers})`,即 Web GUI 同一条作答通道。
709
+ * `callId` 取自 `/live` 或 `/status` 的 interaction.call_id。返回布尔受理结果
710
+ * (false = 问题已不存在/已答)。
711
+ *
712
+ * @param {string} sessionId
713
+ * @param {string} callId
714
+ * @param {object[]} answers
715
+ * @returns {Promise<boolean>}
716
+ */
717
+ export async function answerQuestion(sessionId, callId, answers) {
718
+ const resp = await request('POST', '/answer', {
719
+ session_id: sessionId, call_id: callId, answers,
720
+ }, SHORT_TIMEOUT_MS);
721
+ return Boolean(resp && resp.accepted);
722
+ }
723
+
724
+ // ---------- 会话归档 ----------
725
+
726
+ /**
727
+ * 归档 / 取消归档一个会话(驱动 `POST /archive`,两条都幂等)。
728
+ *
729
+ * - `archived=true`:宿主归档该会话(`stopActivity: true`——平台拖入「已完成」时刚发过
730
+ * abort,异步生效前宿主可能仍认为有在跑的工作,带此开关避免竞态拒绝);
731
+ * - `archived=false`:取消归档(不校验会话是否存在,幂等)。
732
+ *
733
+ * 失败语义:会话不存在抛 `DshDriverError(ARCHIVE_UNKNOWN_SESSION, …)`(调用方按「无同步
734
+ * 对象」跳过);其余 HTTP/传输错误原样抛(调用方按硬失败回滚卡片列)。
735
+ *
736
+ * @param {string} sessionId
737
+ * @param {boolean} [archived]
738
+ * @param {{timeoutMs?: number}} [opts]
739
+ */
740
+ export async function archive(sessionId, archived = true, { timeoutMs = SHORT_TIMEOUT_MS } = {}) {
741
+ return request('POST', '/archive', {
742
+ session_id: sessionId, archived: Boolean(archived),
743
+ }, timeoutMs);
744
+ }
745
+
746
+ /**
747
+ * 取消归档(`archive(sid, false)` 的同义封装,读侧语义更直白)。
748
+ * @param {string} sessionId
749
+ * @param {{timeoutMs?: number}} [opts]
750
+ */
751
+ export async function unarchive(sessionId, opts = {}) {
752
+ return archive(sessionId, false, opts);
753
+ }
754
+
755
+ // ---------- 看管声明(外部会话投递通道) ----------
756
+
757
+ /**
758
+ * 声明 / 撤销「平台看管该会话」(驱动 `POST /watch`,幂等)。返回看管是否生效。
759
+ *
760
+ * 为什么需要:外部会话(用户在 dsh GUI 里直跑/接管、`/live` 里 `owned:false`)既不在驱动
761
+ * 池、也没有平台持有者,驱动侧不可能靠「在不在池内」推断平台是否有意投递——**范围必须由
762
+ * 平台显式划定**。平台只对确实要投递的会话声明看管;驱动据此放行 `/prompt`·`/steer`·
763
+ * `/answer`·`/approval` 的池外分支。
764
+ *
765
+ * 幂等:回执给的是 `watched` 的**当前实际状态**(而非「这次有没有改动」),故本函数返回
766
+ * `Boolean(resp.watched)`——调用方按「true 才算声明成功」处置。`on=false` 撤销。
767
+ *
768
+ * 失败语义:**异常照抛**(`DshDriverError`:旧插件 404 / 驱动不可达 / 令牌错),
769
+ * 「是否吞异常降级」由调用方决定。
770
+ *
771
+ * @param {string} sessionId
772
+ * @param {boolean} [on]
773
+ * @param {{timeoutMs?: number}} [opts]
774
+ * @returns {Promise<boolean>}
775
+ */
776
+ export async function watchSession(sessionId, on = true, { timeoutMs = WATCH_TIMEOUT_MS } = {}) {
777
+ const resp = await request('POST', '/watch', {
778
+ session_id: sessionId, on: Boolean(on),
779
+ }, timeoutMs);
780
+ return Boolean(resp && resp.watched);
781
+ }
782
+
783
+ // ---------- 项目级默认值下发 ----------
784
+
785
+ /**
786
+ * 把项目级默认(思考等级 / 权限档)应用到刚建或刚续的会话上(best-effort)。
787
+ *
788
+ * 起会话路径(board 起卡 / runner 起轮 / feishu 新建会话)在 create/resume 之后调本函数:
789
+ * 项目里配了默认值就下发,没配就什么都不做(沿用宿主默认)。
790
+ *
791
+ * 为什么走 `/model`(selectModel)而不是建会话时塞 agentOptions:宿主对 reasoningEffort 的
792
+ * 校验发生在**请求装配**时(模型不支持的档位直接抛 UNSUPPORTED_REASONING_EFFORT)——塞进
793
+ * agentOptions 会让「档位与模型不匹配」变成整轮失败;走 `/model` 则同步返回错误,这里捕获后
794
+ * 只记一行告警,会话照常跑(不因一个配置错误起不来)。
795
+ *
796
+ * **best-effort**:捕获 `DshDriverError`(Python 侧只捕这一类)并收集告警文本;返回告警列表
797
+ * (空数组=全部成功或无需应用)。调用方把告警写进会话日志。
798
+ *
799
+ * @param {string} sessionId
800
+ * @param {string} [model] 会话当前模型(可为空:档位不变、仅改思考等级时由插件回读)
801
+ * @param {string} [provider]
802
+ * @param {string} [reasoningEffort] 空=不改;非 `EFFORT_LEVELS` 之一直接告警跳过
803
+ * @param {string} [permissionMode] 平台三档(manual/yolo/auto),空=不改
804
+ * @param {((text: string) => void)|null} [log] 可选日志回调,逐条即时落日志
805
+ * @returns {Promise<string[]>} 告警文本列表(每条自带 `### ` 前缀与换行,与 Python 逐字同)
806
+ */
807
+ export async function applySessionDefaults(sessionId, model = '', provider = '',
808
+ reasoningEffort = '', permissionMode = '', log = null) {
809
+ const warns = [];
810
+ const warn = (text) => {
811
+ warns.push(text);
812
+ if (typeof log === 'function') {
813
+ try {
814
+ log(text);
815
+ } catch {
816
+ // 日志失败绝不影响起会话
817
+ }
818
+ }
819
+ };
820
+ if (permissionMode) {
821
+ const preset = lookupOwn(PERMISSION_PRESETS, permissionMode);
822
+ if (!preset) {
823
+ warn(`### 项目权限档 ${reprText(permissionMode)} 非法,已跳过(会话沿用宿主默认)\n`);
824
+ } else {
825
+ try {
826
+ await setPermission(sessionId, preset, permissionMode);
827
+ } catch (error) {
828
+ if (error instanceof DshDriverError) {
829
+ warn(`### 项目权限档 ${permissionMode} 未生效:${error.message}\n`);
830
+ } else {
831
+ throw error;
832
+ }
833
+ }
834
+ }
835
+ }
836
+ if (reasoningEffort) {
837
+ if (!EFFORT_LEVELS.includes(reasoningEffort)) {
838
+ warn(`### 项目思考等级 ${reprText(reasoningEffort)} 非法,已跳过`
839
+ + `(合法值:${EFFORT_LEVELS.join('/')})\n`);
840
+ } else {
841
+ try {
842
+ await setModel(sessionId, model, provider, reasoningEffort);
843
+ } catch (error) {
844
+ if (error instanceof DshDriverError) {
845
+ warn(`### 项目思考等级 ${reasoningEffort} 未生效:${error.message}\n`);
846
+ } else {
847
+ throw error;
848
+ }
849
+ }
850
+ }
851
+ }
852
+ return warns;
853
+ }
854
+
855
+ // ---------- 事件流(SSE;消费本体在 sse.js) ----------
856
+
857
+ /** 判定「本轮的 turn 结束」的帧类型 */
858
+ export const TURN_END = 'turn/end';
859
+ /** 判定「会话进入等人工输入(提问/审批)」的帧类型 */
860
+ export const INTERACTION = 'driver/interaction';
861
+
862
+ /**
863
+ * `turn/end` 的 reason.kind → 平台轮次退出码(与旧 kimi_web 口径对齐)。
864
+ * @param {string|null|undefined} reason
865
+ * @returns {number} completed→0 / aborted→130 / 其他(含 null)→1
866
+ */
867
+ export function turnExitCode(reason) {
868
+ if (reason === 'completed') return 0;
869
+ if (reason === 'aborted') return 130;
870
+ return 1;
871
+ }
872
+
873
+ /**
874
+ * 把 `stop` 归一成 `() => boolean`(对齐 Python 的 `stop()` 真值判定):
875
+ * 回调按真值收(`Boolean(stop())`);AbortSignal 按其 `aborted` 收;其余 ⇒ 无停止探测。
876
+ *
877
+ * @param {(() => any)|AbortSignal|null|undefined} stop
878
+ * @returns {(() => boolean)|null}
879
+ */
880
+ function stopProbe(stop) {
881
+ if (typeof stop === 'function') return () => Boolean(stop());
882
+ if (stop !== null && typeof stop === 'object' && typeof stop.aborted === 'boolean') {
883
+ return () => stop.aborted === true;
884
+ }
885
+ return null;
886
+ }
887
+
888
+ /**
889
+ * **真** `AbortSignal` 的判据:`sse.js` 要对它 `addEventListener('abort', …)`,
890
+ * 鸭子类型的普通对象(`{aborted: false}`)**不是** EventTarget。
891
+ *
892
+ * 这条判据是 2026-10-14 M6.5-R3 抓到的真缺陷的修复(当时现场:把 `{aborted:false}` 当停止信号
893
+ * 传给 `feedInto`,整轮以 `### 事件流异常: signal.addEventListener is not a function` 收口、
894
+ * 轮次被判失败)。原先这里与 `stopProbe` 共用「`typeof stop.aborted === 'boolean'`」这同一条松判据
895
+ * ⇒ 普通对象被塞进 `fetch(..., {signal})`,而 `fetch`/`sse` 只认真 EventTarget。
896
+ * 归一后的契约:**函数**与**鸭子 `{aborted}`** 都只当「停止探针」;只有真 AbortSignal 才额外
897
+ * 充当立即中断信号。
898
+ *
899
+ * @param {unknown} value
900
+ * @returns {boolean}
901
+ */
902
+ function isAbortSignal(value) {
903
+ return value !== null && typeof value === 'object'
904
+ && typeof value.addEventListener === 'function';
905
+ }
906
+
907
+ /**
908
+ * SSE 消费的公共壳:拼请求头(驱动令牌 + `text/event-stream`)并把 `SseError` 翻译成
909
+ * `DshDriverError`(Python 侧这一层本来就只有 `DshDriverError` 一种错误)。
910
+ *
911
+ * @param {string} url
912
+ * @param {(frame: object) => void} onFrame
913
+ * @param {object} opts
914
+ * @returns {Promise<{frames:number,lastSeq:number,stopped:boolean}>}
915
+ */
916
+ async function consumeEvents(url, onFrame, { since, stop, idleTimeoutMs, signal, fetchImpl }) {
917
+ const opts = {
918
+ headers: { ...authHeaders(), accept: 'text/event-stream' },
919
+ since: toSeq(since),
920
+ idleTimeoutMs,
921
+ };
922
+ if (typeof onFrame === 'function') opts.onFrame = onFrame;
923
+ const probe = stopProbe(stop);
924
+ if (probe) opts.stop = probe;
925
+ if (isAbortSignal(stop)) {
926
+ opts.signal = stop; // AbortSignal:立即中断(比 250ms 轮询更快)
927
+ }
928
+ if (signal) opts.signal = signal;
929
+ if (fetchImpl) opts.fetchImpl = fetchImpl;
930
+ try {
931
+ return await streamEvents(url, opts);
932
+ } catch (error) {
933
+ if (error instanceof SseError) throw new DshDriverError(error.code, error.message);
934
+ throw error;
935
+ }
936
+ }
937
+
938
+ /**
939
+ * 订阅**会话事件流**(阻塞直到 stop() 为真、链路结束或空闲超时)。
940
+ *
941
+ * - `onFrame(frame)`:每收到一帧调用一次(调用方在此落日志/唤醒等待者);
942
+ * - `stop`:可选回调(真值即主动断开,用于任务停止/超时收口)或 AbortSignal;
943
+ * - `since`:从该 seq 之后开始(会话池 ring 补发),避免重连后重复消费历史帧;
944
+ * - `idleTimeoutMs`:超过该时长没有任何字节(含 keepalive 注释)判定链路僵死并抛错。
945
+ *
946
+ * 返回 `{frames, lastSeq, stopped}`(Python 是 `(帧数, 最后 seq)` 二元组 + 主动断开不抛)。
947
+ * 链路断开/超时抛 `DshDriverError`。
948
+ *
949
+ * @param {string} sessionId
950
+ * @param {(frame: object) => void} onFrame
951
+ * @param {{since?: number, stop?: any, idleTimeoutMs?: number, signal?: AbortSignal,
952
+ * fetchImpl?: typeof fetch}} [opts]
953
+ * @returns {Promise<{frames:number,lastSeq:number,stopped:boolean}>}
954
+ */
955
+ export async function stream(sessionId, onFrame, {
956
+ since = 0,
957
+ stop = null,
958
+ idleTimeoutMs = STREAM_IDLE_TIMEOUT * 1000,
959
+ signal = undefined,
960
+ fetchImpl = undefined,
961
+ } = {}) {
962
+ if (!configured()) throw new DshDriverError(-1, UNCONFIGURED_MESSAGE);
963
+ splitDriverUrl(); // 地址口径校验(对齐 Python 先 _split_url)
964
+ const url = sseUrl(driverUrl(), '/events', {
965
+ session_id: String(sessionId ?? ''),
966
+ since: toSeq(since),
967
+ });
968
+ return consumeEvents(url, onFrame, { since, stop, idleTimeoutMs, signal, fetchImpl });
969
+ }
970
+
971
+ /**
972
+ * 订阅**全局状态流**(`?scope=state`,P4 事件化;dshevents.EventHub 的唯一连接)。
973
+ *
974
+ * 帧形 `{seq, time, type, session_id, data}`,type ∈ `session/created|disposed`、
975
+ * `driver/attached|detached`、`agent/status`、`turn/start|end`、`driver/interaction`。
976
+ * 语义与 `stream()` 一致(since 续传、stop 主动断开、空闲超时判僵死)。
977
+ *
978
+ * 接口冻结(`dshevents.js` 会 import):`stateStream(onFrame, {since, stop, idleTimeoutMs})`。
979
+ *
980
+ * @param {(frame: object) => void} onFrame
981
+ * @param {{since?: number, stop?: any, idleTimeoutMs?: number, signal?: AbortSignal,
982
+ * fetchImpl?: typeof fetch}} [opts]
983
+ * @returns {Promise<{frames:number,lastSeq:number,stopped:boolean}>}
984
+ */
985
+ export async function stateStream(onFrame, {
986
+ since = 0,
987
+ stop = null,
988
+ idleTimeoutMs = STREAM_IDLE_TIMEOUT * 1000,
989
+ signal = undefined,
990
+ fetchImpl = undefined,
991
+ } = {}) {
992
+ if (!configured()) throw new DshDriverError(-1, UNCONFIGURED_MESSAGE);
993
+ splitDriverUrl();
994
+ const url = sseUrl(driverUrl(), '/events', { scope: 'state', since: toSeq(since) });
995
+ return consumeEvents(url, onFrame, { since, stop, idleTimeoutMs, signal, fetchImpl });
996
+ }
997
+
998
+ /**
999
+ * SSE 订阅体(runner / chat / board 共用):把事件帧喂给 `TurnWaiter`。
1000
+ *
1001
+ * `onFrame` 为可选的额外处理(如 runner 把帧翻译成轮次日志行);它抛异常不影响等待线程
1002
+ * ——日志写失败绝不能让轮次收口逻辑失联。链路异常在未主动断开时经 `waiter.fail()` 上抛
1003
+ * (等待方据此收口,而不是无限等)。
1004
+ *
1005
+ * 返回:正常收尾给流结果 `{frames,lastSeq,stopped}`,链路异常给 `null`(Python 无返回值,
1006
+ * 调用方一律忽略返回值,这里只多给一份可诊断信息)。
1007
+ *
1008
+ * @param {string} sessionId
1009
+ * @param {number} since
1010
+ * @param {TurnWaiter} waiter
1011
+ * @param {(() => any)|AbortSignal|null} stop
1012
+ * @param {((frame: object) => void)|null} [onFrame]
1013
+ * @returns {Promise<{frames:number,lastSeq:number,stopped:boolean}|null>}
1014
+ */
1015
+ export async function feedInto(sessionId, since, waiter, stop, onFrame = null) {
1016
+ const handle = (frame) => {
1017
+ if (typeof onFrame === 'function') {
1018
+ try {
1019
+ onFrame(frame);
1020
+ } catch {
1021
+ // 落日志失败不致命:等待线程照常推进
1022
+ }
1023
+ }
1024
+ waiter.feed(frame);
1025
+ };
1026
+ try {
1027
+ return await stream(sessionId, handle, { since, stop });
1028
+ } catch (error) {
1029
+ const probe = stopProbe(stop);
1030
+ if (probe === null || !probe()) waiter.fail(String((error && error.message) || error));
1031
+ return null;
1032
+ }
1033
+ }
1034
+
1035
+ // ---------- 轮次等待 ----------
1036
+
1037
+ /**
1038
+ * 一轮的事件收集器:在 SSE 回调里落日志、抓 `turn/end`、记录挂起交互。
1039
+ *
1040
+ * **形态差异(与 Python 唯一必要的一处)**:Python 用 `threading.Condition` 做阻塞等待
1041
+ * (`feed()` 由 SSE 线程调、`wait_turn()` 由轮次主线程调);Node 是单线程事件循环,没有
1042
+ * 「另一个线程」可阻塞,故改为 **Promise 式**:
1043
+ * - `feed(frame)` / `fail(message)` 同步更新状态并 settle 挂起的 `wait()`;
1044
+ * - `wait(timeoutMs)` 返回 `Promise<'turn_end'|'interaction'|'error'|'timeout'>`。
1045
+ *
1046
+ * 语义逐条保留(与 Python `wait_turn` 同):
1047
+ * - 判定优先级 `exitCode → error → interaction`(有结论先返结论,链路错误优先于挂起交互);
1048
+ * - `interaction` 是 **consume-on-read**:返回 `'interaction'` 后即清零,调用方要么据此
1049
+ * 让位、要么记一行日志后继续等(同一轮内可能多次提问);
1050
+ * - `'timeout'` 只表示本地等待窗口到期(不代表远端异常)——超时后状态保留,可再次 `wait()`;
1051
+ * - 多个并发等待者被同一次 `feed()` 唤醒时各自按当前状态取结论(interaction 只被一个取走)。
1052
+ *
1053
+ * 字段:`started`(是否见过 turn/start)、`exitCode`、`turnReason`、`interaction`、`error`。
1054
+ */
1055
+ export class TurnWaiter {
1056
+ constructor() {
1057
+ this.started = false; // 是否见过 turn/start(判定「turn 压根没起来」)
1058
+ this.exitCode = null; // turn/end 换算出的退出码(null = 尚无结论)
1059
+ this.turnReason = null;
1060
+ this.interaction = null; // 挂起等作答实况(consume-on-read:读后清零)
1061
+ this.error = null; // SSE 链路错误
1062
+ this._waiters = new Set(); // 挂起的 {resolve, timer}
1063
+ }
1064
+
1065
+ /**
1066
+ * 处理一帧:更新状态(turn/start、turn/end、driver/interaction)并唤醒等待者。
1067
+ * @param {object} frame SSE 帧 `{seq, time, type, session_id, data}`
1068
+ */
1069
+ feed(frame) {
1070
+ const type = frame ? frame.type : undefined;
1071
+ if (type === 'turn/start') {
1072
+ this.started = true;
1073
+ } else if (type === TURN_END) {
1074
+ // dsh 的 cancel 落盘 reason=**null**(插件已按 aborted 归一);装机副本可能是旧版,
1075
+ // null/缺失直落会让「用户主动中断」被判轮次失败(turnExitCode(null)=1)⇒ 这里兜底。
1076
+ const raw = (frame.data || {}).reason;
1077
+ let reason = raw !== null && typeof raw === 'object' ? raw.kind : raw;
1078
+ if (reason === undefined || reason === null) reason = 'aborted';
1079
+ this.turnReason = reason;
1080
+ this.exitCode = turnExitCode(reason);
1081
+ } else if (type === INTERACTION) {
1082
+ const data = frame.data || {};
1083
+ if (data.state === 'asked') {
1084
+ this.interaction = data.interaction;
1085
+ this.started = true; // 提问意味着 turn 已在跑
1086
+ }
1087
+ }
1088
+ this._wake();
1089
+ }
1090
+
1091
+ /**
1092
+ * 链路异常:落错误并唤醒等待者(`wait()` 返回 `'error'`)。
1093
+ * @param {string} message
1094
+ */
1095
+ fail(message) {
1096
+ this.error = message;
1097
+ this._wake();
1098
+ }
1099
+
1100
+ /**
1101
+ * 等本轮结论,返回 `'turn_end' | 'interaction' | 'error' | 'timeout'`。
1102
+ *
1103
+ * @param {number|null} [timeoutMs] 本地等待窗口(毫秒);null/省略 = 一直等(只可能被
1104
+ * feed/fail 唤醒)。到期时先复核状态再判超时(对齐 Python「先查结论、再查剩余时间」)。
1105
+ * @returns {Promise<'turn_end'|'interaction'|'error'|'timeout'>}
1106
+ */
1107
+ wait(timeoutMs = null) {
1108
+ const immediate = this._take();
1109
+ if (immediate !== null) return Promise.resolve(immediate);
1110
+ return new Promise((resolve) => {
1111
+ const entry = { resolve, timer: null };
1112
+ if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs)) {
1113
+ entry.timer = setTimeout(() => {
1114
+ const value = this._take();
1115
+ this._settle(entry, value === null ? 'timeout' : value);
1116
+ }, Math.max(0, timeoutMs));
1117
+ }
1118
+ this._waiters.add(entry);
1119
+ });
1120
+ }
1121
+
1122
+ /** 取当前可用的结论(按 exitCode → error → interaction 优先级;interaction 读后清零) */
1123
+ _take() {
1124
+ if (this.exitCode !== null) return 'turn_end';
1125
+ if (this.error) return 'error';
1126
+ if (this.interaction !== null) {
1127
+ this.interaction = null;
1128
+ return 'interaction';
1129
+ }
1130
+ return null;
1131
+ }
1132
+
1133
+ /** 状态变化后:把已有结论发给每一个挂起的等待者(无结论的继续等自己的超时) */
1134
+ _wake() {
1135
+ if (this._waiters.size === 0) return;
1136
+ for (const entry of [...this._waiters]) {
1137
+ const value = this._take();
1138
+ if (value !== null) this._settle(entry, value);
1139
+ }
1140
+ }
1141
+
1142
+ /** 结算一个等待者(幂等:定时器与 feed 同时到达时只有先到者生效) */
1143
+ _settle(entry, value) {
1144
+ if (!this._waiters.delete(entry)) return;
1145
+ if (entry.timer) clearTimeout(entry.timer);
1146
+ entry.resolve(value);
1147
+ }
1148
+ }