dsh-remote-bridge 2.3.7 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path, { join } from "node:path";
2
2
  import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
3
+ import { execFile, spawn } from "node:child_process";
3
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
5
  import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
6
  import { DatabaseSync } from "node:sqlite";
@@ -132,6 +133,581 @@ function isLoopbackOrigin(origin) {
132
133
  }
133
134
  }
134
135
  //#endregion
136
+ //#region src/host/cloudflared.ts
137
+ /** 平台/架构 -> cloudflared 归档后缀。 */
138
+ function archSuffix(platform = process.platform) {
139
+ if (platform !== "linux") throw new Error(`cloudflared 尚不支持 ${platform} 平台。`);
140
+ switch (process.arch) {
141
+ case "x64": return "linux-amd64";
142
+ case "arm64": return "linux-arm64";
143
+ default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
144
+ }
145
+ }
146
+ const OFFICIAL_BASE = `https://github.com/cloudflare/cloudflared/releases/download/2024.11.1/cloudflared-`;
147
+ /**
148
+ * 官方 deb 兜底仓库(GitHub 被墙时回退)。
149
+ * `pkg.cloudflare.com` 是 Cloudflare 官方仓库,实测在 GitHub 直连被墙的机器上可达可用
150
+ * (见 PITFALLS §国内下载 cloudflared 必须择源)。版本比 GitHub 默认新(2026.10.1),
151
+ * 仅在官方 GitHub 失败时兜底,不改变「默认先 GitHub」的语义。
152
+ */
153
+ const DEB_FALLBACK_URL = "https://pkg.cloudflare.com/cloudflared/pool/main/c/cloudflared/cloudflared_2026.10.1_amd64.deb";
154
+ async function downloadHttps(url, ms, onProgress) {
155
+ const mod = url.startsWith("http:") ? await import("node:http") : await import("node:https");
156
+ return await new Promise((resolve, reject) => {
157
+ const req = mod.get(url, { headers: { "User-Agent": "dsh-remote-bridge" } }, (response) => {
158
+ clearTimeout(timer);
159
+ const total = Number(response.headers["content-length"] ?? -1);
160
+ const chunks = [];
161
+ let received = 0;
162
+ response.on("data", (c) => {
163
+ const buf = c;
164
+ chunks.push(buf);
165
+ received += buf.length;
166
+ onProgress?.(received, total);
167
+ });
168
+ response.on("error", (cause) => reject(/* @__PURE__ */ new Error(`下载中止:${url}(${cause.message})`)));
169
+ response.on("end", () => {
170
+ resolve({
171
+ ok: statusOk(response.statusCode),
172
+ status: response.statusCode ?? 0,
173
+ body: Buffer.concat(chunks)
174
+ });
175
+ });
176
+ });
177
+ const timer = setTimeout(() => {
178
+ req.destroy(ms < Number.POSITIVE_INFINITY ? /* @__PURE__ */ new Error(`连接${ms}ms无响应:${url}`) : void 0);
179
+ }, ms);
180
+ req.on("error", (cause) => reject(/* @__PURE__ */ new Error(`下载失败:${url}(${cause.message})`)));
181
+ req.on("close", () => clearTimeout(timer));
182
+ });
183
+ }
184
+ function statusOk(code) {
185
+ return typeof code === "number" && code >= 200 && code < 300;
186
+ }
187
+ /** 环境变量注入的下载源配置;空则回退官方。 */
188
+ function mirrorBase() {
189
+ return process.env.CLOUDFLARED_MIRROR?.trim() ?? "";
190
+ }
191
+ /**
192
+ * 判定当前下载源是否为「Deb 仓库」。用 `deb+` 前缀声明:后续拼一个 `https://...deb` 完整地址。
193
+ * 例:`CLOUDFLARED_MIRROR=deb+https://pkg.cloudflare.com/cloudflared/pool/main/c/cloudflared/cloudflared_2026.10.1_amd64.deb`
194
+ * 与「裸二进制基址」镜像(拼 `cloudflared-linux-amd64`)区分开。
195
+ */
196
+ function isDebSource() {
197
+ return mirrorBase().startsWith("deb+");
198
+ }
199
+ /** 返回要下载的源 URL:deb 源返回完整 .deb 地址;裸镜像基址拼架构后缀;否则官方 GitHub。 */
200
+ function downloadUrlFor() {
201
+ const suffix = archSuffix();
202
+ const mirror = mirrorBase();
203
+ if (mirror === "") return `${OFFICIAL_BASE}${suffix}`;
204
+ if (mirror.startsWith("deb+")) return mirror.slice(4);
205
+ return `${mirror}${suffix}`;
206
+ }
207
+ /** 从进程输出文本提取 `trycloudflare.com` 地址;找不到返回 null。 */
208
+ function extractQuickTunnelUrl(text) {
209
+ const match = /https:\/\/([a-z0-9-]+\.trycloudflare\.com)/i.exec(text);
210
+ return match?.[1] ? `https://${match[1]}` : null;
211
+ }
212
+ /**
213
+ * 确保 cloudflared 可执行文件就位:先看 `dataDir/cloudflared` 是否已存在,否则下载。
214
+ * 返回可执行文件绝对路径。
215
+ *
216
+ * 下载失败会把错误抛给调用方(router -> 面板可见),不会把半个文件当「已就位」——
217
+ * 用临时文件 + rename 原子落盘。
218
+ *
219
+ * @param report 可选进度上报:下载/解包过程各阶段推进时回调(前端据此渲染进度条)。
220
+ */
221
+ async function ensureCloudflared(dataDir, report) {
222
+ const existing = path.join(dataDir, "cloudflared");
223
+ report?.({
224
+ stage: "locating",
225
+ label: "定位本机二进制…",
226
+ percent: null
227
+ });
228
+ try {
229
+ if ((await stat(existing)).isFile()) {
230
+ report?.({
231
+ stage: "locating",
232
+ label: "已找到本机二进制,直接复用",
233
+ percent: 100
234
+ });
235
+ return existing;
236
+ }
237
+ } catch {}
238
+ await mkdir(dataDir, { recursive: true });
239
+ const candidates = mirrorBase() !== "" ? [{
240
+ url: downloadUrlFor(),
241
+ deb: isDebSource()
242
+ }] : [{
243
+ url: downloadUrlFor(),
244
+ deb: false
245
+ }, {
246
+ url: DEB_FALLBACK_URL,
247
+ deb: true
248
+ }];
249
+ const errors = [];
250
+ for (const { url, deb } of candidates) {
251
+ report?.({
252
+ stage: "downloading",
253
+ label: `正在下载 cloudflared(${deb ? "官方 deb 仓库" : "官方 GitHub"})…`,
254
+ percent: null
255
+ });
256
+ try {
257
+ const res = await downloadHttps(url, 15e3, (received, total) => {
258
+ const percent = total > 0 ? Math.min(99, Math.round(received / total * 100)) : null;
259
+ if (percent !== null) {
260
+ const mb = (received / 1024 / 1024).toFixed(1);
261
+ const all = (total / 1024 / 1024).toFixed(1);
262
+ report?.({
263
+ stage: "downloading",
264
+ label: `正在下载 cloudflared… ${mb}/${all} MB`,
265
+ percent
266
+ });
267
+ }
268
+ });
269
+ if (!res.ok) {
270
+ errors.push(`HTTP ${res.status}`);
271
+ continue;
272
+ }
273
+ if (deb) {
274
+ report?.({
275
+ stage: "unpacking",
276
+ label: "解包官方 deb 并提取二进制…",
277
+ percent: null
278
+ });
279
+ return writeFromDeb(dataDir, res.body, url);
280
+ }
281
+ report?.({
282
+ stage: "unpacking",
283
+ label: "写入二进制并赋予执行权限…",
284
+ percent: null
285
+ });
286
+ const tmp = path.join(dataDir, `.cloudflared.tmp-${process.pid}`);
287
+ await writeFile(tmp, res.body);
288
+ await chmod(tmp, 493);
289
+ await rename(tmp, existing);
290
+ return existing;
291
+ } catch (cause) {
292
+ errors.push(cause instanceof Error ? cause.message : String(cause));
293
+ }
294
+ }
295
+ const tried = candidates.map((c) => c.url).join("\n ");
296
+ throw new Error(`下载 cloudflared 失败:\n ${tried}\n原因:${errors.join(";")}`);
297
+ }
298
+ /**
299
+ * 从 `.deb` 包字节中提取 `usr/bin/cloudflared`,原子落到 `dataDir/cloudflared`。
300
+ * 不调用系统 `dpkg -i`(避免污染包数据库),只做解包 + 提取。
301
+ */
302
+ async function writeFromDeb(dataDir, debBuffer, url) {
303
+ await mkdir(dataDir, { recursive: true });
304
+ const debPath = path.join(dataDir, `.cloudflared.deb-${process.pid}`);
305
+ const extractDir = path.join(dataDir, `.cloudflared-extract-${process.pid}`);
306
+ await writeFile(debPath, debBuffer);
307
+ await mkdir(extractDir, { recursive: true });
308
+ try {
309
+ await new Promise((resolve, reject) => {
310
+ execFile("dpkg-deb", [
311
+ "-x",
312
+ debPath,
313
+ extractDir
314
+ ], (err) => err ? reject(err) : resolve());
315
+ });
316
+ const inner = path.join(extractDir, "usr", "bin", "cloudflared");
317
+ const out = path.join(dataDir, "cloudflared");
318
+ const bin = await readFile(inner);
319
+ await writeFile(out, bin);
320
+ await chmod(out, 493);
321
+ return out;
322
+ } finally {
323
+ await rm(debPath, { force: true }).catch(() => {});
324
+ await rm(extractDir, {
325
+ recursive: true,
326
+ force: true
327
+ }).catch(() => {});
328
+ }
329
+ }
330
+ /** 一次 cloudflared 子进程生命周期 + 对外状态机。 */
331
+ var CloudflaredTunnelManager = class {
332
+ process = null;
333
+ url = null;
334
+ channel = null;
335
+ errorText = void 0;
336
+ listeners = [];
337
+ activity = {
338
+ stage: "idle",
339
+ label: "",
340
+ percent: null
341
+ };
342
+ /** 当前进行中任务的阶段进度(无任务时 stage 为 idle)。 */
343
+ get activityState() {
344
+ return this.activity;
345
+ }
346
+ /** 上报一条阶段进度(前端轮询 /cloudflare/activity 可见)。 */
347
+ reportActivity(a) {
348
+ this.activity = a;
349
+ this.emit();
350
+ }
351
+ /** 任务结束(成功或失败)时清空进行中进度。 */
352
+ clearActivity() {
353
+ this.activity = {
354
+ stage: "idle",
355
+ label: "",
356
+ percent: null
357
+ };
358
+ this.emit();
359
+ }
360
+ get publicState() {
361
+ return {
362
+ channel: this.channel,
363
+ url: this.url,
364
+ running: this.process !== null && !this.process.killed,
365
+ ...this.errorText === void 0 ? {} : { error: this.errorText }
366
+ };
367
+ }
368
+ /** 订阅状态变化;返回取消函数。 */
369
+ onStatus(cb) {
370
+ this.listeners.push(cb);
371
+ return () => {
372
+ this.listeners = this.listeners.filter((fn) => fn !== cb);
373
+ };
374
+ }
375
+ emit() {
376
+ for (const fn of this.listeners) fn();
377
+ }
378
+ setError(message) {
379
+ this.errorText = message;
380
+ this.emit();
381
+ }
382
+ /**
383
+ * 启动一次 Quick Tunnel。
384
+ * @param exe cloudflared 可执行文件路径(来自 [ensureCloudflared])
385
+ * @param port bridge 监听端口,cloudflared 把外网请求转发到 localhost:port
386
+ */
387
+ async startQuick(exe, port) {
388
+ if (this.process !== null && !this.process.killed) throw new Error("隧道已经在运行中,请先停止。");
389
+ this.errorText = void 0;
390
+ this.reportActivity({
391
+ stage: "starting",
392
+ label: "正在启动 cloudflared 隧道…",
393
+ percent: null
394
+ });
395
+ const child = spawn(exe, [
396
+ "tunnel",
397
+ "--url",
398
+ `http://127.0.0.1:${port}`,
399
+ "--no-autoupdate"
400
+ ], { stdio: [
401
+ "ignore",
402
+ "pipe",
403
+ "pipe"
404
+ ] });
405
+ this.process = child;
406
+ this.channel = "quick";
407
+ this.emit();
408
+ const onData = (buf) => {
409
+ const found = extractQuickTunnelUrl(buf.toString("utf8"));
410
+ if (found !== null && this.url === null) {
411
+ this.url = found;
412
+ this.clearActivity();
413
+ this.emit();
414
+ }
415
+ };
416
+ child.stdout?.on("data", onData);
417
+ child.stderr?.on("data", onData);
418
+ child.on("exit", (code, signal) => {
419
+ const wasThis = this.process === child;
420
+ this.process = null;
421
+ this.channel = null;
422
+ this.emit();
423
+ if (wasThis) {
424
+ if (this.url === null) this.setError(`cloudflared 退出(code=${code ?? "?"}, signal=${signal ?? "?"}),未拿到隧道地址。`);
425
+ this.url = null;
426
+ if (this.channel === null) this.clearActivity();
427
+ }
428
+ });
429
+ return this.publicState;
430
+ }
431
+ /** 停止当前隧道。 */
432
+ stop() {
433
+ const child = this.process;
434
+ if (child !== null && !child.killed) child.kill("SIGTERM");
435
+ this.process = null;
436
+ this.channel = null;
437
+ this.url = null;
438
+ this.emit();
439
+ return this.publicState;
440
+ }
441
+ /** 注入已抓到的地址 + 通道(命名隧道用)。 */
442
+ setNamed(url) {
443
+ this.url = url;
444
+ this.channel = "named";
445
+ this.emit();
446
+ return this.publicState;
447
+ }
448
+ };
449
+ //#endregion
450
+ //#region src/shared/version.ts
451
+ const PLUGIN_VERSION = "2.4.0";
452
+ //#endregion
453
+ //#region src/host/capabilities.ts
454
+ /**
455
+ * 桥的**能力位**:这个服务端能做哪些事,由服务端自己声明。
456
+ *
457
+ * ## 为什么要有它(而不是让客户端比版本号)
458
+ *
459
+ * 先说清一件事:README 里那句「要求 bridge ≥ 2.3.7」是给**用户**看的安装门槛,
460
+ * 而客户端**代码里并没有**版本比较、也没有处理 `connection.ready`(已核实)。
461
+ * 于是服务端能力不足时,手机端只会「静默少一个功能」—— 审批卡片不弹,
462
+ * 看起来与「本来就没有待审批」一模一样(这个坑踩过两次,根因都在插件侧)。
463
+ * 这份清单要解决的正是它:让客户端能把「服务端不支持」与「本来就没有待办」分开。
464
+ *
465
+ * 顺带说清为什么版本号不适合当判据 —— 它有两个毛病:
466
+ *
467
+ * 1. **粒度太粗**:一个版本里加了五件事,客户端只能整包接受或整包拒绝;
468
+ * 2. **方向错了**:能不能用取决于「服务端现在提供什么」,而版本号只是它的历史编号。
469
+ * 服务端完全可能在同一版本里因为配置原因关掉某个能力(比如文件下载开关)。
470
+ *
471
+ * 所以 `connection.ready` 会带上这份清单,客户端可以按**能力位**做特性探测。
472
+ * 版本号仍然保留(诊断与日志要用),但不再是唯一的兼容判据。
473
+ *
474
+ * ## 两类用途(别把第二类当死代码删掉)
475
+ *
476
+ * 这份清单同时服务两个目的,**同一项能力位对它们意味着不同的事**:
477
+ *
478
+ * 1. **我方客户端的分支判据** —— 目前只有 3 项:
479
+ * `approvals-v1` / `questions-v1`(`dsh-companion` 的 `REQUIRED_CAPABILITIES`:
480
+ * 缺了就明说「插件过旧、卡片不会出现」)与 `session-archive-list-v1`
481
+ * (决定会话面板是否显示「已归档」入口)。
482
+ * 2. **对外声明** —— 其余 23 项。它们回答的是「这台服务端能做什么」,
483
+ * **服务的是任何客户端(包括第三方)**,而不是某一处分支。
484
+ *
485
+ * 所以:**「我方客户端没用到」不等于「没人用」**。删掉第 2 类会破坏对外承诺
486
+ * (而且第 1 类里那两个名字一旦消失,客户端会对**所有用户**误报插件过旧 ——
487
+ * `tests/capabilities.spec.ts` 因此专门钉住了它们)。
488
+ *
489
+ * 反过来,**加位要克制**:只有当「客户端可能需要据此分支」或「这是一个稳定的对外能力面」时才加。
490
+ * 服务端加端点/事件时应当同步考虑加位(它是对外契约的一部分),但不要为一次性的内部改动加位。
491
+ *
492
+ * ## 纪律(加之前先读)
493
+ *
494
+ * - 能力位是**对外承诺**:加了就不能悄悄撤(客户端可能已经按它分支),
495
+ * 要撤必须发新的能力位并把旧的标成 deprecated。
496
+ * - 命名固定为 `<能力>-v<主版本>`;语义变更(不是新增字段,而是改变既有字段含义)
497
+ * 要升 `-v2`,不能原地改 `-v1` 的含义。
498
+ * - **加能力位必须能在契约里指出依据**:一个 OpenAPI 路径 + 方法,或一个 AsyncAPI 事件。
499
+ * `tests/capabilities.spec.ts` 会拿这份依据去 `docs/api/openapi.yaml` 里逐条核对 ——
500
+ * 声明与契约文件绑死,避免「文档说支持、契约里没有」。
501
+ * - 只声明**已经稳定**的东西:还在改的接口先别进来(宁可客户端探测不到而走老路)。
502
+ */
503
+ const BRIDGE_CAPABILITIES = [
504
+ "device-pairing-v1",
505
+ "file-browse-v1",
506
+ "file-write-v1",
507
+ "file-trash-v1",
508
+ "session-list-v1",
509
+ "session-rename-v1",
510
+ "session-fork-v1",
511
+ "session-archive-v1",
512
+ "session-archive-list-v1",
513
+ "history-v1",
514
+ "turn-cancel-v1",
515
+ "session-config-v1",
516
+ "commands-v1",
517
+ "agent-presets-v1",
518
+ "workspace-list-v1",
519
+ "workspace-manage-v1",
520
+ "provider-settings-v1",
521
+ "model-catalog-v1",
522
+ "plugin-inventory-v1",
523
+ "plugin-toggle-v1",
524
+ "approvals-v1",
525
+ "questions-v1",
526
+ "turn-failure-v1",
527
+ "history-byte-budget-v1",
528
+ "interaction-replay-v1",
529
+ "session-snapshot-v1"
530
+ ];
531
+ /**
532
+ * `connection.ready` 事件的载荷。
533
+ *
534
+ * 每次调用返回**新的副本**:调用方(或测试)改它不该影响常量,
535
+ * 否则一处手滑就会让整个进程后续所有连接都收到被改过的能力清单。
536
+ */
537
+ function connectionReadyData() {
538
+ return {
539
+ bridgeVersion: PLUGIN_VERSION,
540
+ capabilities: [...BRIDGE_CAPABILITIES]
541
+ };
542
+ }
543
+ //#endregion
544
+ //#region src/host/history-budget.ts
545
+ /**
546
+ * 历史响应的**字节预算**与**通用裁剪**。
547
+ *
548
+ * ## 为什么需要
549
+ *
550
+ * `GET /chat/sessions/{id}/messages` 是原样透传 DSH 的 history 事件。这些事件里
551
+ * 最重的是工具输出 —— 一条 `cargo build`、一次大文件读取、一段日志回放,
552
+ * 单条就能到几 MB。手机端拿到这种响应要**先在内存里解析整份 JSON**,
553
+ * 于是「翻一次历史把 App 卡住甚至 OOM」是完全可能的,而服务端这边毫无察觉
554
+ * (序列化成功、HTTP 也正常返回)。
555
+ *
556
+ * 对标第三方移动网关:它给历史/订阅基线设了字节预算(单帧 4 MiB、订阅基线 256 KiB),
557
+ * 并在 `tool/result` 上做「首尾各保留 8000 字符 + `outputTruncated` 标记」。
558
+ *
559
+ * ## 我们为什么不照抄字段级的做法
560
+ *
561
+ * 它的做法依赖**自己归一化过的**事件形状(它知道哪个字段装工具输出)。
562
+ * 本仓是**原样透传内核事件**:`tool/result` 这个类型名在本仓(`src/`、`tests/`、`docs/`)零命中;
563
+ * 而**内核契约不在本仓可查范围内** —— `node_modules` 下没有 `@deepseek-ai/*`
564
+ * (那些包随 DSH 主包安装,本仓只把它们列为 peer 依赖,见 `CHANGELOG-DEV.md` 里 2.1.x 的记录)。
565
+ * 所以「工具结果挂在哪个字段上」在本仓是**不可证**,而不是「查过、没有」——
566
+ * 两者结论相同,但证据强度完全不同,把后者写进注释等于伪造一条证据。
567
+ * 按猜出来的字段名写裁剪,最坏结果是**静默无效**(裁剪器一条都没命中,界面上看不出区别,
568
+ * 直到某天线上真的 OOM)。
569
+ *
570
+ * 所以这里做**形状无关**的两层防护:
571
+ *
572
+ * 1. **单条事件超限** → 对该事件里所有超长字符串做首尾保留,并在外层元素上打
573
+ * `bridgeTruncated: true`。递归找字符串,不需要知道字段名。
574
+ * 2. **整份响应超限** → 从**最旧**的开始丢整条,保留最新的连续后缀,并把 `hasMore` 置真。
575
+ * 这与既有分页语义一致:客户端仍可带 `beforeSeq` 往回翻,丢掉的只是「本次没必要传」的部分。
576
+ *
577
+ * 两层都**只在真的动手时**才加标记 —— 正常响应与改动前逐字节一致(有测试钉住)。
578
+ */
579
+ /** 单次 history 响应最多返回的 UTF-8 字节数。 */
580
+ const HISTORY_BYTE_BUDGET = 2097152;
581
+ /** 单条事件超过这个字节数就走字符串级裁剪。 */
582
+ const HISTORY_EVENT_BYTE_BUDGET = 524288;
583
+ /** 裁剪时首尾各保留的字符数(对标值)。 */
584
+ const HISTORY_KEEP_CHARS = 8e3;
585
+ /** 省略号:让被裁剪的文本一眼能看出「中间没了」。 */
586
+ const ELLIPSIS = "\n…[truncated]…\n";
587
+ /**
588
+ * 按 **UTF-8 字节**量一份 JSON 的大小。
589
+ *
590
+ * 为什么不数字符(`JSON.stringify(value).length`):预算要拦的是**出网体积**,
591
+ * 而中文一个字在 UTF-8 里占 3 字节、emoji 占 4 字节 —— 按字符数算会让一份全中文的历史
592
+ * 看起来「还没超」,实际传出去是它的三倍。
593
+ */
594
+ function byteLength(value) {
595
+ try {
596
+ return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
597
+ } catch {
598
+ return 0;
599
+ }
600
+ }
601
+ /**
602
+ * 把超过 [limit] 的字符串替换成「首尾保留」版本;返回新结构(不改入参)。
603
+ *
604
+ * 只处理字符串:数字/布尔/空值本来就不占体积,对象与数组由递归覆盖。
605
+ * 循环引用用 [seen] 兜住 —— 内核事件是 JSON 来的,理论上无环,
606
+ * 但这里是**出网路径**,宁可退化成「原样返回该子树」也不能抛异常把整个响应打掉。
607
+ */
608
+ function clampStrings(value, limit, seen) {
609
+ if (typeof value === "string") {
610
+ if (value.length <= limit) return {
611
+ value,
612
+ clamped: 0
613
+ };
614
+ const keep = Math.max(1, Math.min(limit, HISTORY_KEEP_CHARS));
615
+ return {
616
+ value: `${value.slice(0, keep)}${ELLIPSIS}${value.slice(-keep)}`,
617
+ clamped: 1
618
+ };
619
+ }
620
+ if (value === null || typeof value !== "object") return {
621
+ value,
622
+ clamped: 0
623
+ };
624
+ if (seen.has(value)) return {
625
+ value,
626
+ clamped: 0
627
+ };
628
+ seen.add(value);
629
+ let clamped = 0;
630
+ if (Array.isArray(value)) return {
631
+ value: value.map((item) => {
632
+ const result = clampStrings(item, limit, seen);
633
+ clamped += result.clamped;
634
+ return result.value;
635
+ }),
636
+ clamped
637
+ };
638
+ const source = value;
639
+ const next = {};
640
+ for (const [key, item] of Object.entries(source)) {
641
+ const result = clampStrings(item, limit, seen);
642
+ clamped += result.clamped;
643
+ next[key] = result.value;
644
+ }
645
+ return {
646
+ value: next,
647
+ clamped
648
+ };
649
+ }
650
+ /**
651
+ * 按预算裁剪 history 事件:先逐条限幅,再从最旧端丢弃,直到整份落在预算内。
652
+ *
653
+ * 丢弃**从最旧端**开始是刻意的:客户端永远从最新往回看,翻历史靠 `beforeSeq`。
654
+ * 若从最新端丢,用户会看到「刚发生的事没了」,那才是真的坏。
655
+ */
656
+ function clampHistoryEvents(events, byteBudget = HISTORY_BYTE_BUDGET, perEventBudget = HISTORY_EVENT_BYTE_BUDGET) {
657
+ let clampedStrings = 0;
658
+ const prepared = events.map((entry) => {
659
+ if (byteLength(entry) <= perEventBudget) return entry;
660
+ const result = clampStrings(entry, perEventBudget, /* @__PURE__ */ new WeakSet());
661
+ if (result.clamped === 0) return entry;
662
+ clampedStrings += result.clamped;
663
+ const marker = { truncated: true };
664
+ const clampedValue = result.value;
665
+ return clampedValue !== null && typeof clampedValue === "object" && !Array.isArray(clampedValue) ? {
666
+ ...clampedValue,
667
+ bridgeTruncated: marker
668
+ } : {
669
+ value: clampedValue,
670
+ bridgeTruncated: marker
671
+ };
672
+ });
673
+ const kept = [];
674
+ let bytes = 0;
675
+ for (let index = prepared.length - 1; index >= 0; index -= 1) {
676
+ const size = byteLength(prepared[index]);
677
+ if (kept.length > 0 && bytes + size > byteBudget) break;
678
+ bytes += size;
679
+ kept.push(prepared[index]);
680
+ }
681
+ kept.reverse();
682
+ return {
683
+ events: kept,
684
+ bytes,
685
+ droppedOldest: prepared.length - kept.length,
686
+ clampedStrings
687
+ };
688
+ }
689
+ /**
690
+ * 把预算结果套回 history 响应体(adapter 唯一要调用的入口)。
691
+ *
692
+ * 抽成纯函数而不是写在 adapter 里,是因为这几行决定**客户端会不会误判「到底了」**:
693
+ * 丢了最旧事件却不把 `hasMore` 置真,用户就再也翻不回更早的历史;
694
+ * 而裁剪过却不给 `truncated` 标记,客户端会把被截断的正文当完整正文展示。
695
+ * 放在这里可以直接断言,不必为了测四行逻辑去复刻 adapter 的 principal/database/roots 夹具。
696
+ */
697
+ function applyHistoryBudget(raw, byteBudget = HISTORY_BYTE_BUDGET, perEventBudget = HISTORY_EVENT_BYTE_BUDGET) {
698
+ const clamped = clampHistoryEvents(raw.events, byteBudget, perEventBudget);
699
+ const touched = clamped.droppedOldest > 0 || clamped.clampedStrings > 0;
700
+ return {
701
+ ...raw,
702
+ events: clamped.events,
703
+ hasMore: raw.hasMore === true || clamped.droppedOldest > 0,
704
+ ...touched ? {
705
+ bytes: clamped.bytes,
706
+ truncated: true
707
+ } : {}
708
+ };
709
+ }
710
+ //#endregion
135
711
  //#region src/host/path-policy.ts
