Skip to content
dsh.fish
Bundle

dsh-plugin-mobile-bridge

LAN-only mobile surface for DeepSeek Harness: import photos from a phone and drive a Session with live feedback.

Source
jxboop
License
MIT
Updated
Updated 8 days ago

Readme

# dsh-plugin-mobile-bridge

> 给 [DeepSeek Harness](https://github.com/deepseek-ai) 用的手机端桥接插件:
> **在手机上导入图片、下发任务、实时看输出**,从同一个 WiFi、USB 共享、或者外网隧道都行。

在电脑上跑 DSH,在手机上用。不用装 App,打开浏览器就是控制台。

---

## 它能做什么

- **手机直接发图**(截图、拍的照片)给 DSH,不用再经过微信/QQ 中转;**动图、HEIC/BMP 等格式、视频也能发**(图片先在手机上压到长边 1600,大文件分片上传)
- **「文件」键:任意文件都能传**(PDF / 文档 / 压缩包 / 代码 / 音频…),**不限制类型** —— 原样传到电脑,agent 拿到的是**路径**(模型看不了这些格式,但 agent 能用工具读它、解压、转码、跑脚本)。一次最多 **3 个**(宿主的上限;手机上会拦住第 4 个并说清原因,不会让它悄悄消失)
- **电脑给的素材在手机上直接看、直接存**(agent 交付的「交付物」也会列出来:图片给缩略图):对话里出现图片/视频路径(脚本截的图、`image_generate` 出的图、导出的视频…)会自动变成缩略图,点开全屏,**长按就能"存储到照片"**;附件也走真 URL(`data:` 在 iOS 上长按没有"存储到照片")
- **自定义背景(手机上自己换)**:点工具栏「背景」→ 选一张**图片 / 动图(GIF)/ 视频** → 传到电脑、当场铺满整个手机界面,不用碰电脑也不用重载插件;「去掉背景」一键还原。整套界面是**毛玻璃**风格(对话气泡透明、面板半透明 + blur,冷色冰蓝调子),跟你挑的背景同一个调子
- **新建任务、下发指令、取消运行**,实时看思考过程和工具调用
- **它跑着的时候能插话**:运行中右边的按钮变成「插话」,说的话排进队列、这一轮结束就接着处理,不用干等它跑完才敢打字(桌面端的 `steer` = 打断当前回合,手机上默认不用它:宁可等一轮,也不要把你正在跑的事打断)
- **发出去的话立刻看得见、还能撤回**:排队中的消息先在对话里显示成一条虚线气泡(带「排队中」状态),点「撤回」真的把它从队列里拿掉(DSH `updateQueue` 的 `remove`);已经开始处理的会如实告诉你撤不回来,绝不假装成功
- **第一次连上会讲一遍怎么用**(点「开始使用」收起);顶栏有「**说明**」键随时能再看。这篇说明按**设备**记"看过没有"(不是按 IP)—— 所以换一台手机、同学连进来都会各讲一遍,不会被人"代表"掉
- **按会话回看**历史;每条消息气泡外侧带一个**看得见的「复制」**(点一下复制这条的正文 —— agent 的回复只复制正文、不带思考过程;长按气泡也能复制,但那个入口藏得深,没人会去猜)
- **会话能置顶、能标常用**(2.5.0 新增):点顶部那一行打开列表 —— **📌 置顶**(钉到最上面,重启手机还在)、**⭐ 常用**(自己打星)、以及手机按"你用得多的"自动排的前几个;点行切换、点 📌/⭐ 只改标记。这三份数据**只存在你这台手机里**,不碰电脑上的会话数据
- **顶端就写着当前模型、列表能删会话**(2.6.0 新增):最上面一行是**当前模型**(读宿主的真值,电脑端换过这里也跟着变,点开就能换);会话列表**默认不摆 agent 子会话和自检会话**(想看在底部勾「显示全部会话」),每行有 **🗑 删除** —— **要输 PIN 才删得掉**,正在跑的会话得再确认一次,删掉的记录在电脑端可以取消归档找回来
- **余额查询**(配合余额插件的同一份凭据)
- **地址自动排序**:USB 共享 / 蓝牙 PAN / 个人热点 / 局域网,哪个能通就把哪个排前面
- **外网可达**:推荐 Tailscale Funnel(一条固定的 `https://<机器名>.<tailnet>.ts.net/` 地址、开机自恢复、手机不用装任何 App);也附带 Cloudflare 快速隧道脚本做临时方案
- **手机上直接生图**(1.0 新增):「生图」键 → 写一句英文提示词 → 它不是直连生图后端,而是把这条指令交给**当前会话里的 `image_generate` 工具**去出图 —— 图因此落在会话里,能像别的素材一样在对话中看、点开、长按存相册。第一次点会把那句话先放进输入框让你过目,之后照发。**⚠️ 这需要电脑上装了 `image_generate` 工具(单独的插件,不在本仓库里)**,否则 agent 只会回一句"没有这个工具 / 没有生图模型" —— 那不是手机桥坏了,见下面《手机上直接生图》一节。
- **手机上看 / 改权限档位**(1.0 新增):权限面板 + 状态角标。**读不需要额外证明;收紧到只读也不拦**(往安全方向走不设障);但**放开档位必须重新输 PIN** —— 免得"捡到一台已解锁手机的人"直接把整台机器放开。**3.5.0 起,新建任务那张纸里也能先选**一档(建好的那一刻就按它跑;选「跟随默认」=什么都不改、不额外要 PIN)
- **任何 App 的「分享」都能进电脑**(3.6.0 新增):用 iOS **快捷指令**做一个"分享表"动作 POST 到桥的一条短链,之后 Safari / 相册 / 备忘录里点「分享」→ 选它,内容就进了电脑上**最近那个会话**。**页面上一个键都没加**;默认关 + 每次带 PIN(见下面《分享到桥》一节)
- **分享键还能给「网页版」**(4.2.0):一份**单文件、自带排版**的 `.html` 安装页(`GET /api/install?format=html` ✓),同学**直接浏览器打开就能读** ✓、**不需要你电脑在线** ✓;手机上不能直接分享文件时会**如实说**并退到**下载** ✓
- **📋 那张纸上还多了两样**(3.8.0,同样**没加新键**):**「最近放进去的」** —— 你放进电脑剪贴板的文字在手机本地留最近 20 条,点一条**只放进输入框**(不替你按发送);⚠️ **从电脑读回来的不进历史**(那可能是密码)。**🔤 取字** —— 截一张电脑屏幕交给电脑上的 agent 认字、存成一份**可搜索的**笔记(桥自己不认字)

> 版本变更记录见 [`CHANGELOG.md`](./CHANGELOG.md)。

---

## 想分享给同学?照这三步走

1. **同学先得有 DSH**(这个插件是扩展,不是独立程序;Node 22+)。
2. **一条命令装插件,然后重启 DSH**:

   ```bash
   dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge
   ```

   重启后日志里会打印**他自己那台电脑**的手机网址:

   ```
   [mobile-bridge] listening on 0.0.0.0:3081, PIN 123456
   [mobile-bridge] phone url: http://192.168.1.20:3081/ab12cd34ef56ab78/  (WLAN)
   ```

3. **手机浏览器打开那条网址**,输 PIN 就能用。桌面 DSH 左下角的徽标上也写着完整网址 + PIN。

两点务必说清楚:

- **PIN 和网址里的随机段是每台电脑各自生成的**(`~/.dsh/mobile-bridge.json`)——**别把自己的网址/PIN 发给别人**,那等于把你电脑的后门给对方。
- **同一个 WiFi(宿舍/家里)一般直接可用**;**校园网常常禁止客户端互访**,那就按下面「外网访问」走 Tailscale Funnel —— 地址固定、手机不用装 App,也还是只认那台电脑自己的 PIN。

---

## 装成「App」放在手机桌面上(推荐)

不需要 App Store、不需要越狱、不用装任何第三方东西 —— 用手机浏览器自带的
「添加到主屏幕」,就是一个带图标、全屏、能离线打开的 App:

**iPhone(Safari)**:打开手机桥的网址 → 底部「分享」按钮 → **添加到主屏幕** → 添加。
**Android(Chrome)**:打开网址 → 右上角菜单 → **安装应用 / 添加到主屏幕**。

装完从桌面图标点开:没有地址栏、没有浏览器按钮,启动有加载进度条,界面是毛玻璃那套;
后台断网也能打开外壳(页面走 network-first,永远拿最新的)。

图标是 `tools/make-icons.mjs` 生成的真 PNG(iOS 不认 SVG 图标),改了图标设计重跑一次即可:

```bash
node tools/make-icons.mjs
```

> 说明:**原生 App 做不了**(iOS 打包签名要 Mac + 开发者账号 + 上架审核),
> 而 PWA 在体验上已经等价:独立图标、全屏、启动图、离线外壳、无浏览器界面。

## 安装

**前置条件**:你得先有一个能正常运行的 DSH —— 这是个扩展插件,不是独立程序。需要 Node 22 以上(用的都是内置模块,没有构建步骤)。

### 1. 装插件

> **两个会直接卡住你的坑**(同学那边实测踩到,都写在这里):
>
> 1. **这条命令需要 Git。** pnpm 对 `github:` / `git+https:` 一律走 git 解析,**不会**自动改走
>    tarball;没装 Git 就是 `ERR_PNPM_GIT_RESOLVE_FAILED`。装一次
>    [Git for Windows](https://git-scm.com/download/win) 再重跑;不想装 Git 就用下面的 tar.gz 地址。
> 2. **必须是 1.8.2 及以上。** 1.8.0 / 1.8.1 的包里**缺 `lib/addresses.js`**(`files` 漏列),
>    装上去插件一加载就报找不到模块 —— 症状是**手机页面打不开、一直转圈**。
>    判断办法:看 `<profile>\node_modules\dsh-plugin-mobile-bridge\lib` 里有没有 `addresses.js`。

```bash
# 从 GitHub 安装(`link:` 只接受本地路径,网址要用 git+https 或 github: 简写)
dsh plugin --profile web add git+https://github.com/jxboop/dsh-plugin-mobile-bridge.git

# 等价写法
dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge

# 没有 Git?用 codeload 的 tar.gz 地址(这条路不需要 git)
dsh plugin --profile web add "https://codeload.github.com/jxboop/dsh-plugin-mobile-bridge/tar.gz/refs/heads/main"

# 从本地目录(改代码时用)
dsh plugin --profile web add link:/path/to/dsh-plugin-mobile-bridge
```

> **tar.gz 地址不会自己更新到最新提交**:它的版本串是"分支"而不是提交号,`add` 时 pnpm
> 会跳过已装的那一份。要更新得**先 remove 再 add**:
> ```bash
> dsh plugin --profile web remove dsh-plugin-mobile-bridge
> dsh plugin --profile web add "https://codeload.github.com/jxboop/dsh-plugin-mobile-bridge/tar.gz/refs/heads/main"
> ```
> (用 `github:` 那条则可以直接重跑 `add` 更新 —— pnpm 会重新解析到最新提交。)

装完**重启 DSH**,日志里会出现:

```
[mobile-bridge] listening on 0.0.0.0:3081, PIN 123456
[mobile-bridge] phone url: http://192.168.1.20:3081/ab12cd34ef56ab78/  (WLAN)
```

### 更新到最新版

**把上面那条安装命令原样再跑一遍就行**,不需要先卸载:

```bash
dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge
```

然后重启 DSH。(`dsh plugin` 是 pnpm 的转发器;对分支形式的 git 依赖,`add` 会重新解析到最新提交。实测 0.2.2 → 0.3.0 可直接升级。)想确认装到了哪一版:看 `<profile>/node_modules/dsh-plugin-mobile-bridge/package.json` 的 `version`。

> 版本号从 **0.9.4** 起才是准的。0.7.0~0.9.3 那一段只在提交标题里写了版本,`package.json`
> 一直停在 `0.6.7` —— 所以那几版**看不出新旧**,照"重跑一遍安装命令 + 重启"来判断即可。

**版本怎么排(2026-10-09 起)**:**加功能 = 进一位小数**(1.3.17 之后的下一个功能是 **1.4.0**,
再下一个 1.5.0…);**修 bug / 只改文案 = 补丁位**(1.4.1、1.4.2…)。一次提交里两者都有时按功能算。
每个版本都带 `package.json` 版本、`CHANGELOG.md` 一段、`git tag v<版本>` 和 Release。

### 2. 首次启动会自动生成配置

`~/.dsh/mobile-bridge.json`(Windows:`%USERPROFILE%\.dsh\mobile-bridge.json`):

```json
{
  "version": 1,
  "port": 3081,
  "pin": "123456",
  "pathSecret": "ab12cd34ef56ab78",
  "tokenTtlHours": 12,
  "bindTokenToIp": false,
  "elevationMinutes": 60
}
```

**`pin` 和 `pathSecret` 都是自动生成的,别外传。** 在 DSH 界面左下角的徽标上能直接看到完整网址和 PIN。

| 字段 | 默认 | 说明 |
|---|---|---|
| `port` | 3081 | 监听端口 |
| `pin` | 随机 6 位 | 登录密码 |
| `pathSecret` | 随机 16 位十六进制 | **所有路由都藏在这个随机段后面**,没有它一律 404 |
| `tokenTtlHours` | 12 | 登录令牌有效期(上限 14 天) |
| `bindTokenToIp` | false | 是否**严格**绑定令牌的来源 IP。默认 `false`:地址变化只**记录告警**(在 `/api/bootstrap` 的 `foreignUses` 里可见),不吊销登录。设 `true` 则换地址即吊销 —— **移动网络下不可用**(运营商 NAT、IPv6 隐私扩展会不停换地址,手机会被反复踢下线),只适合固定网络 |
| `elevationMinutes` | 60 | 输一次 PIN 后,多久内可以"指挥 DSH 干活" |
| `crashProbe` | false | **排查用,平时别开。** 设为 `true` 会把宿主的 uncaughtException / exit 事件写进 `~/.dsh/dsh-crash.log`(含每 30 秒一条内存心跳)。它跑在宿主进程里、会注册全局处理器并持续写盘,所以默认关闭。 |
| `answerOnPhone` | false | 让**手机**回答 agent 的提问(`ask_user_question`)和工具审批。默认关:开着时"正在看这个会话的手机"会抢在桌面前面拿到提问,桌面要等 120 秒才轮到。没有手机在看这个会话时,无论开关如何都是桌面先拿到。 |

### 为什么过一会儿又让我输 PIN?

这是设计如此,不是掉线。手机上有两种状态:

- **已登录**(有 cookie):能看会话列表和聊天记录。令牌默认 12 小时有效,所以**重启电脑、重启 DSH 都不会把你踢出去**。
- **已提权**(最近输过一次 PIN):才能让这台电脑干活 —— 下发任务、取消、新建会话。

**提权默认保持一个小时**(`elevationMinutes` —— 2026-10-14 用户要求:从 15 分钟改成 60 分钟),过期后第一次动手会重新弹出 PIN 门;宿主重启后恢复的会话同样不带提权。觉得还不够就把它调大,上限 240 分钟:

```json
{ "elevationMinutes": 240 }
```

> 如果你看到"需要重新输入 PIN"却找不到输入框,那是 0.2.0 及更早版本的 bug(页面只认 401、不认服务器用来表达提权的 `403 + needPin`,于是提示了却没有门)。**0.2.1 起已修复**:过期时页面会直接把 PIN 门打开。


### 3. 放行防火墙(Windows)

首次启动会弹 Windows 防火墙提示。**只勾"专用网络"**即可(手机和电脑在同一 WiFi 时够用)。若要走外网隧道,勾"公用网络"。

---

## 外网访问(可选)

局域网之外也能连。三条路,**按这个顺序试**:手机也装 Tailscale(最稳)→ Tailscale Funnel(手机不用装东西)→ Cloudflare 快速隧道(临时)。

### 方案 A:手机也装 Tailscale(**最稳**,推荐先试)

电脑和手机都装 [Tailscale](https://tailscale.com/download)、用**同一个账号**登录,然后手机浏览器打开:

```
http://<电脑的 tailnet IP>:3081/<pathSecret>/
```

电脑的 tailnet IP 怎么拿:托盘图标点开就能看到(形如 `100.122.188.30`),或者命令行 `tailscale ip -4`。
`<pathSecret>` 是网址密钥段 —— DSH 窗口的日志里、或桌面 DSH 左下角徽标上都有完整网址。

**为什么它最稳**:流量走 tailnet(设备间端到端加密),**完全不经过 Funnel 的公网入口**。
2026-10-08 实测过一次:Funnel 的公网入口有两个 IP,其中一个偶发回不到机器
(外部 6 个国家的节点复测,一半报 `Broken pipe`)—— 手机就会间歇性"网络不通",
而同一时刻 tailnet 直连是好的。

代价:手机要多装一个 App(**iPhone 需要非中国区 Apple ID**),并且 Tailscale 要在后台常驻。

### 方案 B:Tailscale Funnel(地址永不变、开机自恢复、手机不用装东西)

**为什么推荐**:Cloudflare 免费隧道的域名**每次重启都变**,旧的永久失效;Funnel 给的是一条 `https://<机器名>.<tailnet>.ts.net/` 的**固定公网地址** —— Tailscale 服务随开机自启、配置存在 daemon 状态里,**重启电脑后什么都不用做,网址照旧**。而且它是标准 HTTPS,**手机上不用装任何客户端**(这点对 iPhone 尤其重要:Tailscale 的 iOS app 没有上架中国区 App Store)。

```powershell
# 1. 装 Tailscale 并登录:https://tailscale.com/download

# 2. 首次开 Funnel 需要去控制台同意一次(只此一次),命令会把链接打出来:
tailscale funnel 3081
#    Funnel is not enabled on your tailnet.
#    To enable, visit: https://login.tailscale.com/f/funnel?node=xxxxx

# 3. 同意之后再执行一次,就常驻后台了:
tailscale funnel --bg 3081
#    Available on the internet:
#    https://<机器名>.<tailnet>.ts.net/
#    |-- proxy http://127.0.0.1:3081

# 4. 手机访问(结尾必须带密钥段):
#    https://<机器名>.<tailnet>.ts.net/<pathSecret>/
```

不用时关掉:`tailscale funnel --https=443 off`

> ⚠️ **选了一条路就别同时跑 `tools/tunnel-start.ps1`** —— 它会把配置里的 `publicUrl` 改回那个每次都变的 cloudflared 域名。
>
> **安全上如实说**:Funnel 和免费隧道一样是**公网地址**,保护来自随机密钥段 + PIN 这两层;区别只是域名固定、不会更容易被猜到。要"只有自己的设备能进",就用**方案 A**(设备之间端到端加密、不经公网入口)。
>
> **Funnel 不稳时怎么办**:今天实测到的症状是"手机说网络不通",但电脑这边一切正常(`tools/tunnel-status.ps1` 也是 OK)。
> 先 `tailscale down` 再 `tailscale up`(不需要管理员)常常能救回来;实在不行重启电脑,或直接用方案 A。

### 方案 C:Cloudflare 免费快速隧道(临时用)

`tools/` 下有一套脚本:**不需要公网 IP、不需要改路由器、不需要注册账号**。代价是**域名每次重启都变**,旧的永久失效 —— 只适合临时开一下。

```powershell
# 1. 下载 cloudflared.exe 放到 tools/ 目录
#    https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe

# 2. 启动隧道(会打印完整网址并复制到剪贴板)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-start.ps1

# 3. 不用时关掉
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-stop.ps1
```

其它工具:

| 脚本 | 用途 |
|---|---|
| `tools/tunnel-status.ps1` | **体检**:隧道现在是死是活(进程、端口、外网实测),手机上出现错误页时先跑它 |
| `tools/show-phone-url.ps1` | 一个可复制的小窗口,显示外网/局域网网址 + PIN(带刷新按钮) |
| `tools/revoke-phone.ps1` | **应急**:一键吊销所有手机登录(两步确认,防误触) |

### 在手机上回答 agent 的提问

agent 卡在 `ask_user_question` 上等你选,或者某个工具调用需要你点头时,**手机上会直接出现一张卡片**:选项按钮、可多选、也能自己写一段话;审批则是「允许一次 / 拒绝」两个键。答完 agent 立刻继续,不用你跑回电脑前面。

默认关闭,打开方式:

```json
{ "answerOnPhone": true }
```

打开后改配置需**重启 DSH**(或重载插件)生效。

**它怎么决定谁来答:**

| 情况 | 谁作答 |
|---|---|
| 有手机正在看着这个会话 | **手机**(桌面等 120 秒才轮到,防手机没答导致卡死) |
| 没有手机在看这个会话 | **桌面**,和我们没插手时一模一样 |
| 手机答了,别的手机也开着 | 先答的算数,其余卡片自动撤掉 |

**几个已经处理好的细节:**

- **答案要提权**:和下发任务一样,光有 cookie 不够,得最近输过 PIN —— 否则偷走 cookie 的人能替你在批准框上点"允许"。
- **同一个提问不能答两次**:用过的 id 立刻失效。
- **手机刷新不会丢卡片**:新开的流会把还没答的提问补推一次。
- **问题文本来自模型,一律当纯文本渲染**(`textContent`,不是 `innerHTML`),模型塞不进脚本。
- **贴到别的页签也看得见**:卡片挂在主区域外面,切到「余额」不会漏掉提问。
- **提问作废了就撤卡**:超时/已在别处回答后,卡片自己消失(而不是点一次报一次错)。
- **你正开着面板时也看得见**(2.5.1):卡片一到会**自动收起**当前打开的工具面板(会话列表 / 录音 / 生图 / 背景 / 权限 / 删除纸),振动一下并提示「agent 要你点头」——
  面板只是收起,**里面写好的内容不会丢**;卡片的层级也不再低于任何全屏面板。
  (这条是用户报「审批没弹出来」查出来的真因:卡片本来是流内元素,被那些 `z-index:50` 的全屏纸整个盖住,
  而宿主只等 120 秒就退回桌面 —— 桌面上两分钟后才冒出来,看起来就像"手机上没提示"。)

**已知限制**:加载页和断线整屏(那两屏比卡片高)时不会显示卡片 —— 那属于"还没连上/断开了",
连上以后卡片自然回来。

### 断了会告诉你,不用你自己发现

手机和电脑之间的连接有三种坏法,每一种都会**在页面顶部弹出一条说明**,而不是像以前那样只把一个 9 像素的圆点变红:

| 情况 | 页面会说什么 |
|---|---|
| 连接被切断(隧道没了 / 网络断了) | 「和电脑断开了」+ 可能的原因 |
| 连接还在、但对面不再说话(电脑睡眠最典型) | 「好像和电脑失去联系了」+ 最后收到消息是多久之前 |
| 提问过期、或已在别处回答 | 卡片自己撤掉,并说明原因 |

**而且它会给出两条能点的出路:**

1. **改用局域网地址** —— 同一个 WiFi 下,这条链路完全不依赖隧道。地址由宿主在 `bootstrap` 里给(含密钥段的完整 URL)。注意换地址等于换站点,cookie 带不过去,**要重输一次 PIN**。
2. **重试连接** —— 不换地址,直接重连。

桌面那一侧也不再装死:插件每 120 秒从外网探一次自己的隧道,连续两次探不通就把徽标上的外网地址标红成「⚠️ 隧道已断,手机连不上」,并往 `~/.dsh/mobile-bridge.log` 写一行。**恢复时也会写一行。**


### 手机上是 Cloudflare 错误页?(Error 1033 / 1016)

**这两个错都不是插件的问题,而是隧道没了。**

| 错误 | 含义 |
|---|---|
| **1033** Cloudflare Tunnel error | 这个主机名**曾经**是一条隧道,但现在没有任何隧道连着它 |
| **1016** Origin DNS error | 这个主机名已经**完全解析不到**了(隧道早就没了) |

**最常见的两个原因:**

1. **免费快速隧道的网址,每次 cloudflared 重启都会换一个。** 旧网址**永久失效,救不回来** —— 不是"过一会儿就好",是那个域名再也不属于你了。手机上收藏的旧网址永远打不开。
2. **跑隧道的电脑睡眠 / 休眠 / 合盖了**,或者换了网络、被安全软件或加速器掐掉了连接 —— 隧道随之中断。

**怎么办:**

```powershell
# 先体检:一眼看出是进程没了、端口没起、还是网址失效
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-status.ps1

# 网址失效就重拿一个(先停再起,确保拿到全新的)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-stop.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-start.ps1
```

`tunnel-start.ps1` 会**真的从外网打开一次**再告诉你成功了 —— 如果它说"没能通过外网验证",那就是**现在把网址给手机也没用**,直接照它列的原因排查,别浪费时间。

想要**一个永不变的网址**,只有两条路:Cloudflare **命名隧道**(要有自己的域名 + 账号),或者用 **Tailscale / ZeroTier** 这类组网工具(推荐:地址固定、端到端加密、且完全不暴露到公网)。

> ⚠️ 免费快速隧道的**网址每次重启都会变**,而且是一条**公开网址** —— 拿到的人都能看到 PIN 登录页。手机桥有 PIN 认证和限速兜着,但**别把网址发到群里**。

---

## 安全模型

这个桥可以经隧道暴露到公网,所以"安全"必须是**能验证**的,不是一句承诺。

### 已实施的防护

| 防护 | 说明 |
|---|---|
| **随机路径前缀** | 所有路由藏在 `/<pathSecret>/` 后面。无密钥路径一律 404 |
| **限速的钥匙不可伪造** | 只在 socket 对端是 loopback(即自家 cloudflared)时才读转发头;`cf-connecting-ip` 优先,`x-forwarded-for` 取**最后一段**(自家代理追加的),**绝不取第一段**(那是调用方可控的) |
| **两级登录限速** | 每地址 12 次/10 分钟 + **所有人合计** 60 次/10 分钟 |
| **令牌绑定来源 IP** | 偷走的 cookie 换一台机器就是废纸 |
| **写操作需要再验 PIN** | cookie 只能**读**;下发指令/取消/新建会话需要一小时内输过一次 PIN |
| **Cookie `Secure` 条件化** | HTTPS 下带 `Secure`,明文 HTTP 下不带(否则局域网登录会静默失效) |
| **令牌 12 小时过期** | 上限 14 天,`/api/logout` 立即吊销 |
| **SSE 连接上限** | `MAX_LIVE_STREAMS = 6`,超限 503 |
| **网络层超时与连接上限** | `headersTimeout 20s`(挡 slowloris)、`requestTimeout 180s`、`maxConnections 64` |
| **统一安全响应头** | `nosniff`、`X-Frame-Options: DENY`、`cache-control: no-store`、**`Referrer-Policy: no-referrer`**(防止密钥路径经 Referer 泄漏) |
| **不回显内部错误** | 细节只进日志,不回给调用方 |
| **探测留痕但密钥不落盘** | 无密钥请求记为 `GET <no-secret> <- ip`;日志里永远不出现密钥路径 |
| **登录审计** | 成功/失败的每一次登录都落一行(`~/.dsh/mobile-bridge.logins.jsonl`,超 256KB 时**成功的那几行优先留** —— 否则约 1300 次错 PIN 就能把真记录挤光、面板假报"没有陌生设备")。**失败尝试不算"已知设备"** —— 否则攻击者先试错一次就把自己洗白;"新地址第一次成功登录"会在桌面角标上报警,并写明"走外网时这是隧道那端的地址" |
| **看审计要现输 PIN** | `GET /api/logins` 走 **`x-dsh-pin` 头**(绝不进 URL):这份账里有这台电脑的**网络史**,和"看剪贴板 / 截图"同一级。`current`("这台手机")按**令牌**判,不按 IP(IP 会被 warn-only 路径改写) |
| **踢设备要现输 PIN** | 手机端「踢掉其它设备」= 收回权限,仍需证明是本人(和"删除会话"同级);默认保留当前这台。桌面那个 `/revoke` 照旧**只认本机**(loopback),密钥泄漏也踢不掉你 |
| **诊断包脱敏** | `GET /api/diag` 生成的文本里,PIN 与 `pathSecret` 一律替换成 `<PIN>` / `<密钥段>`,另有 `Bearer …` / `sk-…` 两条通用规则;读不到配置时**在包里写明"没能确认脱敏"**;每份日志只截最近 48KB —— 它是要发出去的东西 |
| **自检的隧道那一条是"现探"** | `state.tunnel.ok` 是三态(`null` = 没探过):点一次 🩺 就现探一次,免费隧道换域名后会重新探 —— **绝不说"没测过"就是"可达"** |

### 怎么验证(请自己跑)

```bash
node test/security.mjs
```

51 项断言:限速钥匙的单元断言、密钥路径门禁、每个接口的未授权拒绝、限速生效、cookie 标志、令牌有效期、注销吊销、响应头、SSE 上限、附件不泄漏、日志脱敏。退出码非 0 即有失败。

其中**只读**的那半(密钥门禁、未授权拒绝、响应头、日志脱敏)打的是你正在用的那台桥;**破坏性的**那一半(连猜 15 次 PIN、真登录拿 cookie、打满 SSE 上限)会在临时目录里另起一台**一次性实例**,跑完即拆 —— 因为登录限速有一份**全局额度,是和手机共用的**,在真身上打就是拿手机当靶子。早期版本正是这么把用户锁在门外的。

要连**真实 Cloudflare 边缘**一起验(会消耗本机出口 IP 的登录额度,10 分钟后自愈):

```powershell
$env:BRIDGE_TUNNEL_URL='https://<你的隧道域名>'
node test/security.mjs
```

其它测试。先装一次测试依赖(`jsdom`,只有 UI 测试用它;插件本身零运行时依赖):

```bash
npm install
```

| 文件 | 内容 | 需要 |
|---|---|---|
| `test/crashprobe.mjs` | 崩溃探针开关:默认不碰宿主全局状态、开启后能在配置重写中存活(17 项) | — |
| `test/harness.mjs` | 桥的端到端行为:PIN 门、cookie、prompt 组装、SSE 扇出、取消、附件、新建会话、余额缓存、重启保活(含"恢复的会话可读但不可写 = 403+needPin")、注销、**桌面角标注入与 start() 不抛错**、**普通请求取对话 `/api/transcript`(巨型历史裁剪 / 一堆短字符串拼成的 `stream` 也要丢 / 缺 sessionId 400 / 客户端半路掐断后桥还活着)**、**子会话的错误帧带 unsupported 且从列表里消失**,以及**手机作答全链路**(提问推到流上 / POST api/answer 收答案 / 防重放 / 审批 outcome 校验 / 取消不惊动桌面 / 刷新补推)(**492 项**) | — |
| `test/ui.mjs` | 手机页面的 DOM 与交互:含"没有请求跑出密钥段"、"提权过期时必须把 PIN 门打开"、**运行中同时给出「插话」与「停止」并确认插话已收下、换会话后「在跑」状态跟着重置**、**首次说明页(自动弹出 / 讲清四件事 / 点了就记住 / 真有提问时让位)**、**子会话不许谎报断线、也不再重连**、**迟到的旧会话回包不许贴到新会话上**、**localStorage 写不进去(无痕模式)也不许把页面搞死**、**Ctrl+Enter 连按两下只发一次**、**提问与审批卡片**(渲染、作答编码、防 XSS、重推保草稿、单选与自定义互斥、作废撤卡、跨页签可见),以及**断线提示条**(弹条、说清原因、给局域网兜底、心跳到达后收起)(**685 项**) | jsdom |
| `test/addresses.mjs` | 地址排序与分类(16 项) | — |
| `test/shot.mjs` | 真浏览器截图 + 布局/滚动体检(`scroll:` 那行会报能不能滚到顶底、滚动中位重取后位置还在不在、有没有块在估算高度) | Windows + Edge |
| `test/switch-bench.mjs` | 真浏览器量"切会话":切到没打开过的会话多久有内容、切回来是不是那一刻就有、这次切换下载了多少字节 | Windows + Edge |
| `test/reopen-bench.mjs` | 真浏览器量"从别的窗口点回来":把网络延迟拉到 2 秒再重载,报"对话出现"与"界面显示"两个时刻 | Windows + Edge |
| `test/scroll-live.mjs` | 真浏览器复现"回弹":开一个临时会话让它真的流式输出一千多行,一边往上滚一边采样 15 秒(回弹次数 / scrollHeight 变动 / 最长掉帧) | Windows + Edge |
| `test/phone-diag.mjs` | 真浏览器诊断:抓页面异常、控制台、网络状态码 | Windows + Edge |
| `test/boot-probe.mjs` | 加载页进度条探针:网络节流下每 250ms 采样一次,回答"百分比会不会冻住"(这类 bug 本机复现不了,只有慢网络才看得见) | Windows + Edge |

```bash
node test/package.mjs && node test/harness.mjs && node test/qr.mjs && node test/ui.mjs
node test/security.mjs && node test/crashprobe.mjs && node test/addresses.mjs && node test/offline.mjs
node test/platform.mjs
```

**九个套件共 1486 项**(package **20** · harness **553** · qr 12 · ui **770** · security 54 · crashprobe 17 ·
addresses 16 · offline 17 · platform 27)。另有 `test/voice.mjs` **33 条语音草稿** —— 语音那一版
按用户要求先跳过,文件留在仓库里、能单独跑,但**不并进**这项门槛(`tools/check-counts.mjs` 只管上面九个)。
**改完必跑全套** ✓ —— 只跑某一个会漏掉内容规矩与界面回归(2.7.2 就是这么漏了两版的 ✓)。

## 分享到桥:任何 App → 分享 → 电脑会话(3.6.0 起)

在手机上看见一段话、一个链接、一张图,想直接丢给电脑上的会话 —— 不用切到桥的界面,**在原来的 App 里点「分享」**就行。

**为什么做成"快捷指令 + 一条短链"**:手机上这个网页已经很大了(10.8k 行),
再加按钮就要跟"面板重渲染"那套脾气搏斗(3.5.0 为了一个「解锁」按钮折腾了五轮 ✗)。
短链那条路**完全不碰页面** —— 更稳,也更快。

### 怎么开(**默认关** ✓)

1. 在这台电脑的 `~/.dsh/mobile-bridge.json` 里加一行:

   ```json
   { "shareIntake": true }
   ```

   (**改完立刻生效,不用重启** ✓;不想要了删掉/改回 `false` 就立刻关 ✓。)

2. 手机上打开桥 → 工具键那排的 **🩺 一键自检** → 找到**「分享到桥」**那一行 → 抄下它给出的**短链**
   (那条链里带着这段密钥,**别发给别人** ✓)。

3. 在 iPhone 的**快捷指令**里新建一个快捷指令:

   - 打开「**在分享表单中显示**」;
   - 「**接收**」选 **文本**、**网址**(想分享图片再加 **图像**);
   - 加一个「**获取 URL 内容**」:方法 **POST**,URL 填第 2 步抄来的短链,
     请求体选 **JSON**,内容形如:

     ```json
     { "pin": "你的六位 PIN", "text": "分享进来的文本" }
     ```

     (有网址就再加 `"url": "分享进来的网址"`;有图片可先「Base64 编码」再放进 `"images": [{"mediaType": "image/png", "data": "…"}]`。)
   - 保存 ✓ —— 之后在任何 App 点「分享」,列表里就有它。

### 它到底做了什么(以及没做什么)

- 它把内容当成**你发的一条消息**送进电脑上**最近那个会话**(跳过空会话 / agent 子会话 / 自检会话);
  消息最前面会带一句【分享到桥】。也可以带 `"sessionId": "…"` 指定会话 —— 指定的会话找不到就**如实报 404**,
  **不会偷偷换个会话发**。
- **每次都要带 PIN**:它等于替你指挥这台电脑(和你在手机上点「发送」同级),而短链**没有登录 cookie**,
  所以只能要"随这次请求的 PIN"。错一次就**记一笔账**(审计留一行、桌面角标会显示有人正在试);
  **同一 IP 10 分钟内错满 12 次**才会被挡 10 分钟(和登录那条路同一个闸)。
- 关着的时候(默认)→ `403` + 一句"怎么开";**一个字都不会进会话**。
- **收不下的会明说**:文本超 8000 字只取前 8000(内容里与回包里都写明)、一次最多 3 张图
  (多出来的张数也会说明)—— 不静默丢。
- **分享文件(PDF / 压缩包)这一版没做**:大文件要走分片上传,在快捷指令里拼分片不划算 ——
  用手机上的「文件」键传,那条路是现成的。

接口形状(想自己写脚本也一样):

```bash
curl -X POST "https://<你的地址>/<密钥段>/api/intake" \
  -H 'content-type: application/json' \
  -d '{"pin":"935881","text":"看到的这段话","url":"https://example.com/a"}'
# → {"ok":true,"sessionId":"…","title":"…","requestId":"…","accepted":true,"images":0}
```

### 一键 OCR / 扫码(3.7.0 起)

同一条短链,多带一个**白名单**里的 `task` 就行 —— 桥会把一句**固定指令**附在内容最前面:

```bash
# 拍张照 → 让它认字 → 整理成一份可搜索的 Markdown 笔记
curl -X POST "https://<你的地址>/<密钥段>/api/intake" -H 'content-type: application/json' \
  -d '{"pin":"935881","task":"ocr","images":[{"mediaType":"image/png","data":"<base64>"}]}'

# 拍二维码 / 条形码 / 书号 → 解出来 + 查一下它是什么
curl -X POST "https://<你的地址>/<密钥段>/api/intake" -H 'content-type: application/json' \
  -d '{"pin":"935881","task":"scan","images":[{"mediaType":"image/png","data":"<base64>"}]}'
```

- **`task` 是白名单**(`ocr` / `scan`,`GET /api/intake` 会列出来):**`task` 这个字段不接受自由指令** ——
  这条链虽然是凭证(密钥段 + PIN),但它形状是公开的,让调用方自己写指令等于白送一个"远程指挥 agent"的入口。
  名单外的值(含**空值**与**非字符串**)→ `400` + 列出能用哪些(**不静默忽略**)。
  ⚠️ 说清楚:`text` 与图片名仍然是**调用方自己的内容**(它们本来就是"分享进来的东西"),
  桥不改写它们、也不拿它们当系统指令用。
- **认出来的字落在会话里** —— 所以**挂了会话检索**(宿主 `dsh-session-query`)的部署上,
  手机上现成的「🔍 搜一搜」(搜这台电脑上的会话内容、不上云)直接就能搜到 ✓;
  **没挂的话搜一搜会如实说搜不了**(那是宿主的原话,不是桥坏了)—— 文字照样在会话与那份 Markdown 笔记里,
  在电脑上搜也一样。
- **桥自己不做识别、也不做解码**:它只把图和指令交给电脑上的 agent(它本来就会看图);
  扫码那条指令里也写着"桥不做解码",让 agent 自己找工具或写一小段脚本。**零新依赖**。
- 内容顺序是固定的:`【分享到桥】` → 任务指令 → 你的 text → 图片。收不下的(文本超 8000 字 / 图超 3 张)
  照旧**明说**(`truncated` / `dropped`)。

## 开放接口:把事件投给你自己的程序(3.4.0 起)

想让别的程序"知道电脑上发生了什么"(跑完了一轮、出错了、会话有新活动),
用 **webhook** 把事件 POST 到你自己写的地址上就行。

### 怎么开(**默认关** ✓)

在电脑上的 `~/.dsh/mobile-bridge.json` 里加三行(配置是**现读**的 ✓ —— 改完不用重启 ✓;
**重启 / 开机之后也会自动按配置订上** ✓ —— 复查第 83 轮修掉了"重启后订阅会掉、还得手工再调一次"那个洞 ✗):

```json
{
  "webhookUrl": "https://你的服务器/hook",
  "webhookSecret": "一串你自己的随机字(用来签名)",
  "webhookEvents": ["api-session/status", "api-session/error", "api-session/activity"]
}
```
- `webhookEvents` 不写就默认订上面这三个 ✓;
- 改完想**立刻**生效:调一次 `POST /api/webhook/subscribe` ✓(**登录即可** ✓ —— 这个动作不改任何配置,
  只是按现配置重新订阅 ✓;重启 / 开机本来就会自动订 ✓,它是"不想重启"时的入口 ✓);
- 想先确认"发不发得出去":`POST /api/webhook/test`(**要现输 PIN** ✓)会真发一条测试投递 ✓。

### 你会收到什么

每条事件一个 POST,头里带三样东西 ✓:

| 头 | 意思 |
|---|---|
| `x-dsh-event` | 事件名(如 `api-session/status`)|
| `x-dsh-timestamp` | 毫秒时间戳 |
| `x-dsh-signature` | `sha256=<hex>` —— 用 `webhookSecret` 对**正文**做 HMAC-SHA256(与 GitHub 的 `X-Hub-Signature-256` 同形)|

正文**只有元信息** ✓(这是有意的 ✓ —— 会话内容太敏感 ✗):

```json
{"event":"api-session/status","at":1791800198081,"sessionId":"…","running":true}
```

### 三个事件分别是什么

| 事件名 | 什么时候发 | 正文里的字段 |
|---|---|---|
| `api-session/status` | 某个会话**开始跑 / 跑完了**(宿主级状态,比手机上的"这一轮"更准)| `sessionId`、`running` |
| `api-session/error` | 某个 agent **出错了**(且不在某轮的可记录位置上)| `sessionId`、`message`(**宿主的错误原文**,截断到 300 字 —— 可能夹带路径等上下文 ✓)|
| `api-session/activity` | 有一条**用户消息**推进了会话的活动时间 | `sessionId`、`updatedAt`(**活动发生的时间** ✓)|

> `at` 一律是**投递时刻** ✓(复查第 83 轮 R13:以前 `activity` 的发生时间被映射成 `at` ✗,
> 于是对方拿到的是"我们发出去的时间"、还看不出来 ✗ —— 现在发生时间单独叫 `updatedAt` ✓)。

### 一个最小接收端(Node,验签名)

```js
import { createServer } from 'node:http'
import { createHmac } from 'node:crypto'

const SECRET = '你在配置里写的那串'
createServer((req, res) => {
  let body = ''
  req.on('data', (c) => { body += c })
  req.on('end', () => {
    const expected = 'sha256=' + createHmac('sha256', SECRET).update(body, 'utf8').digest('hex')
    if (req.headers['x-dsh-signature'] !== expected) { res.statusCode = 401; return res.end('bad signature') }
    console.log(req.headers['x-dsh-event'], JSON.parse(body))
    // 回 200 + 一句正文最省事 ✓;回 **204**(成功且没有正文)也完全可以 ✓ ——
    // 桥对 204 **不会**告警 ✓(复查第 83 轮 R9:以前会把规范意义上的成功记成"正文为空、说不准"✗)。
    res.statusCode = 200
    res.end('ok')
  })
}).listen(8787, () => console.log('listening'))
```

### 安全边界(都按项目的铁律 ✓)

- **默认关** ✓:没配 `webhookUrl` 就**一条都不订**(审计日志里会明确写
  「没配 webhookUrl —— 默认关 ✓,一个都没订 ✗」✓;订阅接口也会**回同一句真话** ✓,
  不会回"已订阅 ✓"那种假成功 ✗ —— 复查第 83 轮 R3);
- 出站地址**白名单** ✓:只允许 `https://` 或本机 `http://127.0.0.1|localhost`;
  **拒** `file:`/`ftp:`/`data:`、拒内网段(`10./172.16-31./192.168.`)、拒云元数据地址(`169.254.169.254`)、
  **拒带用户名/密码的 URL**(`https://user:pass@…` ✓ —— `fetch` 本来也不会用它 ✓,复查 R7);
- ⚠️ **白名单是"字面串"判断** ✓ —— 挡不住「**解析后才内指**」的域名(`*.nip.io`、`localtest.me` ✓)。
  要挡必须在**投递时**用 `dns.lookup` 校验**全部**结果 ✓,**这一条还没做** ✗(套件里有一条**哨兵断言**
  把"它现在仍被放行"钉着 ✓ —— 将来做了校验,那条会变红提醒改成"必须拒" ✓)。
  同理:`POST /api/webhook/test` 拿 PIN 可以往任意 https 主机发一次固定正文的 POST 并拿到状态码 ✓,
  在缺口没补之前,它相当于一个**受 PIN 保护的 SSRF 探针** ✓("能拿到 PIN = 已完全信任"是当前威胁模型 ✓,
  但这不等于零风险 ✓ —— 如实写在这儿 ✓);
- **签名** ✓:对方能验真,伪造不了;
- **投递失败会写审计日志** ✓(`~/.dsh/mobile-bridge.log`),不静默丢;
  「2xx 但正文为空」这种"说不准收没收到"也会记一行 ✓(`204` 例外 ✓ 那是规范意义上的成功 ✓);
- `POST /api/webhook/test` 与"读剪贴板/截屏"同级:**要现输 PIN** ✓。

### 还没做的(如实 ✗)

- **手机侧还没有配置入口** ✗(现在只能改电脑上的文件 + 调一次订阅接口);
- **只订了 `emit` 类事件** ✗ —— `user-questions/request`("agent 要你点头")与 `approval/request` 是
  **waterfall** 类 ✓,订阅者必须调 `next()` 并给出决定 ✓;乱订会**干扰宿主自己的流程** ✗,所以这两个
  要按 waterfall 的规矩单独写并单独测;
- **投递不重试** ✗(一次不成功只记一行 ✓)—— 要重试得有退避与上限,那是后面的事;
- 正文**不带会话内容** ✗(有意 ✓);想带内容需要另开一个开关并写明后果 ✓(还没做)。


## 个人知识库(3.3.0 起)

- **搜一搜**:工具键那排的 🔍 → 输入关键词 → 结果来自宿主自己的 `sessionController.search` ✓
  (**不会唤醒 agent** ✓、**不建索引** ✓)。点一条结果直接切到那个会话 ✓。
  ⚠️ **先在电脑上挂 `@deepseek-ai/dsh-session-query`(+ `-sqlite`)** ✓ —— 这台部署默认没挂 ✓,
  没挂时界面会显示宿主的原话(`搜不了:…`)✓,**不会**假装搜过了没结果 ✗。
- **这周学了什么**:同一张纸的 📅 → 最近 7 天有活动的会话(`days` 可传 1–30 ✓,夹取 ✓)。

- ⚠️ **标题那段更正**:这一版最初写的是"这台宿主的列表**不带标题**" ✗ —— **那是我们读错了字段** ✓
  (标题在 `projections.values.title` ✓,桥自己的 `summarize()` 一直读对 ✓;宿主 `/api/boot` 83 条**全非空** ✓)。
  已修 ✓:`titleOfSummary()` 共用 ✓ —— 所以现在列表里显示的是**真标题** ✓,
  短编号只是极少数兜底 ✓。**把我们的 bug 说成宿主的能力缺失,是这一版最该记住的教训** ✓。
  不留空白行 ✗。**子会话也会混进来** ✓(筛不筛留到设置那一版 ✓)。
- 两条路都是**只读** ✓、**不出这台电脑** ✓;读不到就说读不到 ✓(503 + 原话 ✓),
  **不显示空列表假装搜过** ✗。

## 手机上把步骤拼起来跑(3.2.0 起)

「📅 定时」纸里点开「🧩 工作流编排」:**一行一步** ✓,写几行就是几步(最多 12 步 ✓)。

- 点「▶ 跑一遍」:**一步步来** ✓ —— 前一步跑完(这一轮结束)才发下一步 ✗(不会三条一起塞队列 ✓);
  纸上写着“第 N / M 步”✓,随时能按「■ 停下」✓(停下之后**再来的“这轮跑完”也不会继续发** ✗)。
- ⚠️ **步骤之间不传数据** ✓:agent 就在同一个会话里 ✓,上一步的产物本来就在它眼前 ✓ ——
  这也是“转写 → 出提纲 → 存笔记”这种链天然成立的原因 ✓。
- 点「📅 定时跑」:每天到点把这条编排投给这个会话 ✓。⚠️ **那是另一种形态** ✗:无人值守时没人盯着
  “这轮跑完”✓ → 所以整串**压成一条提示词** ✓、交代“按顺序一步一步做完” ✓,让 agent 自己按顺序做 ✓。
- 点「🔁 一轮跑完自动跑」:**一轮跑完**就自动跑这条编排 ✓(按钮上写着是哪条 ✓,再点一次取消 ✓)。
  ⚠️ 需要**页面开着** ✗(冻结/关掉就收不到 ✓);有三道防套娃闸 ✓:正在跑不起新的 ✓、
  跑完冷却 90 秒 ✓、每小时最多 3 次 ✓(到顶会**说出来** ✓,不静默 ✗)。
- 「导出(复制)」给你一份 JSON ✓(发给别人或存起来 ✓);「导入(粘贴)」是**追加** ✓,不覆盖 ✗。
  编排存在**手机本地** ✓(dshm.workflows ✓),也**进备份** ✓。

## 自己写一个手机面板(3.1.0 起)

一个目录 + 一个 `panel.json`,就能在手机上多一个面板(默认**关**,见下面的信任边界):

```
~/.dsh/mobile-panels/my-notes/panel.json    ← { "title": "我的面板", "icon": "🧩", "kind": "tool", "entry": "index.html" }
~/.dsh/mobile-panels/my-notes/index.html    ← 你的页面
```
然后在 `~/.dsh/mobile-bridge.json` 里加 `"panels": true` ✓,手机刷新一次 ✓。

- ⚠️ **目录名(id)有规矩** ✗:只能小写字母/数字/短横线 ✓、字母或数字开头 ✓、最多 32 字 ✓
  (`my-notes` ✓、`我的面板` ✗)。写错了手机上不会出现 ✓,但 `/api/panels` 会带 `skippedReasons`
  说清是哪一条、为什么 ✗(不再只给一个数字 ✓)。
- `kind`:`tool` / `sheet` / `screen`(现在都表现为"工具键 + 整屏"✓,字段留着给以后分化 ✓)。
- 页面里写**相对路径**就是在调桥的接口 ✓(同源 ✓)—— 例如 `fetch('api/panels')` ✓(那是清单 ✓;
  面板自己的资源在 `/panel/<id>/<文件>` ✓)。
  ⚠️ 别把密钥段写进页面 ✗(换地址就废 ✓,而且会进 git ✓)。
- 同 id 时 **`~/.dsh` 里那份优先** ✓(能盖掉自带示例 ✓);桥自带 📝 便签 / ⏳ 计时 两个示例 ✓。
- ⚠️ **信任边界(必须知道)**:面板与手机页**同源** ⇒ 它拿得到手机页的 cookie ⇒
  **装一个面板 = 把手机端的一切交给它** ✓。所以默认关 ✓;而"谁能装"的边界是
  "**谁能写这台电脑的 `~/.dsh`**" ✓ —— 那本来就是完全信任 ✓。
- ⚠️ **那个目录整个"会被托管"** ✗:只要扩展名在 html/js/css/json/svg/图片 里 ✓,
  任何已登录的会话都能按路径取 ✓ —— 所以**别把密钥或私有配置放进 `mobile-panels/`** ✓。
- 桥这边只管发文件 ✓:大小上限 512 KB ✓;**符号链接一律拒** ✓(`lstat` + `realpath` 双检 ✓ ——
  否则 `leak.json -> ~/.dsh/mobile-bridge.json` 会把 PIN 与路径密钥整套读走 ✗);
  词法穿越守卫 ✓(`/panel/x/../../mobile-bridge.json` 读不到 ✓)。

发版前**顺手跑一次** `node tools/check-counts.mjs` ✓:它会真跑一遍**九个**套件、把实测项数
和 **README / CHANGELOG 最新一节里写的数字逐项比对** ✓ —— 手写项数加错过一次(2.10.0 ✗),
而"套件全绿"的断言管不到文档里的数字 ✓。

### 仍未消除的风险(如实列出)

- **Cloudflare 能看到明文**:HTTPS 在其边缘终结,prompt、会话内容、附件对它都是明文
- **会话记录无独立保护**:拿到有效 cookie 即可读取全部历史
- **登录审计里的 User-Agent 是客户端自己写的**:它只作为线索存进本机文件(前 120 字),绝不当证据 —— 真正的凭据始终是 PIN
- **公网攻击面是一个手写的 Node HTTP 服务**:没有框架兜底
- **免费快速隧道**:无可用性保证,网址每次重启都会变
- **不用时请关闭**:`tools/tunnel-stop.ps1`。不暴露就没有攻击面,这是最有效的一条

---

## 语音转文字:手机发的录音,模型直接"读"得到

模型读不了音频。桥会把手机发来的**音频**(语音备忘录、录音)转成文字交给模型;
**视频里的语音也一起转**(所以"发视频"= 关键帧 + 语音文字,讲解类视频全都能懂)。

用**智谱 GLM-ASR**,0.06 元/分钟(一分钟六分钱)。如果你已经装过本仓库的
[生图插件](https://github.com/jxboop/dsh-plugin-image-gen),**那份智谱 key 会被自动复用** ——
不用再配任何东西。接口限制单次 ≤30 秒,所以长音频按 **25 秒切片**转写,段与段之间把上一段
结果作为上下文传下去,句子不会在切口处断;超过 `asrMaxMinutes`(默认 10)分钟的录音不转,
并明确写出原因(省钱,也让模型知道为什么没有转写)。

`~/.dsh/mobile-bridge.json` 里的可选项:

```json
{
  "asrKey": "(留空 = 自动复用生图插件的智谱 key)",
  "asrBaseUrl": "https://open.bigmodel.cn/api/paas/v4",
  "asrModel": "glm-asr-2512",
  "asrMaxMinutes": 10
}
```

没配 key、或没装 ffmpeg(切片要用它)时,音频会像以前一样**只给路径**,不影响发送。
实测:66 秒的 iPhone 讲课视频 → **239 字中文、2.2 秒、约 7 分钱**。

### 一节课的录音(45~90 分钟)能不能识别?能,但要知道三件事

1. **上传是最慢的一段**:手机上行实测只有 **20~70 KB/s**。一节课的压缩录音约 20~40 MB,
   也就是 **5~30 分钟**的上传时间(走分片上传,中断了能续,不会白传)。
   想快:录音 App 里选「压缩」音质;或者干脆**把文件拷到电脑上**再让 agent 处理(见下)。
2. **转写要花钱**:智谱 GLM-ASR **0.06 元/分钟**,一节课(45 分钟)约 **2.7 元**。
   转写完成时消息里会写明这段花了多少(`≈ x.xx 元`),不用自己算。
3. **上限已经放到 3 小时**(`asrMaxMinutes`,默认 180)。以前是 10 分钟 —— 会把一整节课
   直接挡在门外。转写本身按 25 秒切片逐段跑,45 分钟大约 **2~4 分钟**转完。

手机里的录音直接发过来就行;音频单文件上限放宽到 **80 MB**(其他文件仍是 28 MB)。

---

## 视频:手机发的视频,模型直接"看"得懂

模型本身**看不了视频**。桥会在电脑上自动**抽 6 帧**(均匀分布、每帧缩到 768 宽、JPEG),
连同一句"这是第几秒"的说明一起交给模型 —— 于是"这段视频里讲了什么"能直接答。
实测:一支 66 秒的 iPhone HEVC `.MOV`,抽帧 **0.6 秒**、6 帧合计 **约 100 KB**(比原片小三个数量级,
手机上行也扛得住)。原片照样留在 `~/.dsh/mobile-uploads/`,路径也一并给模型,
要看细节/听声音时它自己用 ffmpeg 处理。

抽帧(以及语音转写的切片)靠 **ffmpeg**。插件本身不打包它(100 MB 的二进制不该塞进插件),
按下面的顺序找:

1. 配置里的 `ffmpegPath`(`~/.dsh/mobile-bridge.json`,可选);
2. `~/.dsh/bin/ffmpeg.exe`;
3. `PATH` 里的 `ffmpeg`。

**装一个(Windows,不用管理员)**。两条路都行,**建议下官方的 essentials 包**(同时给你
`ffprobe`,读时长更准更快:实测 65 ms,而不是解析 ffmpeg 的日志):

```powershell
# 路 A(推荐,约 110 MB,含 ffprobe;国内可能慢,几十 KB/s 也能下完)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\bin" | Out-Null
$zip = "$env:TEMP\ffmpeg.zip"
Invoke-WebRequest 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' -OutFile $zip
Expand-Archive $zip "$env:TEMP\ffmpeg-x" -Force
Copy-Item (Get-ChildItem "$env:TEMP\ffmpeg-x" -Recurse -Filter ffmpeg.exe)[0].FullName "$env:USERPROFILE\.dsh\bin\" -Force
Copy-Item (Get-ChildItem "$env:TEMP\ffmpeg-x" -Recurse -Filter ffprobe.exe)[0].FullName "$env:USERPROFILE\.dsh\bin\" -Force

# 路 B(国内镜像,几秒钟,只有 ffmpeg 没有 ffprobe;够用,时长走 ffmpeg 日志兜底)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\bin" | Out-Null
Invoke-WebRequest 'https://registry.npmmirror.com/-/binary/ffmpeg-static/b6.0/ffmpeg-win32-x64' `
  -OutFile "$env:USERPROFILE\.dsh\bin\ffmpeg.exe"
& "$env:USERPROFILE\.dsh\bin\ffmpeg.exe" -version
```

没装也能用:视频会像以前那样只把**路径**交给模型(它自己想办法),不影响发送。

---

## HEIC 素材(iPhone 照片)

iPhone 拍的照片是 HEIC,而且**扩展名经常骗人**(名字叫 `..jpeg`,内容却是 `ftypheic`)。
浏览器不一定解得了,插件里的背景/素材预览就会显示不出来。桥会**按内容**认出它是 HEIC
(而不是当视频),但要在任何浏览器里都能显示,最好先转成 JPEG:

```powershell
powershell -File tools\heic-to-jpg.ps1 -In <HEIC 文件> [-Out 输出.jpg] [-MaxEdge 1600]
```

它借 Windows 自带的 HEIF 解码器(`Microsoft.HEIFImageExtension`)转码,只读输入、
写输出。转出来的 1600px JPEG 任何浏览器都能显示,也能直接当手机界面背景。

---

## 已知坑(作者踩过的,写在代码注释里)

1. `res.write()` 写到已断开的响应上**不会同步抛错**,而是**异步 emit `'error'`** —— 无人监听时 Node 直接抛出,整个宿主进程陪葬。所以每一处响应都要挂 `res.on('error')`。
2. **不要用局部变量遮蔽同名函数**。曾经有一个 `const record = ...` 遮蔽了日志函数 `record()`,只在"令牌异地使用"这条路径上抛 `TypeError`,把整个 DSH 打死,排查了很久。**后来又踩了一次**:手机作答的等待记录也叫了 `record`,于是"提问推给手机"那一行直接把宿主打死。变量名起之前,先看一眼作用域里有没有同名函数。
3. **注释里写了"要保住的东西",就得在每一条写入路径上兑现。** 提问卡片的草稿注释着"存在 state 上就不会丢",但"宿主重推同一个 id"那条路径直接整体覆盖,把用户勾到一半的选择清光 —— 注释不能当实现。
4. **`server.close()` 不会断开已建立的 keep-alive 连接。** 插件重载时旧实例的会话已清空,却还在服务复用连接,手机就会拿到莫名的 401(表现为"刚重载又要我输 PIN")。要显式 `closeIdleConnections()` / `closeAllConnections()`。
5. **`waterfall` 是顺序的,注册顺序决定谁作数。** 手机应答者必须 `prepend` 在官方 `api-remotes` 之前,否则永远不会被调用(官方那一环会一直等到浏览器作答)。
6. **超时和取消要分开。** 超时该 `next()` 交还桌面;取消(agent 已放弃)绝不能 `next()`,否则桌面上会弹出一个已经作废的批准框。
7. **退出前要同步落盘**。异常处理器里用异步 `appendFile` 再 `process.exit()`,写入不会完成 —— 探针会把证据弄丢。
8. `pnpm`/`npx` 建的联接,`Target` 可能是**相对路径**,重建时要相对**原联接所在目录**解析。
9. `robocopy` **默认跟进目录联接**,会把链接指向的整棵树也复制过来。
10. **`str.Replace()` 替换的是【全部】匹配,不是第一处。** 曾用带锚点的替换插入 `/api/revoke` 路由,而那个锚点在文件里出现了两次:一处正确,另一处落在无密钥的 404 分支里、位于 `const route` 之前 —— 于是手机浏览器自动请求 `/favicon.ico` 时抛 `ReferenceError: Cannot access 'route' before initialization`,整个宿主进程直接退出。表现是"**手机一连上就崩**"。插入唯一代码块必须用强制唯一匹配的编辑方式,或先断言出现次数。
11. **配置重写要带上所有字段。** `loadConfig()` 只写它列出的字段;新增的排查开关漏掉,就会被下一次"配置有变化"的重写悄悄抹掉(`crashProbe` 踩过)。
12. **测试会因为路径形态漂移而静默烂掉。** 页面改成相对地址(`api/...`)后,UI 测试的 mock 仍在按 `startsWith('/api/...')` 匹配,于是每个请求都 404 —— 测试不是失败,是**整个不工作了**。同一类漂移还让 `harness.mjs` 一次废掉 37 条断言(它的 `base` 里没有密钥段,于是 PIN 门、cookie、SSE、取消、余额缓存全部 404),让 `shot.mjs` 永远拿不到截图。规则:**测试里凡是拼 URL,都必须从配置里读密钥段,绝不硬编码绝对路径**;mock 要按浏览器语义把相对地址解析到带密钥段的页面地址上,并断言"没有请求跑出密钥段"。
13. **页面是启动时读一次并缓存的**(`state.page`,配合 page tag 做自重载)。改了 `mobile.html` 之后必须**重载插件**才对外生效 —— 只改文件不重载,线上还是旧页面。
14. **模块级函数里用不了 `createBridge` 内部的日志器。** 为了给角标加两行日志,在模块级的 `installBadge` 里直接写了 `record(...)` —— 那抛 `ReferenceError`,而 `installBadge` 正好是 `start()` 的**最后一行**,一抛就打断整个启动:**角标从界面上消失、隧道自检永不启动**,而当时 236 条测试全绿(角标那条路径没有任何覆盖)。**同一个名字第三次咬人。** 现在 logger 作为参数传进去,并且 harness 里补了角标的覆盖。
15. **给用户看的消息必须写进用户会看的地方。** 隧道断了的告警一开始用的是 `log()`/`warn()` —— 它们只到 stdout/stderr,而 DSH 不捕获这些。**等于把"隧道断了"说给空气听。** 只有 `record()` 会落进 `~/.dsh/mobile-bridge.log`。
16. **成功分支别提前把状态改回默认值**,否则"恢复了"那条分支永远不可达(`ok` 已经被改回去了,`ok === false` 不可能成立)。
17. **保活轮询必须限速,而且要避开后台。** 长连接半死(电脑睡了)时不响 `onerror`,所以页面有一个 5 秒一跳的哨兵,发现 25 秒没数据就用普通请求补拉对话。可是条件只写了"静置超过 25 秒"——于是**静置的手机每 5 秒拉一次对话**,一次最多二百多 KB,一小时七百多次,流量、电、日志全烧掉,画面还不会因此更及时。现在补拉最快一分钟一次,页面切到后台(锁屏)时整个哨兵直接返回,回到前台再补一次。
18. **大响应可能被通道缓冲住,不能只赌长连接。** 手机上"打开对话一片空白,直到电脑那边发来下一条消息才冲出来"就是这个:SSE 的初始快照是 200 多 KB 的一次性写入。所以初始对话改走 `GET /api/transcript`(普通请求,`bootstrap` 能通就证明它一定送达),实时增量仍走 SSE —— 能推就推,推不动也不至于什么都看不见。**教训:把"能不能送达"和"能不能推送"分开,别让一个赌注同时决定两件事。**
19. **破坏性测试绝不能打真身。** 登录限速的额度是**全局**的、和手机共用;旧版 `security.mjs` 直接在正在服务的桥上连猜 15 次 PIN,还在真身的令牌文件里留下一串 `192.0.2.x` 的假令牌 —— 表现就是用户被自己的测试锁在门外。现在它用临时 `DSH_HOME` + 随机端口另起一台一次性实例来跑这些检查,跑完拆掉,并在结束时清掉临时目录。
20. **异步请求回来时,要认准"我请求的是谁"。** `pullTranscript()` 取的是 `state.sessionId`,但 await 期间用户可能已经换了会话 —— 回来无条件贴上去,就变成 B 的标题下面挂着 A 的对话,而且之后的事件全接在错的对话上,**自己永远好不了**。现在先记住 `wanted`,回来第一件事是核对。同一个坑还有更狠的变体:那条"这个会话打不开"的判定如果用 `state.sessionId`,一个迟到的失败回包会把**一个健康的新会话**永久标成打不开。
21. **会话在不在跑,要以宿主给的 `running` 为准。** `state.running` 原本只由流里的 `turn/start` / `turn/end` 写,换会话时没人重置 —— 切到一条闲着的会话,按钮还写着「插话」、停止键还亮着,发一条普通消息还会回一句假的"已插话"。切换和列表刷新时都从 `running` 字段重新取值。
22. **兜底成功了,就要把误报收掉。** 长连接把大包缓冲住时顶部会报"好像和电脑失去联系了";而补拉用的普通请求能通,恰恰证明电脑还在。以前补拉成功不改 `lastFrameAt`/`link`,于是那条红字**在这个接口专门为之存在的场景里一直挂着**。现在补拉成功即抹掉 stale。
23. **"大"不只有"超长文本"一种。** `assistant/message` 的 `data.stream` 里是这条消息已经逐字推过的全部增量 —— **一堆短字符串**。按 4000 字截断的逻辑对它完全无效,实测一条这样的记录能让 `/api/transcript` 回 **782 KB**,把"别让手机收到大包"的初衷又踩回去。`fitSnapshot` 现在先过一遍 `trimEvent` 再压。
24. **`localStorage` 在无痕 / 隐私模式下会【抛异常】**(iOS Safari:`getItem` 照常、`setItem` 抛 `QuotaExceededError`),而不只是返回 null。而那句 `setItem` 恰好夹在 `state.sessionId = chosen` 和 `connect()` 之间:一抛,流不开、对话不拉,**屏幕全白**,用户还看着一切正常(仍然登录着)。现在读写只有 `readLocal`/`writeLocal` 两个入口,都不许抛;测试里把 `Storage.prototype.setItem` 改成抛错来钉住这条。
25. **守卫要放在函数里,不能只放在按钮上。** `send()` 只靠 `sendBtn.disabled` 挡重复,而 Ctrl/Cmd+Enter 是直接调 `send()` 的另一条路 —— 连按两下就是两个 prompt、两轮要花钱的对话。
26. **弹层要给别人让路。** 首次说明页是全屏 `position:fixed`,而提问/审批卡片是流内元素 —— 真有提问时卡片被挡在后面,而说明页里恰好写着"需要你拍板时会弹出卡片"。现在收到提问就先收起说明页。
27. **别把"这个会话打不开"说成"和电脑断开了"。** agent 自己派生的子会话不能用 `{kind:'session'}` 地址跟随,宿主会回 `subagent Sessions require their durable parent address`。以前页面把它当普通断线:弹"和电脑断开了 / 你正在用的地址已经失效"(把人引去换地址、重试连接,全是没用的动作),而浏览器还**每三秒自动重连一次,连一整晚**。现在宿主给这条错误打上 `unsupported` 标记、把该会话从列表里拿掉,页面据此说人话、关掉那条流、并在下次自动选一个能用的会话。
28. **服务端拒绝必须留下原因。** 手机侧永远只显示"发送失败:xxx",所以 `prompt` 被宿主拒的时候一定要 `record()` 出**哪个会话、什么原因**。加这一行之前,用户连点八次只看到 500,日志里也只有一串 `-> 500` —— 谁也判断不出是动图、还是子会话。
29. **隧道会掐断"慢而大"的请求,这一条直接决定了上传怎么设计。** 实测 Tailscale IPv6 入口:16 MB 的请求回 **408**、6.7 MB 要 **89 秒**;手机上行更慢,一整块必然死在边缘,而**边缘的错误页不是 JSON**,页面只能显示"发送失败 http 403"。所以:图片先在手机上压到长边 1600 + 质量 0.82(几 MB → 几百 KB),视频/大文件**切成 384 KB 一片**逐片发(每片一个请求,慢链路也就几秒),服务端拼回来再把路径写进任务。顺带:客户端解析响应要**先取原文再 JSON.parse**,否则永远不知道这个 403 是谁给的。
30. **动图是宿主解不开的那一类。** 宿主图片白名单只认 png/jpeg/webp/gif,但多帧图会让它的解码器抛错 → 整条 prompt 被拒。桥的对策是**被拒就自动降级**:把图存成文件、用路径重发一次(任务照样下去),页面回一句"这张图电脑解不开,已经当文件发过去了"。
31. **背景视频的三件套 + Range。** iOS 上 `<video>` 要自动播,必须同时 `muted` + `playsinline` + `autoplay`,少一个就是黑屏;而 Safari 会先发 `Range: bytes=0-1` 试探,服务端只回 200 它可能直接不播 —— 所以背景接口实现了 206 + `content-range` + ETag(第二次打开走 304,不用在移动网络下把几 MB 再下一遍)。加载失败(格式不认、文件被删)必须**整层撤掉**:背景是装饰,不能因为它让界面用不了。还有第 11 条那个坑:新配置字段一定要加进 `loadConfig()` 的重写列表,否则下一次写配置就把它抹了(`backgroundFile` 就是这么加的)。
32. **`data:` URI 的图片在 iOS 上存不进相册。** 用户说"手机看不到电脑给的素材",一半是因为素材只是**一行磁盘路径**(脚本截的图、image_generate 出的图),页面上就是纯文本;另一半是附件被取成 base64 拼的 `data:` —— 长按只有"拷贝",**没有"存储到照片"**。现在:附件走 `/api/media`(真二进制 URL),磁盘上的图片/视频走 `/api/file?path=`,页面把路径渲染成缩略图、点开全屏看图,长按即可存相册。
33. **给手机读磁盘文件,白名单要跟着"用户自己的目录"走,而不是"整台电脑"。** `/api/file` 只发**图片/视频**,而且必须落在桥认识的根里:工作区注册表、会话 cwd(`listSessions()` 顺手记下来的那些,比如 `D:\自媒体`、`D:\蹭饭图`)、`~/.dsh`、系统临时目录。别的路径一律 403 —— 这条接口不是"任意文件读取"。实测用户那几个素材目录(`D:\自媒体\图片`、`D:\蹭饭图\...`、`.zhihu-live\frames`)都在白名单内,全部 200。
34. **往折叠区里塞东西等于没塞。** 工具卡片的结果区 `.tool .body` 默认只有 132px 高、超出裁掉(点一下才展开);把素材缩略图放进那个 div,用户依旧什么都看不到。缩略图要放在折叠区的**兄弟位置**。
35. **排队中的消息在对话里是"不存在"的。** 插话走 `mode:'queue'`,要等这一轮跑完才变成一条真正的 `user/message` —— 中间那几分钟手机上什么都没有,用户以为没发出去。解法是**本地回声**:发成功后先在对话末尾画一条虚线气泡(带"排队中"和撤回按钮),真消息进来时按文本把回声撤掉(快照里已经有就更要撤,否则重连后会显示两遍)。
36. **撤回靠 rpcId 对齐,不靠文本。** `POST /api/prompt` 里的 `requestId` 就是 DSH 在 `agent/inbox/spliced` 事件里给的 `source.rpcId` —— 桥把它回给手机,手机据此认出"这条排队项是我刚发的",拿到 `inserted[].id` 后调 `updateQueue({action:{kind:'remove'}})` 真把它拿掉。已经开始处理时宿主回 `queue-item-not-found`,这时要**如实说撤不回来**(409 + 人话),别假装成功。

37. **绝不要按名字杀浏览器。** 为了清"卡住的旧实例",截图前跑过 `Get-Process msedge | Stop-Process -Force` —— 那句是**按名字**杀,把用户正在用的浏览器窗口一起关了;每截一次图就杀一次,现象就是"我的浏览器窗口隔一会儿自己消失",隔壁会话为此盯了一整晚进程树才定位到。规则:**只动自己 spawn 出来的那棵树**。`shot.mjs` 现在每次用独立临时 profile + 随机调试端口,收尾 `taskkill /PID <自己的 pid> /T /F`,跑完删自己那份 profile,从根上不需要杀任何别人的进程。
38. **扩展名会骗人,媒体类型要按内容认。** 手机相册里存下来的"动图",文件名可能是 `1791380138027.jpeg`,内容却是 QuickTime/MP4(头 4 字节是 box 长度、第 5-8 字节是 `ftyp`)。按扩展名当图片发 → 浏览器解不开 → 背景整层撤掉,用户看到的就是"设了背景却没背景"。`mediaTypeOf()` 现在先看魔数,认不出才退回扩展名。顺带:`ftyp` 后面那个 **major brand** 才是关键(`heic/heix/hevc/hevx/heim/heis/mif1/msf1` 是图、`avif/avis` 是图、`qt  ` 是视频),只看 `ftyp` 会把 HEIC 照片当成 `video/mp4`。
39. **"进去慢"是往返次数 × 往返耗时,不是页面复杂。** 开机原来是两个请求:`/api/bootstrap`(列表)再 `/api/transcript`(对话)。走隧道时一个来回好几秒(实测经旧金山 DERP 8 s),两次就是十几秒 —— 数据量再小也没用,**等待是纯等待**。现在合成 `GET /api/boot?sessionId=...`:一次带回列表 + 对话 + 背景 + welcomed。还有第二个来回是**自己加回去的**:`connect()` 开长连接时会顺手补拉一次对话(防 SSE 缓冲大包),刚开机那份已经是最新的了 —— 所以 boot 路径记 `state.lastPullAt`,1.5 s 内不再重复拉。本机 127.0.0.1 实测:**可见 327 ms / 内容 500 ms,只发 1 次 `/api/boot`、0 次 `/api/transcript`**。

40. **`package.json` 不能有 BOM,而 PowerShell 5.1 的 `Set-Content -Encoding utf8` 会给你加上一个。** 改版本号时图快用了一句 PowerShell 写文件,结果 `package.json` 开头多了 `EF BB BF`:本机因为 Node 的 `JSON.parse` 宽容(其实不宽容,是构建工具先读文本再 parse)而没立刻炸,但 **tsdown / 加载器解析会直接失败** —— 也就是说推上去的那一版对同学是装不上的。现在版本号这类 JSON 一律用 node 写(`fs.readFileSync` → 去掉 `\uFEFF` → `JSON.parse` 验证 → `fs.writeFileSync(..., 'utf8')`),插件自己的重载预检也会拦这一条(`重载前构建产物预检失败:package.json 带 UTF-8 BOM`)。顺带记一笔:`.ps1` 里写中文**必须**带 BOM(否则 PowerShell 5.1 按 GBK 读,中文全乱),两条规则正好相反,别记混。
41. **"连不上"经常根本不是桥的问题 —— 是 DSH 整个进程没了。** 2026-10-08 早上:手机发消息、agent 跑了 51 秒,然后主机上的 DSH **当场退出**,手机桥随它一起消失,之后 3 小时 43 分一直连不上(没人重启就永远不会好)。遗言在 `~/.dsh/launcher/server.err.log.prev`:`ENOENT ... %TEMP%\dsh-subprocess-*\...-stdout.log`,来自 `@deepseek-ai/dsh-subprocess-local` 的 `OutputCollector.spillAll` —— 命令输出太长要落盘暂存,而那个临时目录被外部清掉了(C 盘只剩 3 GB,系统"清理临时文件"开着),`openSync` 抛的 ENOENT 发生在 socket 的 `data` 回调里,没人接得住 → 未捕获异常 → 进程退出。两层都修了:① `~/.dsh/launcher/patches/` 里的补丁让落盘失败降级成"只用内存"(附回归测试);② `open-dsh.ps1` 把 DSH 的 `TEMP` 挪到 `D:\dsh\tmp`,不给外部清理留机会。**诊断顺序**:手机连不上 → 先看电脑上 DSH 还活着吗(`~/.dsh/launcher/server.err.log.prev`、`dsh-desktop.log`)→ 再看桥的日志(`~/.dsh/mobile-bridge.log`,UTC 时间)→ 最后才怀疑网络/隧道。
42. **编辑带 BOM 的 `.ps1` 之后要把 BOM 补回去。** 改 `open-dsh.ps1` 加了两行,编辑工具把开头的 `EF BB BF` 吃掉了 —— 在 PowerShell 7 里看不出问题,但**这台机器是 PowerShell 5.1**,无 BOM 就按 GBK 读,中文注释里的字节被当成代码,解析器直接报 `The Try statement is missing its Catch or Finally block`。改完必须验:`[System.Management.Automation.Language.Parser]::ParseFile($path,[ref]$null,[ref]$errors)` 看 `$errors.Count` 是不是 0,并确认前 3 字节仍是 `EF BB BF`。同一次排查还发现 `dsh-routing-suite/scripts/install-injector.ps1` 本来就没有 BOM,**4 个语法错误、根本跑不起来**,补上 BOM 后归零。
43. **"拖动后又弹回原处、看不到消息"是渲染策略的锅,不是网络。** 老 `render()` 每帧 `log.innerHTML = 整段 HTML`:① 每次重排都重置滚动位置(手指拖多少抹多少);② 重排把 `content-visibility` 记住的行高全丢掉 —— 没渲染过的行只能用 `contain-intrinsic-size` **估算**,于是 `scrollHeight` 是说谎的,滚到那儿行高突然变大,视口内容被顶回去。修法三条:① **增量渲染**:每条内容带稳定 key(条目序号 + 类型),比对出"前缀没变"就只重排差异之后那一段(`keep > 0` 走 `renderTail`,只有整片换掉才 `renderAll`);② **拖动期间冻结渲染**:`touchstart/touchmove` 置冻结位,`touchend` 后还要等滚动事件停下来(每次 scroll 续 160 ms,覆盖 iOS 惯性滚动),松手后补画一次;③ `content-visibility` **只留给 `.tool/.deliver/.think` 折叠块**,绝不给 `.row`(消息行正是要滚动浏览的东西)。整段重排时的锚定也从"写回同一个 `scrollTop`"改成按【离底部多远】锚。真浏览器复核(`test/shot.mjs` 的 `scroll:` 行):能滚到顶(`top:0`)、能到底(`bottomGap:0`)、行的 `contentVisibility` 是 `visible` 而折叠块是 `auto`、停在中间触发一次真实重取后位置不变(`held:true`)。
44. **别把"拖动"和"点击"当一回事,也别在拖动中改 DOM。** 长按复制(`pointerdown` 起 550 ms 计时)已经存在,它和"拖动冻结"共用同一批触摸事件:冻结只影响渲染时机,不 preventDefault、不吞事件,所以重排让路和长按复制、点开折叠、点图看大图都不打架。测试里用 `new Event('touchstart')` 就够了(监听器是 passive 的、不看 touch 数据),这也是 `ui.mjs` 能覆盖这条路径的原因。
45. **"换个对话再回来又要加载"是两次浪费:清空 + 重下整段。** 换会话的旧实现是 `state.items = []` → 空屏 → 再拉一次整段对话(实测 **43 KB gzip**,隧道上一个来回好几秒)。两层都改了:
    - **按会话留现场**(`state.cache`,内存里最多 4 份):切走前 `rememberSession()` 存下 items/tools/cursor,切回来 `restoreSession()` 立刻铺上 —— 真浏览器实测「切完那一刻画面上就有内容」(`immediate=true`),**不再有空白**。
    - **只补新的那几条**:客户端把"我看到第几号事件了"(`state.cursor`)带上,`/api/stream?since=` 与 `/api/transcript?since=` 都认这个序号,只回这之后的记录。实测同一次切换 **43727 字节 → 84 字节**(`partial:true` 且 0 条记录)。
    - 两条安全底线:① `partial:true` 的快照**只能追加、绝不能清空重建**(里面只有新记录,清空就等于把历史抹了)—— 页面走 `applyPartialSnapshot()`;② 服务端 `sliceSince()` 只在 `since` 落在本份快照的序号范围内时才裁剪,客户端比能给的还旧(页面放了很久 / 历史被裁过 / 被压缩重排过)就老实回整段(**宁可多发,绝不能少发**)。乱写的 `since`(`abc`、`-1`)一律当没带。
    - 量法:`node test/switch-bench.mjs <url> <pin>`(真浏览器 + 真桥)报"切到没打开过的会话多久有内容 / 切回来是不是那一刻就有 / 这次切换下载了多少字节"。本机 127.0.0.1 实测:切回来 `immediate=true`、0 次整段下载。
46. **排队中的插话有两个"看不见"和一个"改不了",都要治。** ① 手机原来只画**自己**发的那条回声(靠 `rpcId` 认领 `agent/inbox/spliced` 里的 `inserted`),**电脑上插的话手机上完全看不到** —— 要等这一轮跑完、真消息进了对话才突然冒出来,用户以为"电脑上插的没生效"。现在认不出来的那些条目也画出来,并标清「电脑/别处发来的」(`remote: true`)。⚠️ 同一条 `inbox/spliced` 会在重连/快照重放时被**再喂一遍**(快照是重放不是增量),所以按 `itemId` 去重,否则会冒出好几条一样的。② 「修改」按钮:把原文**放回输入框**让用户在原文上改,不必重打一遍;顺序必须是**先撤回、再把文字放回去** —— 反过来的话用户改完一发,原来那条还在队列里照跑,同一句话发两遍还多花一轮钱。撤不回来(409,已经开始处理)时文字照样放回去,但必须**明说**"改完再发会多出一条"(`recallPending(entry, 'edit')` 负责这句话),并且把按钮收掉。③ 事件只给了 `removedCount`、没给"删的是哪几条":按最老的先删(撤回通常一次一条),剩下的靠"真消息进了对话"和十分钟兜底清掉。
47. **"每次从别的窗口点回来都要重新加载"是三层原因,分开治。** iOS 的 PWA 切走之后**很可能被系统整个回收**,回来就是一次冷启动(内存缓存全丢);于是又走一遍加载页、又重下整段对话;而加载页的进度条是"到一步跳一格",数字长时间不动,看着就是死了。
    - **对话落到手机上**:`saveTranscriptSnapshot()`(IndexedDB,`dshm-media` 库 version 2 加了 `transcripts`;写不进去退到 `localStorage` 的 `dshm.transcript.<id>`,上限 300 KB、最多 200 条)存 items + cursor;`visibilitychange=hidden` 时**立刻**存一次(那正是被回收前的最后一刻)。
    - **回来先铺上**:boot 第一件事是读本地那份 → `render()` → 再发 `/api/boot?sessionId=…&since=<cursor>`(服务端认这个序号,只回新的那几条,通常 0 条)。真浏览器实测:把网络延迟拉到 **2 秒**,对话在 **77 ms** 就出现在屏幕上,界面在 2087 ms(一次小请求)显示出来。
    - ⚠️ **缓存只往 DOM 里铺,不提前显示**:能不能看要等 `/api/boot` 的 200(那就是鉴权),否则捡到手机的人不输 PIN 就能读到上一次的对话。退出登录时 `clearTranscriptSnapshots()` 抹掉本地那份。
    - **进度条一直在动**:`bootStep()` 改成"目标值 + 缓动 + 慢爬"(每秒约 1.2%,封顶目标 +10 且不超过 96),并且下载走 **XHR 的 progress**(用 `fetch` 的流拿的是解压后的字节,`content-length` 是压缩后的,两个口径算出来的百分比会冲过 100% 再倒退)→ 显示"38 / 90 KB · 42%"。`window.__dshBoot()` 是给测试/诊断看的读数(目标值、显示值、当前这步)。
    - 量法:`node test/reopen-bench.mjs <url> <pin>`。人眼看不到"卡住"的复现,这个能。
48. **发完消息后"光标钉住视线、怎么滑都弹回原位",是两个原因叠在一起。** ① "算不算在底部"的阈值原来是 **120px**,而流式输出每帧都重排一次 —— 手指刚往上滑一点就被判成"仍在底部"、下一帧立刻贴回去:滑多少抹多少。阈值收到 **24px**(宽容只在担心"差几像素"时有用)。② **输入框还聚焦着**:iOS 会不停把 caret 滚进可视区,和用户抢滚动条 —— 一发消息键盘还开着,怎么滑都被拽回去。现在手指一碰对话区就 `text.blur()`(收键盘,也是聊天类 App 的常规行为:往下翻 = 收键盘)。③ 手指按住期间**冻结"是否贴底"的判断**(`touchstart` 直接 `state.stick = false`;冻结中的 `scroll` 只续冻结、不重算),松手落在底部才恢复跟随 —— 以前每个 scroll 事件都重算一次 `atBottom()`,等于每滑一点就重新授权"贴回去"。回归测试用假高度 + **夹取 `scrollTop`**(jsdom 不夹取,真浏览器夹取,不夹的话"贴底"会跑出可滚动范围,测出来的数是假的)钉住这三条。
49. **"回弹"的根子在"边滚边改 DOM" —— 所以渲染必须按 key 复用节点。** iOS Safari 在滚动容器里删/建节点会**把滚动位置复位到改动之前**,而流式输出每帧都在改 DOM。老写法只要变化出现在中间,就会把后面的节点(包括底部的本地回声)整段删掉重建,手指底下的位置因此被拽回去;重建还会丢掉 `content-visibility` 记住的行高、让图片重新解码。现在 `reconcile(entries)` 按 `data-key` 认领既有节点:**只有内容真的变了的那一个节点才换 `innerHTML`**,其余节点一个字节都不动,顺序变化只是 `insertBefore` 移动既有节点。配套三条:① `setScrollTop()` 给自己设的滚动打标记(`selfScrolling`),别把自己的滚动误当成"用户刚滚过"把跟随锁死;② 用户滚过之后 **400ms 内绝不自动滚**(`userScrolledRecently()`),宁可让他自己滑;③ 渲染最快 **80ms 一次**(流式每秒几十条增量没必要每条都重画,手机上那是最长掉帧的来源)。真浏览器复现工具:`node test/scroll-live.mjs <url> <pin>` —— 它会开一个临时会话、让它真的流式输出一千多行,然后一边往上滚一边采样 15 秒,报"回弹了几次 / scrollHeight 变动区间 / 最长掉帧"。修复后实测:`scrollHeight` 从 1.2 KB 长到 43 KB 的过程中 `scrollTop` **一动不动**,回弹 0 次。
50. **不要跟用户抢滚动条,给一个"回到最新"按钮代替。** 自动跟随只在"用户自己贴在底部"时才生效;一旦他往上翻,就不再自动拽回(这正是回弹的观感来源),而是在底部浮出 `#tonew`("↓ 回到最新"),点了才跳回去并恢复跟随。这样即使某个浏览器还有古怪的滚动行为,用户也有明确的出路,而不是被卡在一个位置上。
51. **`content-visibility` 被彻底拿掉了 —— 它是"拿正确性换性能"的典型,而且连性能都没换到。** 它让滚出屏幕的块按 `contain-intrinsic-size` 的**估算高度**参与 `scrollHeight`:估算 132px、真实几百像素,用户往上一滚进历史,行高突然变大,视口内容就被顶回去 —— "拖一下又弹回原处"里就有它一份。按 key 复用节点之后它那点收益也不存在了:真浏览器实测(`test/scroll-live.mjs`,真正的流式输出)去掉它之后**最长掉帧从 108ms 降到 61ms**。现在页面里一处都不用,`ui.mjs` 里有一条断言直接钉住这件事(钉的是根因,不是"阈值等于几像素")。

---

## 诊断纪律:先复现,再治本(治表要被当成失败)

这个项目里最贵的几次返工,都是**没复现就动手**造成的。写成规矩,省得下次又犯:

1. **先复现,再改代码。** 用户报的现象必须在**真环境**里重现出来(真浏览器 + 真桥,
   必要时真流式输出),或者从日志/崩溃遗言里拿到确凿证据。工具表里那几个
   `*-bench.mjs` / `scroll-live.mjs` / `shot.mjs` / `phone-diag.mjs` 就是干这个的;
   需要新的复现手段,就先写工具。
2. **复现不了就明说,不许靠猜。** 说清"我拿到什么证据、还差什么",然后去加测量点
   (例如 `window.__dshBoot()` 这种读数),而不是先塞一个"看起来能缓解"的改动。
3. **区分"症状"和"根因",改完要能解释为什么它不会再犯。** 判据:这条改动如果拿掉,
   问题会不会回来?会 —— 那它治的是根因。不会 —— 那它只是挡了一层。
4. **不许用"性能"当估算的借口。** 典型反面教材:`content-visibility` 让滚动容器里的
   块按估算高度参与 `scrollHeight`,用户往上一滚内容就被顶回去 —— 那是拿**正确性**
   换性能,而实测它连性能都没换来(去掉之后流式期间最长掉帧 108ms → 61ms)。
   要性能就先量,量不出收益就别加。
5. **同一个 bug 第二次回来时,先怀疑上一次的修法是治表。** "回弹"就是这样:第一次改
   阈值和键盘(治表),第二次才挖到"边滚边改 DOM 会被 iOS 复位滚动位置"(根因)。
6. **修完把根因写进这份 README 的坑表**(现象 → 证据 → 根因 → 为什么不会再犯),
   并在测试里钉住**根因**,而不是钉住那个数字。例:现在 `ui.mjs` 断言的是
   "页面里不用 content-visibility"(根因),而不是"阈值等于 24px"。

## 开始页上的版本说明(1.8.0 起)

开始页两处都写着版本,报障时不用再问"你装的是哪一版":

- **PIN 登录页**底部:`手机桥 v1.8.0`;
- **「开始使用」说明页**:版本号 + **最近三版的更新要点**(只挑"加 / 改 / 修"这类用户能感知的,
  不带"测试""实现细节")。

两个数字都是**现读**的,不是手抄:

| 显示什么 | 从哪儿读 |
|---|---|
| 版本号 | `package.json` 的 `version` |
| 更新要点 | `CHANGELOG.md` 头部最上面三节的小标题 |

手抄一份的下场是迟早对不上,而版本号一旦开始撒谎,排查就没有依据了。读不到就整块收起来
(不会变成报错)。更新清单以 `<script type="application/json">` 注入,客户端用 `textContent`
建节点 —— CHANGELOG 是我们自己的文本,但"把文本当 HTML 塞进页面"这种事一次都不做。

> 顺带修掉的一个真坑:页面里的注入标记**出现不止一次**(版本标记在登录页和说明页各一处),
> 而 `String.replace(字符串, …)` 只换**第一处** —— 结果是说明页上摆着一串
> `__DSH_BRIDGE_VERSION__` 给用户看。现在全部用 `replaceAll`,harness 里也钉了一条
> "注入标记一处都没漏"。

## 手机打开电脑上的文档(2.1.0 起)

会话里出现的文档——agent 在电脑上写的文稿、导出的 PDF、脚本、数据表——在手机上**多一个「打开 ›」**:

| 类型 | 手机上怎么呈现 |
|---|---|
| Markdown / 文本 / 日志 / YAML / 代码 / CSV / JSON | **直接在页面里渲染**(Markdown 会做标题/加粗/列表格式化) |
| PDF | 用 iframe 打开,手机浏览器自己翻页 |
| 图片 / 音频 / 视频 | 直接看 / 听(原有能力) |
| Word / Excel / PPT / 压缩包 | 浏览器**渲染不了** → 页面会明说,并指向右上角「下载」(用手机上的 WPS/Office 打开) |

范围是**最小方案**:只服务**会话里给你的文件**,不做目录浏览 ✓。

安全上有两条硬的:

- 文档走 `/api/file`,路径必须在桥认识的目录里(工作区 / 会话目录 / `~/.dsh` / 临时目录)✓;
- **密钥类文件一律 403**:`mobile-bridge.json`(PIN 明文)、`.credentials.yaml`(模型 key)、
  `image-gen.json`(生图 key)、`.env*`、`*.key/*.pem/*.pfx/*.p12/*.keystore` 都不通过手机发送 ✓
  (加文档类型时发现的洞:`.json` 一度让它们变成"点一下就下载")。
- 文档另有 25 MB 上限(手机上打开几十 MB 的文件只会卡死)。

## 顶端显示当前模型(2.6.0 起)

网页最上面(标题栏正下方)有一行 **「当前模型」**:

- 值来自**宿主**(每一行会话带的 `modelSelection` 投影),所以**在电脑上换过模型,手机这里也跟着变** ✓
  —— 2.4.2 那个"手机永远显示默认"的已知限制在这一版修掉了。宿主在流里推的
  `request/header`(下一次请求真用哪个)和 `model/selection`(刚选了还没发)都会立刻反映过来,
  **不用重开页面**。
- 它同时是**切换器**:点开就能换,落在**当前会话**上(和「新建」面板里那份目录是同一份)。
- 宿主报出来的模型**不在目录里**时(provider 被停用、目录没拉到)也照样显示那一个,
  不会静默跳到第一项 —— 这个功能的全部意义就是"看到正在用的到底是哪个"。

## 删除会话(2.6.0 起)

会话列表里每一行右边多了一个 **🗑**(在 📌 / ⭐ 后面)。点它打开一张确认纸:

- **必须现输 PIN**(就是登录手机桥那个 6 位 PIN)—— 每次都要,**不复用"最近提权过"**。
  这是手机上唯一不可从手机撤销的操作,所以门槛单独抬一级:手上有一部已解锁手机的人,
  不能直接把你几十个会话从列表里清空。PIN 错了就地报错(不会把你踢回登录页),
  而且同样进登录限速 —— 它不是可以无限试的接口。
- **正在跑的会话不会被悄悄删掉**:宿主先拒绝,页面把按钮换成「停掉它并删除」,
  **你再点一次**才真的连正在跑的一起停。
- 删完**列表立刻刷新**;如果删的正好是你正在看的那个,页面会自动让到另一个会话。

### 它到底是"删"还是"归档"?

**是归档**(宿主原生的 `archiveSession`,桌面端会话菜单里那一项就是它):

- 会话**不再出现在手机和电脑的会话列表里**;内容仍留在电脑上,在电脑端可以**取消归档找回来**;
- 桥**不会自己去删会话文件**。手删会话日志(JSONL)会绕过宿主的索引、附件目录和派生会话的
  血缘 —— 那是真会弄坏东西的做法,所以这条线不碰。

### 列表默认不显示哪两类会话

用户报过"agent 子对话和测试对话占位置",所以这两类**默认不摆出来**:

| 类别 | 判据 | 为什么 |
|---|---|---|
| agent 子会话 | `origin: 'subagent'` 或带 `parentSessionId` | 手机本来就打不开它们(宿主只认父会话地址) |
| 自检 / 开发会话 | cwd 在**桥自己的目录**里 | 那是开发测试跑出来的("输出数字1到1200"这类),不是你的工作区 |

两条都只是**手机这一侧的显示规则**:宿主的会话数据一个字不改,桌面上照旧全都能看到。
列表底下会写明"已藏起 N 个",并有一个 **「显示全部会话」** 开关(记在手机本地)能看回来 ——
不静默过滤、也不会让人以为会话丢了。

## 从 2.x 升到 3.0 要做什么

**什么都不用做** ✓ —— 直接升级、手机刷新一次就行 ✓(所有新功能都默认"不动你现在的习惯" ✓)。
想让新东西生效,只有两三处可选动作:

| 想用 | 要做什么 |
|---|---|
| **有事通知我** | 用 **https 的网址**打开手机页(`https://…ts.net` ✓)→ 在「🩺 一键自检」里勾上「有事通知我」✓ |
| **说出来 → 文字** | 同上,**必须是 https**(浏览器只在安全地址给麦克风 ✗)→ 「🎤 录音」纸里点「说一句」✓;电脑上第一次用要先点「准备识别器」✓ |
| **念出来** | 不用配置 ✓(用手机自带的声音 ✓);点回答旁边的 **🔊** ✓ |
| **新设备要你点头** | 在 `~/.dsh/mobile-bridge.json` 里加 `"deviceApproval": true` ✓,然后到**电脑上**打开 `/approve` 放行 ✓(⚠️ 默认关 ✓;一台可信设备都没有时**自动放行** ✓,不会把你锁在外面 ✓)。**放行是按设备记的** ✓(3.4.1 起 ✓):同一台手机换网络 / 换地址 / 登录过期后重登,**都直接进** ✓;只有**没见过的设备**才要你点头 ✓ |
| **弱网模式** | 不用配置 ✓;在「🩺」里勾上,或等它在你发得慢时主动问你要不要开 ✓ |

⚠️ 两个**环境**前提(不是配置能绕过的 ✗):**通知**和**麦克风**都要求浏览器认为你在"安全地址"上 ✓
(https 或 localhost ✓)——用 `http://100.x.x.x:3081` 这种局域网地址时,浏览器直接不给,
插件会**如实告诉你**,并保留"标签页标题提醒"这个兜底 ✓。

## 把手机当第一屏(3.0.0 起)

- **离线优先**:写到一半的话自动存住 ✓(点一下输入框就回来 ✓);整屏离线时能直接读到上次的对话 ✓、
  能接着写 ✓、并且**一眼看到"有几条还在等着发"** ✓。
- **有事一定找得到你**:要你拍板 / 一轮跑完 → 页面在后台就弹通知 ✓(正看着时不打扰 ✓;
  几分钟的一轮才叫 ✓);点通知回到页面 ✓。安全地址之外用**标签页标题**兜底 ✓。
- **说 → 文字 → 你看一眼再发**:转出的文字**只进输入框** ✓,插件**绝不替你发出去** ✓。
- **念出来**:助手回答旁的 🔊(代码不念 ✓、超 1200 字只念一段并说明 ✓、再点一下停 ✓)。
- **新设备要你点头**(可选,默认关):电脑上 `/approve` 放行 ✓;防锁死三条见上表 ✓。
- **连不上自己撑住**:带倒计时的重试 ✓、切到当前地址 ✓、改用局域网地址 ✓(会先判能不能用 ✓)、
  离线读缓存 ✓、排查清单 ✓。

## 从 2.9 到 2.10(键盘手感 / 长回答 / 弱网 / 看图)

……见下面各节 ✓(README 这一版按"第一屏"重排 ✓,旧的说明都还在 ✓)。

## 看图 / 相册滑动(2.11.0 起)

点开一张图(或视频)进全屏:
- **左右滑**(或按 ‹ ›;桌面上按方向键)在**同一组**之间翻,计数写着 `3 / 7`。
  "一组" = 点的那张所在的**气泡/交付块**;只有一张时退回整个对话(最多 40 张)。
- **📌 钉住** = 并排对比(挑封面):钉住的那张留在左边不动,另一张随手指翻。
  钉住当下不会把同一张复制成两份。
- **选这张** = 把这个素材的**电脑路径**写进输入框(`data-path`;没有路径就退而写链接),
  发出去就等于告诉 agent 用哪张。
- ⚠️ 全屏看图器开着时,**页面级左右翻页会让开** —— 不然在看图器里滑一下,底下的页面也跟着换。

## 弱网模式(2.11.0 起)

开关在「🩺 一键自检」纸里(不往工具键那排再加键)。开了之后:

- **文字立刻发**;图片/文件进"留着"的箱子,有网时**自动补发**(复用离线发件箱那条路:
  `requestId` 幂等 + 20 秒重试)。代价如实说:附件是**单独再发一条**,agent 晚一轮看到。
- ⚠️ **存不下就绝不"留着"**:附件是 data URL,localStorage 只有 ~5MB ——
  超过 1.2MB 或写入失败时**退回"一起发"**并说清原因(刷新后附件消失是最不能犯的错)。
- 发送中屏幕上写「📡 正在发送…」,发完自己收走(只收自己写的那句,不吞别人的提示)。
- 一条发得超过 6 秒 → 主动问一次要不要开(一个会话只问一次)。

## 读长回答 & 键盘手感(2.10.0 起)

工具键那一排里的 **⌨** 是这一批的两个入口(它俩都只记在这台手机上,也进备份):

**打长句子顺手**
- **↑ 取上一条已发消息**(↓ 往回翻;只记最近 20 条、单条 >4000 字的不进历史):只在"不会抢活"的时候接管 ——
  光标不在第一行时,↑/↓ 还是正常的"看上一行"。**一打字就退出翻历史状态**(所以取回历史后再改字不会被抹掉)。
  ⚠️ 历史是**这台手机共用一份、不分会话**(像终端的 history 一样)。
- **Enter 可直接发送**:默认**不勾**(Enter 就是换行,老习惯不动);勾上后 Shift+Enter 仍换行、
  Ctrl/⌘+Enter 一直是发送。
- **长按输入框再左右拖 → 移光标**:**默认关**(长按本来会触发系统的"选词 / 放大镜",硬抢只会两败俱伤)。

**长回答不刷屏**
- 超过 22 行的回答只露开头 8 行,其余点「展开全文」;超过 18 行的**代码块**同样默认折。
  折叠用的是原生 `<details>` —— 展开/收起**不重排、不跳滚动**。
- **代码高亮**(零依赖手写:关键字 / 字符串 / 注释 / 数字),铁律是**只加标签、不改内容** ——
  对**代码块**有一条**往返断言**(渲染出来的文字必须与原文一字不差,样本含转义反斜杠 / 反引号 /
  URL 里的 `#` / 十六进制 / 中文 / emoji)+ 一个注入探针。⚠️ 两处**口径边界**如实写在这儿:
  · **行内格式**(`**粗**`、`` `代码` ``)不是往返 —— 它本来就是 Markdown 渲染,标记会被吃掉;
  · `\r\n` 会归一成 `\n`(和 textarea 一致);单条 >8000 字符的代码块**不做高亮**(只转义,保内容)。
- **Markdown 表格**画成真表格,外面那层声明了 `overflow-x: auto`(⚠️ jsdom 没有布局引擎,
  "真的滑得动"这条只能真机看 —— 测试里也这么写的)。
- 单条 >24KB 会降级成纯文本(流式输出每 80ms 重画一次,长文里做排版会把帧率吃光)。
- 想全部铺开:⌨ 里把「长回答默认折叠」关掉,已有对话立刻重画。

## 收起看(2.9.0 / 2.9.1 起)

手机上竖着的地方最贵,所以上下两条栏都能收 ✓:

- **底部工具键**:输入框左边的 **`⋯`** —— 收起的**只有那 13 个工具键**;输入框、发送、页签栏、
  附件缩略图一个都不藏。开关自己一直看得见(放进被收起的那排 = 再也找不到)。
- **顶栏**:最左边的 **`⌃`** —— 收起后只剩状态点与开关(一条细缝);标题、「说明 / 刷新 / 退出」、
  「当前模型」那行一起让位。「已跑 X:XX / 本轮 token」**不跟着收**(它只在跑任务时出现,
  收掉等于把功能藏了)。
- 两个状态都记在手机本地(`dshm.tools` / `dshm.top`),刷新、切回 App 都记得,也进备份白名单。
- ⚠️ 实现上有一条**别踩的坑**:`#tools` 里还嵌着「只看不发」「离线发件箱」两条横幅和三张纸 ——
  收 `#tools` 会把它们一起 `display:none` 掉(祖先隐藏,`position:fixed` 也救不了)。
  所以收起的是新加的那层 `#toolKeys`(只包工具键)。

## 备份 / 换手机(2.9.0 起)

🩺 那页里的「💾 备份 / 换手机」:把这台手机上的设置导成一个 JSON 文件,新手机 / 重装 PWA /
清了站点数据之后导回来就还在(置顶、常用、用得多的顺序、只看不发、收起工具键、上次停在哪个会话)。

- **只备份"这台手机上的"东西**:电脑那边的配置(PIN / 端口 / 隧道)**不用备份** —— 换手机不动它。
- **两条故意不备份**:网址里的**密钥段**(`dshm.canonical` / `dshm.addresses` —— 备份文件是要发给自己、
  存网盘的,把访问密钥抄进去等于给文件配把钥匙),以及**离线发件箱**(恢复一份几天前的备份不该
  替你把过时的消息发出去)。
- 导入按**外部输入**对待:只认白名单键、只收字符串、有大小上限;坏文件当场说"没恢复",
  白名单外的键跳过并报数。恢复后置顶 / 常用 / 只看不发立刻生效,"上次的会话与模型"刷新一次才生效
  (这一点写在界面上,不假装全都立刻好了)。

## 自己会看病(2.8.0 起)

手机上一个 **🩺** 键 + 一个 **🛡** 键,配桌面角标上的报警:

- **🩺 一键自检**:七条**只读**检查(桥在不在听 / 桌面端在不在听 / 开机自启装没装 /
  有没有会话在跑 / 外网隧道通不通 / 上传目录能不能写 / ffmpeg 在不在),**每条都写"怎么办"**。
  它只看不动 —— 不会替你启动任何东西(那是 `tools/boot-up.ps1` 的活)。**不含**"publish 目录干净"
  这类维护者检查项:插件不该知道你的发布目录在哪。
- **🛡 安全审计**:谁在什么时候从哪个地址登录过、成没成,一行一行列出来(还有"当前还在登录的
  设备有几台")。**看这份账要现输一次 PIN** —— 里面是这台电脑的网络记录(和"看剪贴板 / 截图"同一级)。
  **新地址第一次成功登录**会在桌面角标上报警 —— 提示里写明"手机换 WiFi / 切流量时地址本来就会变,
  走外网时那是**隧道那端**的地址",免得把自己吓一跳。发现不对,页面上「踢掉其它设备」用刚输的
  PIN 就能把别的全部下线(**不会**把你自己这台踢掉)。
- **📦 诊断包**(在 🩺 那一页最下面):下载一份文本 —— 自检结果 + 最近日志 + 登录账 + 审批账 +
  崩溃日志。**PIN 与网址里的密钥段会被替换成占位符**,每份日志只截最近 48KB。
  出问题时先下这一份发出去,比描述一百句都准。
- **实时耗时 / token 跳动**:跑任务时顶部显示 `⏱ 已跑 1:05 · 本轮 ↑9.2k ↓340 · 本会话累计 ↑12.3M ↓106k`。
  计时起点取自 `turn/start` **事件自己的时间**(刷新页面把这一轮重放回来时,计时接着走,
  不是从打开那一刻重算);token 来自宿主的 `usage`(`assistant/message` 里的每轮用量)与
  `tokenUsage` 投影(会话累计)。**读不到就不显示那一段** —— 宿主没报就只显示时间,绝不编数字。
- **发送失败的「重试」**:条幅上有一个能点的键,点一下把刚才那条重发(输入框与附件都还在)。
  断网那种失败另走「离线发件箱」(2.7.0 起),两条路不重叠。

## 会话置顶与常用(2.5.0 起)

任务页顶部原来是一个**原生下拉**选会话 —— 放不下 📌 / ⭐ 按钮,也没法分组排序。
2.5.0 换成**自绘的分组列表**:顶部一行只显示"现在在哪个会话"(📌/⭐ 会一起标在标题前),
点开就是三组:

| 分组 | 谁在这里 | 怎么排 |
|---|---|---|
| 📌 置顶 | 你钉过的会话 | 按钉的顺序,**先钉的在前**;重启手机也还在 |
| ⭐ 常用 | **你打星的** + **用得最多的前 3 个**(自动) | 次数降序 → 平手按最近活动 |
| 🕘 最近 | 其余全部 | 最近活动降序 |

- 每行右侧两个小按钮:**📌 = 置顶开关**、**⭐ = 打星开关**(点一下标上、再点一下取消,
  按钮本身会亮起来);**点行本身 = 切到这个会话**。
- 「常用 A(按使用频率)」就是上面那条自动排序:**切过去 +1、发一条消息再 +1**;
  「常用 B(手动打星)」是你自己说了算 —— 一次没用过也能进「常用」。
- 「打不开的会话」(agent 自己派生的子会话)在那一行里写明 **⚠️ 打不开**,照样能点进去看原因。
- **三份数据只存在这台手机的浏览器里**(`dshm.pins` / `dshm.stars` / `dshm.usage`):
  **不写宿主的会话库、不给宿主加字段** —— 所以电脑端完全不受影响,换台手机也不会互相干扰。
- 触摸上有一条硬规矩:从 📌/⭐ 上起手时拦掉 `touchstart` 的冒泡 —— 否则按着那个小按钮
  横向拖一下就会被当成"翻页"。列表空白处**照样能左右滑换页**(滑动换页一个字没改)。

## 左右滑动换页(1.7.0 起,2.0.0 补齐面板)

底下一栏是三个页面:**任务 → 新建 → 余额**。除了点那三个键,**左右滑动也能换页**:
往左滑 = 下一页,往右滑 = 上一页(用户举的例子:在「新建」上往左滑就是「余额」)。

「新建」其实是一个盖在当前页上的面板(当初它是页签时,主按钮正好在页签栏上方,
拇指瞄页签会误建会话并立刻产生一次计费回合),但**滑动按用户心里的三页顺序走**,
不按 DOM 里那两个 view 走。

**录音 / 生图 / 背景 / 权限这几个面板上也照样能滑**(2.0.0 起,用户报的
「左右滑动时录音界面不行」):划走时那个面板自己关掉、直接翻到下一页。
1.7.0 时这四个"工具抽屉"开着会整个挡掉滑动 —— 用户的感受是"滑动时灵时不灵",
而"时灵时不灵"比"不支持"更难理解。

三条让路的规则(错了就会变成"东西自己动了"这类最难解释的故障):

| 情况 | 行为 |
|---|---|
| 竖着为主的滑动(横向位移不到纵向位移的 1.2 倍) | 当滚动,**绝不翻页** —— 用户在看历史时页面不能自己跳走 |
| 从输入框 / 下拉 / 播放器上起手,或从**横向能滚的元素**(代码块)上起手 | 不翻页(那是在选文字 / 调进度 / 看内容) |
| **说明页**、**提问卡片**、**离线整屏**开着时 | 背后不换页(这三处必须先处理掉;离线整屏 2.0.0 起也不再被穿透) |
| **正在录音**时 | 不翻页,并弹一句"先按「停止」"(面板上就是停止键,划走了够不着它) |
| 生图里**写了提示词**时 | 先 confirm 一句再走(和「新建」同一套) |

另外两条:幅度不到 60px 当点击;左右到头时只提示一句("已经是最左边了(任务)")。
离开「新建」前如果那一页写了东西,会**先 confirm 一句**再走。翻页时进场动画只是"确实翻了"的
即时反馈,`prefers-reduced-motion` 的人看不到它。

> 提示走的是 `#toast`,它的 z-index 必须**高于所有会盖住它的东西**(面板 80 / 加载页 90 /
> 离线整屏 95 → toast 120)。1.7.0 时它是 60,于是这些提示在面板上**根本看不见** ——
> 看不见的提示等于没提示,用户就会以为"在这儿滑动没反应"。

## 上滑出现「回到底部」的箭头(2.0.0 起)

往上翻着看历史时,屏幕下方会出现一个**圆形箭头「↓」**:

- **手指还按着往上滑的过程中就出现**(不用等松手);
- 点它 → 立刻回到底部、恢复"跟着新消息走",箭头自己收起;
- 已经贴到底部时不出现(不做常驻的无用按钮)。

旧版(1.7.0~1.8.x)这个入口是"12px 灰字小胶囊 + 松手 160ms 后才出现",
用户报的原话是"上滑就会出现的会到底部的箭头" —— 也就是**他压根没看见旧的那个**。

## 网页里直接录音(1.5.0 起)

**最底下那一栏**(任务 / 新建 / **🎤 录音** / 余额)里的「录音」:点开就是一个录音面板(大计时器 + 电平条),录完可以先听一遍,
满意再发。发出去之后会和别的音频一样**自动转成文字**交给模型。

- **为什么要在网页里录**:手机上"用系统录音 App 录 → 找到那个文件 → 分享给某个应用"
  这条路很难走;而且系统录音默认音质一节课 20~40 MB,在 20~70 KB/s 的上行下要传半小时。
  网页里录的是 **opus 约 24 kbps** —— 一节课约 **10 MB**,三分之一的体积。
- **两个前提**:① 浏览器只在**安全地址**下给麦克风 —— 用 `https://…ts.net`(Funnel)
  可以;用 `http://100.x.x.x:3081`(Tailscale 局域网 IP)浏览器会直接拒绝;
  ② 录的时候**别锁屏、别切到别的 App**(iOS 会掐掉网页录音)。
- 录音走的是**和「文件」完全一样**的上传路径(分片、可续),所以长录音也发得出去;
  音频单文件上限 80 MB。
- 体积:1 秒约 3 KB。45 分钟的课:

| | 网页录音(opus 24k) | 系统录音 App |
|---|---|---|
| 体积 | 约 8 MB | 约 20~40 MB |
| 在你那条上行上传 | 约 3~7 分钟 | 约 8~30 分钟 |

## 手机上生视频(1.4.0 起,用同一个「生图」键)

「生图」面板顶部有 **🖼 图片 / 🎬 视频** 两个模式。切到视频,写一句描述发出去,
agent 就会调电脑上的 `video_generate` 出片:

- 走智谱 **CogVideoX**,`cogvideox-flash` **免费**(文生视频 / 图生视频,最高 4K),
  用的是**生图那份智谱 key** —— 电脑上装过本仓库配套的生图插件、填过 key,就直接能用;
- 出片是**分钟级**的(免费模型实测 56~63 秒一段,高峰期排队更久);
- 出来的 mp4 落在 `~/.dsh/生成视频/`,**手机上直接显示成能播放的视频**,长按可存相册
  (手机端本来就会把文本里的 `.mp4` 路径渲染成播放器);
- 要更清晰、要带音频,可以让 agent 用 `cogvideox-3`(按量计费)。

手机桥这边**不直连视频后端**(和生图同一个取舍):产物必须落进当前会话,才看得见、存得下。

## 连不上电脑时,看到的是一屏说明(1.6.0 起)

用户原话:「连接不上时不是黑屏就是白屏」。两种成因都真实存在:

- **黑屏**:设了背景图/视频,而它取不到 → 深色底上什么都没有;
- **白屏**:连不上、又没有内容可渲染 → 只剩一个空壳。

现在这两种情况都会换成**一整屏说明**(不透明、压在最上层、失败时顺便把背景收掉):

| 屏幕上告诉你的 | 你能做的 |
|---|---|
| **哪一类故障**:手机自己没网 / 从没连上过 / 用着用着断了 —— 三句不同的话 | 照着第一句排查(多半是电脑睡了) |
| **10 秒后自动重试**(倒计时就写在屏幕上) | 也可以点「立即重试」 |
| **先看上次的对话(离线可读)** | 手机本地缓存的那份能读,等连上再发消息 |
| **切到当前地址 / 改用局域网地址** | 换过隧道地址之后,旧地址是要手动切走的 |
| **一份排查清单**:电脑睡没睡 / DSH 还在跑吗 / Tailscale 两端 / 是不是旧地址 / `tools\tunnel-status.ps1` | 不用自己猜 |

两个细节:**重试是"原地重跑一次 boot",不是整页刷新** —— 你正在读的东西不会被刷掉;
**401(PIN 过期)不会弹这一屏**,那时候该出现的是登录页,弹"连不上电脑"只会把人引去查网络。

## 兼容性

- 需要 DSH(peerDependency:`@deepseek-ai/cordis`)
- 纯 ESM,无构建步骤,无运行时依赖(只用 Node 内置模块)
- 只在 Windows 上实测过;监听、认证、页面都是平台无关的

## 量"进插件"要多久

```bash
node tools/measure-boot.mjs <手机桥网址> <PIN>          # 老用户(本地记着上次那个会话)
node tools/measure-boot.mjs <手机桥网址> <PIN> --cold   # 首装(没有本地标记,会多拉一次对话)
```

真浏览器跑一遍,报"界面可见 ms / 对话有内容 ms"、这次打开的请求数,还有一条**请求时间线**(哪个请求在什么时刻发出去)—— 慢的时候一眼能看出时间被哪个请求吃掉了,以及接口之间是串行还是并行。

本机 127.0.0.1 实测(工具会先按"老用户"给页面塞回 `dshm.session`,量的才是每次进来的真实路径):
**界面可见 327 ms、对话有内容 500 ms,只发 1 次 `/api/boot`、0 次 `/api/transcript`**。
手机上还要加上隧道往返,但数据量已经靠 gzip 压到 1/4~1/5(首屏 122 KB → 41.7 KB,
对话快照 202 KB → 42 KB),第二次打开还走 service worker 的缓存壳。

⚠️ 隧道慢的时候(`tailscale netcheck` 显示最近 DERP 在旧金山就是信号)别硬扛:
手机和电脑在同一个 WiFi / 热点下时,直接用 bootstrap 里报的局域网地址
(`http://<WLAN IP>:3081/<密钥>/`)比走 Funnel 快一个数量级。

---

## 手机上直接生图(要额外装一个插件)

「生图」键**本身只是个入口**:它把「请用 image_generate 生成一张图片…」这句话交给**当前会话**,
真正出图的是电脑上那个 `image_generate` 工具 —— 它是**一个单独的 DSH 插件,不在本仓库里**。

- 装了(会话里能看到 `image_generate` 工具)→ 点「生图」就出图,图落在会话里,可点开、长按存相册;
- 没装 → agent 只会回一句"没有这个工具 / 没有生图模型"。**这不是手机桥坏了。**

装法(一条命令):

```bash
dsh plugin --profile web add github:jxboop/dsh-plugin-image-gen
```

仓库:<https://github.com/jxboop/dsh-plugin-image-gen>(自带构建产物,装上即可用)。

装完**重启 DSH**,再刷新手机页面。验证:在电脑上开一个新会话,直接说"用 image_generate 生成一张图",
能出图就说明插件装好了。

> 免费生图后端(pollinations / HF Space)**不太稳**:会被限流(约 15 秒一条)、配额用尽时返回 402,
> 高峰期可能连续失败。真想稳定出图,去 <https://api.together.xyz> 免费注册拿 key,
> 填进插件配置的 `together.key` 并把 `providers` 设为 `["together"]`。

## 换手机界面的背景

**在手机上点工具栏的「背景」**,选一张图片 / 动图(GIF)/ 视频就完事:

1. 点「背景」→「选图片 / 动图 / 视频」;
2. 选好的文件会传到电脑、写进配置,**当场铺满界面**(不用刷新,也不用重载插件);
3. 想换就再选一张;想关掉点「去掉背景」。

几点说明:

- 背景是给**这台电脑**配的:连上来的手机看到的是同一张。
- 文件存在电脑的 `~/.dsh/mobile-uploads/`(只允许这个目录,`..` 逃不出去),不占手机空间;
  手机第一次打开下一次、之后走**手机本地缓存**(背景地址带 `?v=<大小>-<修改时间>`,换背景必然换缓存)。
- 支持 `.gif/.webp/.png/.jpg` 和 `.mp4/.mov/.webm`;**扩展名会骗人**,服务端按内容认类型。
- 手工改 `~/.dsh/mobile-bridge.json` 的 `backgroundFile` 仍然有效(老办法),但现在没必要了。

## 跑测试(改了代码之后必跑)

```powershell
cd <这个插件目录>
node test/package.mjs     # 打包清单、文本编码自检(乱码/BOM)
node test/harness.mjs     # 宿主接口与闸门(最多)
node test/qr.mjs          # 二维码编码器(含**往返解码**)
node test/ui.mjs          # 手机页面(jsdom)
node test/security.mjs
node test/crashprobe.mjs
node test/addresses.mjs
node test/offline.mjs
node test/platform.mjs    # 解锁那三种结论的纯函数(跨平台命令形态)
node tools/check-counts.mjs   # 跑上面九个 + 核对文档里的项数
```

- `test/voice.mjs`(33 条,语音草稿)**不在**上面这串里 —— 语音那一版按用户要求先跳过;
  它留在仓库里、可以单独 `node test/voice.mjs` 跑 ✓,但**不进门槛** ✗(别把"没验过"说成"验过了"✓)。

- 有两条需要**仅测试**依赖(`jsdom` 给界面测试 ✓、`jsqr` 给二维码往返解码 ✓)——
  它们放在 `..\_verify` 里,**和插件运行时无关**:插件本身**零运行时依赖** ✓。
- **缺 `jsqr` 时 `test/qr.mjs` 会显眼地 SKIP 那条往返解码** ✓(打印一行 `SKIP` + 写明怎么装 ✓),
  结果是「11 通过 + 1 跳过」、**exit 0** ✓ —— 既是"不是失败",也**不算验过** ✓。
  **发版前请在装了 jsqr 的地方跑一次**,确认往返解码那行是 `PASS` ✓(不然等于没验编码器 ✗):
  `npm i --no-save jsqr`。
- **发布目录(`publish/`)里没有那个 `_verify`** ✓,所以在那里跑会走 SKIP ✓ ——
  要真验二维码就回**源码目录**跑 ✓。

## License

MIT

Install

dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source