136
712
  const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
137
713
  function validateRelativePath(value) {
@@ -228,7 +804,21 @@ var DshChatAdapter = class {
228
804
  this.database = database;
229
805
  this.commandServices = commandServices;
230
806
  }
231
- async listSessions(principal) {
807
+ /**
808
+ * 列出会话。
809
+ *
810
+ * `options.archived === true` 时返回**已归档**的那批;默认返回未归档的。
811
+ *
812
+ * 为什么需要这个参数:内核把「归档」实现为**从所有分组界面隐藏**,本桥默认照做 ——
813
+ * 于是用户在手机上一归档,那条会话就**再也看不到**(连找都找不到),
814
+ * 「归档」变成了单向门。参数化之后客户端至少能提供一个「已归档」入口。
815
+ *
816
+ * **恢复(取消归档)不在本桥能力内**:适配层只声明了内核的 `archiveSession`,
817
+ * 内核是否提供 unarchive 从本仓**不可证** —— `node_modules` 下没有 `@deepseek-ai/*`
818
+ * (那些包随 DSH 主包安装)。按 `PITFALLS.md` §空结果的来源要验证,
819
+ * 这里只能写「不可证」,不能写「没有」。
820
+ */
821
+ async listSessions(principal, options = {}) {
232
822
  const [sessionsResponse, workspaceResponse] = await Promise.all([this.api.sessions.list({
233
823
  rpcId: randomUUID(),
234
824
  payload: {}
@@ -243,7 +833,7 @@ var DshChatAdapter = class {
243
833
  const roots = this.authorizedRoots(principal);
244
834
  const visible = [];
245
835
  for (const session of value.items) {
246
- if (archivedSessionIds.has(session.sessionId)) continue;
836
+ if (archivedSessionIds.has(session.sessionId) !== (options.archived === true)) continue;
247
837
  const absoluteCwd = session.cwd;
248
838
  if (absoluteCwd === void 0) continue;
249
839
  const match = matchRoot(roots, absoluteCwd);
@@ -387,10 +977,13 @@ var DshChatAdapter = class {
387
977
  ...options.beforeSeq === void 0 ? {} : { beforeSeq: options.beforeSeq },
388
978
  ...options.maxMessages === void 0 ? {} : { maxMessages: options.maxMessages }
389
979
  };
390
- return unwrapDsh(await this.api.sessions.history({
391
- rpcId: randomUUID(),
392
- payload
393
- }));
980
+ return {
981
+ ...applyHistoryBudget(unwrapDsh(await this.api.sessions.history({
982
+ rpcId: randomUUID(),
983
+ payload
984
+ }))),
985
+ historyFormatVersion: 1
986
+ };
394
987
  }
395
988
  async prompt(principal, sessionId, parts, mode, clientTimeZone, locale) {
396
989
  await this.requireSession(principal, sessionId);
@@ -523,6 +1116,49 @@ var DshChatAdapter = class {
523
1116
  } catch {}
524
1117
  return records.sort((left, right) => left.requestedAt - right.requestedAt).map((record) => publicApproval(record, findToolCallDetail(history, record.callId)));
525
1118
  }
1119
+ /**
1120
+ * 连接建立时重放的待处理审批事件。
1121
+ *
1122
+ * ## 为什么需要
1123
+ *
1124
+ * 「连接后要不要主动查一次 REST」是**客户端自己的选择**:我方 App 会
1125
+ * (`HarnessViewModel` 的 `connection` 回调里刷新历史/审批/提问/命令),
1126
+ * 但协议不该把「审批不丢」寄托在这一点上 —— 客户端若只在收到事件时才刷新,
1127
+ * 重连窗口里产生的审批就会静默消失:内核阻塞着等决定,手机上却一片安静。
1128
+ * 重放把这件事变成**服务端保证**;对已经在连接后刷新的客户端,它只是一次幂等冗余。
1129
+ *
1130
+ * 对标第三方移动网关:它在**连接建立**与**订阅**两个触发点重发仍待处理的提问/审批,
1131
+ * 客户端按 id 去重。我们这里给事件加 `data.replayed = true`,
1132
+ * 客户端可以据此跳过「新卡片」动画,也可以完全忽略(刷新本身是幂等的)。
1133
+ *
1134
+ * ## 为什么先只做审批
1135
+ *
1136
+ * 审批是**阻塞内核**的那一类:漏掉它,Agent 会一直等;提问不阻塞(Agent 已经继续跑)。
1137
+ * 提问侧的待处理数据在 `dsh-0.2-bridge` 的 `QuestionBridge.pending` 里,
1138
+ * 要重放得先通过 `apiProxy` 暴露一个「全部会话」入口 —— 那是独立的一小步,
1139
+ * 不塞进本次改动里凑数。
1140
+ */
1141
+ async replayPendingApprovals(principal) {
1142
+ const allowed = [];
1143
+ for (const record of this.pendingApprovals.values()) if (await this.isSessionAuthorized(principal, record.sessionId)) allowed.push(record);
1144
+ return approvalReplayEvents(allowed);
1145
+ }
1146
+ /**
1147
+ * 连接建立时重放的**挂起提问**事件。
1148
+ *
1149
+ * 与 [replayPendingApprovals] 同一动机:把「新连接一定能拿到仍待处理的东西」
1150
+ * 变成**服务端保证**,而不是每个客户端各自的义务(论证与纠错记录见该方法的 KDoc)。
1151
+ *
1152
+ * 两者一个区别:提问**不阻塞内核**(Agent 已经继续跑),漏掉它的代价小一些;
1153
+ * 但「卡片不出现、用户以为没问题」同样是坏体验,所以对称补上。
1154
+ * 数据来自适配层的 `userQuestions.pendingAll()` —— 老实现不提供时跳过(可选方法),不报错。
1155
+ */
1156
+ async replayPendingQuestions(principal) {
1157
+ const pending = this.api.userQuestions.pendingAll?.() ?? [];
1158
+ const allowed = [];
1159
+ for (const item of pending) if (await this.isSessionAuthorized(principal, item.sessionId)) allowed.push(item);
1160
+ return questionReplayEvents(allowed);
1161
+ }
526
1162
  async decideApproval(principal, sessionId, approvalId, outcome) {
527
1163
  await this.requireSession(principal, sessionId);
528
1164
  const key = approvalKey(sessionId, approvalId);
@@ -797,6 +1433,20 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
797
1433
  ...typeof payload.outcome === "string" ? { outcome: payload.outcome } : {}
798
1434
  }
799
1435
  };
1436
+ if (payloadType === "session/snapshot" && typeof payload.sessionId === "string") {
1437
+ const cursor = typeof payload.cursor === "number" ? payload.cursor : void 0;
1438
+ return {
1439
+ id: `${payload.sessionId}:snapshot:${cursor ?? "unknown"}`,
1440
+ type: "chat.session.snapshot",
1441
+ time,
1442
+ data: {
1443
+ sessionId: payload.sessionId,
1444
+ ...cursor === void 0 ? {} : { cursor },
1445
+ ...typeof payload.hasMore === "boolean" ? { hasMore: payload.hasMore } : {},
1446
+ ...typeof payload.recordCount === "number" ? { recordCount: payload.recordCount } : {}
1447
+ }
1448
+ };
1449
+ }
800
1450
  if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$1(payload.event)) {
801
1451
  const event = payload.event;
802
1452
  const eventType = typeof event.type === "string" ? event.type : "unknown";
@@ -820,8 +1470,8 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
820
1470
  kind,
821
1471
  text: rawChunk.text,
822
1472
  index: rawChunk.index,
823
- ...typeof assistantData.turn === "number" ? { turn: assistantData.turn } : {},
824
- ...typeof assistantData.step === "number" ? { step: assistantData.step } : {}
1473
+ turn: typeof assistantData.turn === "number" ? assistantData.turn : 0,
1474
+ step: typeof assistantData.step === "number" ? assistantData.step : 0
825
1475
  }
826
1476
  };
827
1477
  }
@@ -840,6 +1490,19 @@ function normalizeDshEvent(frame, fallback, time = Date.now()) {
840
1490
  }
841
1491
  };
842
1492
  }
1493
+ if (eventType === "deliverables/presented") {
1494
+ const data = isRecord$1(payload.event) ? payload.event.data : void 0;
1495
+ const files = isRecord$1(data) && Array.isArray(data.files) ? data.files : [];
1496
+ return {
1497
+ id: `${payload.sessionId}:deliverables:${seq ?? files.length}`,
1498
+ type: "chat.deliverables.presented",
1499
+ time,
1500
+ data: {
1501
+ ...base,
1502
+ files
1503
+ }
1504
+ };
1505
+ }
843
1506
  if (eventType === "turn/start" || eventType === "turn/end") {
844
1507
  if (eventType === "turn/end") {
845
1508
  const failure = turnFailureOf(event);
@@ -954,6 +1617,53 @@ function approvalKey(sessionId, approvalId) {
954
1617
  function approvalRisk(reason) {
955
1618
  return reason?.toLowerCase().includes("danger-full-access") === true ? "full-access" : "standard";
956
1619
  }
1620
+ /**
1621
+ * 把待处理审批记录转成可重放的事件(纯函数,好测)。
1622
+ *
1623
+ * 抽出来的理由:这几行决定「重连后手机能不能看见那张卡」,而它本身与授权、IO 无关;
1624
+ * 放在 `replayPendingApprovals` 里就得为了测它去复刻 principal/database 整套夹具。
1625
+ *
1626
+ * 按 `requestedAt` 升序:先申请的排前面,客户端依次刷新时不会因为顺序错乱而闪。
1627
+ * `id` 与实时事件保持同一形状(`<approvalId>:requested`),
1628
+ * 于是「同一审批」在实时与重放两条路径上对客户端是**同一个事件 id**(便于去重)。
1629
+ */
1630
+ function approvalReplayEvents(records, time = Date.now()) {
1631
+ return [...records].sort((left, right) => left.requestedAt - right.requestedAt).map((record) => ({
1632
+ id: `${record.approvalId}:requested`,
1633
+ type: "chat.approval.requested",
1634
+ time,
1635
+ data: {
1636
+ sessionId: record.sessionId,
1637
+ approvalId: record.approvalId,
1638
+ toolName: record.toolName,
1639
+ risk: approvalRisk(record.reason),
1640
+ replayed: true
1641
+ }
1642
+ }));
1643
+ }
1644
+ /**
1645
+ * 把挂起中的提问记录转成可重放的事件(纯函数,好测)。
1646
+ *
1647
+ * 事件类型与**实时路径**一致(`chat.userQuestions/requested`,由 `normalizeDshEvent`
1648
+ * 末段的 `chat.${payloadType}` 兜底产生),data 字段也保持同一组,只多 `replayed: true`。
1649
+ * `id` 用 `<callId>:requested`,与实时事件的 id 形状不冲突(实时用帧 rpcId)——
1650
+ * 客户端按 `callId` 去重,两条路径指向同一道题。
1651
+ */
1652
+ function questionReplayEvents(items, time = Date.now()) {
1653
+ return items.map((item) => ({
1654
+ id: `${item.callId}:requested`,
1655
+ type: "chat.userQuestions/requested",
1656
+ time,
1657
+ data: {
1658
+ type: "userQuestions/requested",
1659
+ sessionId: item.sessionId,
1660
+ callId: item.callId,
1661
+ rpcId: item.rpcId,
1662
+ questions: item.questions,
1663
+ replayed: true
1664
+ }
1665
+ }));
1666
+ }
957
1667
  function publicApproval(record, detail) {
958
1668
  const reason = record.reason === void 0 ? void 0 : redactApprovalText(record.reason, 500);
959
1669
  const safeDetail = detail === void 0 ? void 0 : redactApprovalText(detail, 1200);
@@ -1898,6 +2608,16 @@ var SessionEventRelay = class {
1898
2608
  for await (const rawFrame of stream) {
1899
2609
  if (signal.aborted) break;
1900
2610
  const frame = rawFrame;
2611
+ if (frame.type === "snapshot") {
2612
+ this.mux.push({
2613
+ type: "session/snapshot",
2614
+ sessionId,
2615
+ ...typeof frame.cursor === "number" ? { cursor: frame.cursor } : {},
2616
+ ...typeof frame.hasMore === "boolean" ? { hasMore: frame.hasMore } : {},
2617
+ recordCount: Array.isArray(frame.records) ? frame.records.length : 0
2618
+ });
2619
+ continue;
2620
+ }
1901
2621
  if (frame.type === "event") {
1902
2622
  this.mux.push({
1903
2623
  type: "session/event",
@@ -1909,8 +2629,8 @@ var SessionEventRelay = class {
1909
2629
  if (frame.type !== "assistant-stream" || frame.frame === void 0) continue;
1910
2630
  const inner = frame.frame;
1911
2631
  if (inner.type === "start") {
1912
- turn = typeof inner.turn === "number" ? inner.turn : void 0;
1913
- step = typeof inner.step === "number" ? inner.step : void 0;
2632
+ turn = typeof inner.turn === "number" ? inner.turn : 0;
2633
+ step = typeof inner.step === "number" ? inner.step : 0;
1914
2634
  continue;
1915
2635
  }
1916
2636
  if (inner.type !== "chunk") continue;
@@ -1919,7 +2639,6 @@ var SessionEventRelay = class {
1919
2639
  sessionId,
1920
2640
  event: {
1921
2641
  type: "assistant/chunk",
1922
- seq: typeof inner.index === "number" ? inner.index : 0,
1923
2642
  time: typeof inner.time === "number" ? inner.time : Date.now(),
1924
2643
  data: {
1925
2644
  chunk: inner.chunk,
@@ -2164,6 +2883,22 @@ var QuestionBridge = class {
2164
2883
  }
2165
2884
  return out;
2166
2885
  }
2886
+ /**
2887
+ * **全部会话**挂起中的提问(连接重放用)。
2888
+ *
2889
+ * 与 `pendingFor` 的唯一区别是「不过滤会话」:重放要遍历所有会话,
2890
+ * 再按每条连接的授权逐条过滤 —— 授权判断在 chat-adapter 侧做,这里只给事实。
2891
+ * 带上 `rpcId` 是为了让重放事件与实时事件**字段集一致**:
2892
+ * 客户端按 `callId` 回答,但字段对齐能避免两条路径悄悄漂移(见 chat-adapter 的契约钉子测试)。
2893
+ */
2894
+ pendingAll() {
2895
+ return [...this.pending.entries()].map(([rpcId, entry]) => ({
2896
+ rpcId,
2897
+ sessionId: entry.sessionId,
2898
+ callId: entry.callId,
2899
+ questions: [...entry.questions]
2900
+ }));
2901
+ }
2167
2902
  };
2168
2903
  /**
2169
2904
  * 答案批次的最小校验(路由层已校验过一遍,这里是防御性第二道)。
@@ -2659,6 +3394,7 @@ function createDsh02ApiProxy(host, options = {}) {
2659
3394
  })
2660
3395
  },
2661
3396
  userQuestions: {
3397
+ pendingAll: () => questions.pendingAll(),
2662
3398
  list: (request) => call(async () => {
2663
3399
  const { sessionId } = request.payload;
2664
3400
  const bridged = questions.pendingFor(sessionId);
@@ -3713,8 +4449,97 @@ async function inspectResolvedPath(root, wirePath) {
3713
4449
  };
3714
4450
  }
3715
4451
  //#endregion
3716
- //#region src/shared/version.ts
3717
- const PLUGIN_VERSION = "2.3.7";
4452
+ //#region src/host/wire-json.ts
4453
+ /**
4454
+ * 出网 JSON 的安全序列化:把**孤立 UTF-16 代理项**换成 U+FFFD。
4455
+ *
4456
+ * ## 为什么需要这一层(实发场景)
4457
+ *
4458
+ * DSH 内核在生成事件摘要时会按**字符数**截断字符串。若截断点正好落在
4459
+ * 一个代理对(emoji、部分 CJK 扩展字、某些工具输出的花括号表情)中间,
4460
+ * 字符串里就只剩**半个**代理项。`JSON.stringify` 会把它老实转义成 `\udXXX`,
4461
+ * 于是线上出现一份「语法合法、语义非法」的 JSON:
4462
+ * JavaScript 侧照常解析,而 **Swift/Foundation 的 JSONDecoder 会直接拒绝整帧**。
4463
+ * 症状极具误导性 —— 一条工具输出的半个 emoji,把整条 WebSocket 连接打死,
4464
+ * 客户端表现为「突然不再更新」,日志里只有一句解码失败。
4465
+ *
4466
+ * ## 为什么在传输层做,而不是在产生摘要的地方做
4467
+ *
4468
+ * 摘要不只一处(chat-adapter 的工具输出、内核自己拼的标题、历史重放…),
4469
+ * 逐个补是打地鼠;而**出网**只有两个口(HTTP JSON 响应与 WebSocket 帧),
4470
+ * 在这里做一次就等于全覆盖,且未来新增事件类型自动受益。
4471
+ *
4472
+ * ## 为什么不用正则
4473
+ *
4474
+ * 「孤立代理项」的判定需要看**相邻字符**。带后向断言的正则在超长字符串上
4475
+ * 有性能与兼容两个问题(KMP/Server 端行为不一致),而这是一条会被每个事件
4476
+ * 走到的热路径。这里用单遍字符扫描:O(n)、无递归、无回溯,且对合法代理对
4477
+ * 原样放行(emoji 不会被吃掉)。
4478
+ *
4479
+ * 对标:第三方移动网关 `dsh-plugin-mobile-gateway` 的 `lib/wire-json.mjs` 做了同一件事
4480
+ * (它只用了 7 行 —— 我们这边多出来的部分全是给「为什么不那样做」留的注释)。
4481
+ */
4482
+ /** 孤儿代理项的统一替身:Unicode 的「替换字符」。 */
4483
+ const REPLACEMENT = "�";
4484
+ const HIGH_SURROGATE_START = 55296;
4485
+ const HIGH_SURROGATE_END = 56319;
4486
+ const LOW_SURROGATE_START = 56320;
4487
+ const LOW_SURROGATE_END = 57343;
4488
+ /**
4489
+ * 把字符串里所有**孤立**的 UTF-16 代理项替换为 U+FFFD;合法代理对原样保留。
4490
+ *
4491
+ * 无代理项时**返回原引用**(快路径):绝大多数事件不含代理项,
4492
+ * 这条路径应当零分配 —— 它跑在每次广播上。
4493
+ */
4494
+ function sanitizeLoneSurrogates(text) {
4495
+ let firstBad = -1;
4496
+ for (let i = 0; i < text.length; i += 1) {
4497
+ const code = text.charCodeAt(i);
4498
+ if (code < HIGH_SURROGATE_START || code > LOW_SURROGATE_END) continue;
4499
+ if (code <= HIGH_SURROGATE_END) {
4500
+ const next = text.charCodeAt(i + 1);
4501
+ if (next >= LOW_SURROGATE_START && next <= LOW_SURROGATE_END) {
4502
+ i += 1;
4503
+ continue;
4504
+ }
4505
+ }
4506
+ firstBad = i;
4507
+ break;
4508
+ }
4509
+ if (firstBad < 0) return text;
4510
+ let out = text.slice(0, firstBad);
4511
+ for (let i = firstBad; i < text.length; i += 1) {
4512
+ const code = text.charCodeAt(i);
4513
+ if (code < HIGH_SURROGATE_START || code > LOW_SURROGATE_END) {
4514
+ out += text.charAt(i);
4515
+ continue;
4516
+ }
4517
+ if (code <= HIGH_SURROGATE_END) {
4518
+ const next = text.charCodeAt(i + 1);
4519
+ if (next >= LOW_SURROGATE_START && next <= LOW_SURROGATE_END) {
4520
+ out += text.charAt(i) + text.charAt(i + 1);
4521
+ i += 1;
4522
+ continue;
4523
+ }
4524
+ }
4525
+ out += REPLACEMENT;
4526
+ }
4527
+ return out;
4528
+ }
4529
+ /**
4530
+ * 出网的 `JSON.stringify`:先消毒所有**字符串值**,再交给原生序列化。
4531
+ *
4532
+ * 用 `replacer` 而不是「先深拷贝再清洗」,是为了不复制事件对象 ——
4533
+ * 工具结果动辄几百 KB,多一次深拷贝就是白烧一次内存与 GC。
4534
+ *
4535
+ * **已知边界**:`replacer` 不会经过**对象的键**,所以键名里的孤立代理项不会被修。
4536
+ * 这是刻意接受的:本项目的键要么是协议里固定的 ASCII 字段名,要么由内核以
4537
+ * 固定字面量拼出,不存在「按字符数截断的键」。真出现这种键,应从产生侧修,
4538
+ * 而不是在这里做全量 JSON 词法扫描(那会让每次广播都多一遍 O(n) 解析)。
4539
+ */
4540
+ function stringifyWire(value) {
4541
+ return JSON.stringify(value, (_key, item) => typeof item === "string" ? sanitizeLoneSurrogates(item) : item);
4542
+ }
3718
4543
  //#endregion
3719
4544
  //#region src/host/router.ts
3720
4545
  var ApiRouter = class {
@@ -3979,7 +4804,8 @@ var ApiRouter = class {
3979
4804
  if (pathname === "/api/v1/chat/sessions") {
3980
4805
  if (method === "GET") {
3981
4806
  this.options.auth.requireScope(principal, "chat.read");
3982
- sendJson(res, 200, { items: await this.options.chat.listSessions(principal) });
4807
+ const archived = url.searchParams.get("archived") === "true";
4808
+ sendJson(res, 200, { items: await this.options.chat.listSessions(principal, { archived }) });
3983
4809
  return;
3984
4810
  }
3985
4811
  if (method === "POST") {
@@ -4351,6 +5177,42 @@ var ApiRouter = class {
4351
5177
  res.end();
4352
5178
  return;
4353
5179
  }
5180
+ if (this.options.tunnels !== void 0 && pathname === "/manage/cloudflare/status") {
5181
+ if (method === "GET" || method === "HEAD") {
5182
+ sendJson(res, 200, this.options.tunnels.publicState);
5183
+ return;
5184
+ }
5185
+ }
5186
+ if (this.options.tunnels !== void 0 && pathname === "/manage/cloudflare/activity") {
5187
+ if (method === "GET" || method === "HEAD") {
5188
+ sendJson(res, 200, this.options.tunnels.activityState);
5189
+ return;
5190
+ }
5191
+ }
5192
+ if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/create") {
5193
+ const binDir = this.options.cloudflaredBinDir;
5194
+ let exe;
5195
+ try {
5196
+ exe = await ensureCloudflared(binDir ?? this.options.database.dataDir, (a) => {
5197
+ this.options.tunnels.reportActivity(a);
5198
+ });
5199
+ } catch (cause) {
5200
+ this.options.tunnels.clearActivity();
5201
+ throw new ApiError(502, "TUNNEL_DOWNLOAD_FAILED", cause instanceof Error ? cause.message : String(cause));
5202
+ }
5203
+ const port = this.options.remote.status().configuredPort ?? 3090;
5204
+ const state = await this.options.tunnels.startQuick(exe, port);
5205
+ this.options.tunnels.clearActivity();
5206
+ this.options.database.audit("admin", "loopback-admin", "cloudflare.create", void 0, void 0, {}, requestId);
5207
+ sendJson(res, 201, state);
5208
+ return;
5209
+ }
5210
+ if (this.options.tunnels !== void 0 && method === "POST" && pathname === "/manage/cloudflare/stop") {
5211
+ const state = this.options.tunnels.stop();
5212
+ this.options.database.audit("admin", "loopback-admin", "cloudflare.stop", void 0, void 0, {}, requestId);
5213
+ sendJson(res, 200, state);
5214
+ return;
5215
+ }
4354
5216
  throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
4355
5217
  }
4356
5218
  async dispatchEntries(req, res, url, principal, root, requestId) {
@@ -4478,7 +5340,7 @@ async function readBytes(req, maxBytes) {
4478
5340
  return Buffer.concat(chunks);
4479
5341
  }
4480
5342
  function sendJson(res, status, body) {
4481
- const encoded = Buffer.from(JSON.stringify(body));
5343
+ const encoded = Buffer.from(stringifyWire(body));
4482
5344
  res.writeHead(status, {
4483
5345
  "Content-Type": "application/json; charset=utf-8",
4484
5346
  "Content-Length": encoded.length
@@ -4870,17 +5732,42 @@ var RemoteApiServer = class {
4870
5732
  webSocket.on("error", () => {
4871
5733
  this.sockets.delete(webSocket);
4872
5734
  });
4873
- webSocket.send(JSON.stringify({
5735
+ webSocket.send(stringifyWire({
4874
5736
  id: randomUUID(),
4875
5737
  type: "connection.ready",
4876
5738
  time: Date.now(),
4877
- data: {}
5739
+ data: connectionReadyData()
4878
5740
  }));
5741
+ this.replayPendingInteractions(webSocket, principal);
4879
5742
  });
4880
5743
  } catch {
4881
5744
  socket.destroy();
4882
5745
  }
4883
5746
  }
5747
+ /**
5748
+ * 把待处理的**审批与提问**补推给刚建立的这条连接。
5749
+ *
5750
+ * 为什么要有这一步:「连接后要不要主动查一次 REST」是**每个客户端自己的选择**。
5751
+ * 我方 App 会(`HarnessViewModel` 的 `connection` 回调里刷新历史/审批/提问/命令),
5752
+ * 但协议不该把「审批不丢」寄托在这一点上:客户端若只在收到事件时才刷新,
5753
+ * 重连窗口里产生的审批就会静默消失(内核阻塞着等决定、手机上却一片安静)。
5754
+ * 重放把「新连接一定能拿到仍待处理的审批」变成**服务端保证**,而不是客户端的义务。
5755
+ *
5756
+ * 三个刻意的取舍:
5757
+ * - **不 await**:重放要按会话做授权过滤(异步),握手不能为它排队;
5758
+ * - **只发这一条连接**:不走 `broadcast`,避免每次有人重连就骚扰所有在线设备;
5759
+ * - **失败静默**:重放是补救措施,它出错不该影响这条连接正常收事件。
5760
+ * 客户端侧「连接后/亮屏后主动刷新」仍是兜底,但不再是唯一依赖。
5761
+ */
5762
+ async replayPendingInteractions(socket, principal) {
5763
+ try {
5764
+ const events = [...await this.options.chat.replayPendingApprovals(principal), ...await this.options.chat.replayPendingQuestions(principal)];
5765
+ for (const event of events) {
5766
+ if (socket.readyState !== WebSocket.OPEN) return;
5767
+ socket.send(stringifyWire(event));
5768
+ }
5769
+ } catch {}
5770
+ }
4884
5771
  async broadcast(event) {
4885
5772
  for (const [socket, principal] of this.sockets) {
4886
5773
  if (socket.readyState !== WebSocket.OPEN) continue;
@@ -4901,7 +5788,7 @@ var RemoteApiServer = class {
4901
5788
  this.sockets.set(socket, currentPrincipal);
4902
5789
  }
4903
5790
  if (!await this.eventAllowed(currentPrincipal, event)) continue;
4904
- socket.send(JSON.stringify(event));
5791
+ socket.send(stringifyWire(event));
4905
5792
  }
4906
5793
  }
4907
5794
  async eventAllowed(principal, event) {
@@ -5410,6 +6297,8 @@ async function apply(ctx, config = {}) {
5410
6297
  chat,
5411
6298
  settings,
5412
6299
  remote,
6300
+ tunnels: new CloudflaredTunnelManager(),
6301
+ cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
5413
6302
  maxUploadBytes,
5414
6303
  imageFallbackDir: imageHandoffDir
5415
6304
  });