dsh-mini-remote 0.1.9 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  本文件记录每个版本改了什么。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.2.0] - 2026-10-08
6
+
7
+ 这一版跨了三天、50 笔改动,主体是**「进阶设置」——把电脑端那整套界面搬到手机上**,加上一颗「回到底部」的浮标,以及它俩连着带出来的一批手机端修补。
8
+
9
+ ### 新加的
10
+
11
+ - **手机上能看到电脑端那整套界面了。** 手机这张遥控页是专门为手机做的,所以电脑上那些插件贡献的设置页它进不去——改模型配置、装卸插件、翻会话记录这些事,原来只能回电脑。现在设置页多了一项「进阶设置」:打开它,下面多出一行「电脑端界面」,点「打开」,**同一份电脑端页面原样嵌在手机这半边**(不跳转、不开新标签,关掉就真停)。**它默认关着,打开要付代价**:开启等于把这个访问口令的权限,从遥控页露出的那一点扩到**整个电脑端设置面**(模型配置、凭据页、插件装卸、会话记录),所以页面上写明后果,要点两次才算开;服务端拿不到电脑端地址时整行不显示——不摆一个假的开关。
12
+
13
+ - 这四十来笔里绝大多数是把它磨到能用。值得记的是几处根因:**白屏**有两处(挂载前缀不对,加上我们自己多带了一个 token 参数);**「一直正在重新连接」**是 WebSocket 没转发;**一堆 403** 是 WebSocket 握手和普通请求的 `Origin` 得换成上游自己;**界面加载不出来**是镜像前缀下面的 POST 没转;**重启后卡在「Loading plugins」**是宿主还没就绪就被问了,该等而不是立刻回 502;一次 34 MB 的大附件照旧转发;带内容指纹的静态资源补了长期缓存。
14
+
15
+ - **首帧没字**查了好几轮,最后在真面板上量出来真凶:另一个插件贴在根元素上的「卷起」标记(`opacity:0`)。我们把那个标记摘掉,从样式上根治,删掉了中途加的盲兜底。**内容区滚不动**是我们自己的适配漏了一格 `min-height:0`;**侧栏那一列不能滚、「设置」够不着**、**侧栏中间那层被压成 0**(历史会话看不见)、**主页整片空白**、**插件市场那一节被压窄**各有各的根因,都修了;插件市场的「能滚的列表窗口」从 356 做到 498 像素。
16
+
17
+ - **这一页的适配由我们自己来**:别的插件写死了「只在窄屏生效」的样式,一律摘掉(`dsh-pocket` 是真正动官方设置面板的那一个,单独挡在门外)。官方设置面板的导航掰回竖排、字始终显示;插件市场那格的社区介绍、两个用不上的按钮先藏起来,把地方让给要用的东西。
18
+
19
+ - **怎么知道 DSH 一升级、这些适配会不会悄悄失效**:多了一份**结构依赖清单**(`tools/structure-deps.mjs`),把我们靠官方界面上的哪些「标记」活着一条条登记下来,并标明失配的后果(只是这一条不生效,还是可能误伤别处);配一个**升级自检**(`tools/structure-check.mjs`)对着活界面逐条查,中文报告、失配非零退出。清单和代码之间还钉了测试:改了代码没改清单、加了新依赖没登记,都会红。
20
+
21
+ - **多了一颗「回到底部」的圆形浮标。** 照电脑端 DSH 那颗做:聊天和「完整」两个模式里,往上翻过之后右下角浮出来,点一下回到最新那一条。**单帧模式下它一律不出现**(那一模式整屏只有一条回复,没有「回去」这回事);渐出 130 毫秒;深色浅色各适配一套——浅色下那道环深一档、抬一层更轻的投影,深色下投影本来等于零就去掉;看得见的圆比按得到的地方小一档(热区是手指的下限,不该跟着缩)。尺寸和位置是量着调的:看得见的面积先减半(38 → 27)、往左让开滚动条那条带,最后又增大一档到 31。
22
+
23
+ ### 修了的
24
+
25
+ - **「完整」模式里执行轨迹那个「进行中」不再竖着折行**(真机复现过);组头右侧的「模型原话」也走上 markdown 渲染,和别处的正文一个长相。
26
+ - **插话发送真的插话了。** 0.1.9 只做了「繁忙时是排队还是插话」那个开关,没接到发送路径上——选了插话,实际还是排队。
27
+ - **镜像页偶尔显示 `{"error":"not found"}`。** 那是我们自己的兜底 404:进阶设置关着的时候,门后面本来就不该回 JSON。
28
+ - **进阶设置关着时,那一行仍然显示。** 根因在 CSS,不在 JS。
29
+ - **`engines.dsh` 和 `peerDependencies` 声明的是同一个区间了。** 两处不一致时,npm 拿一个、宿主看另一个。
30
+
5
31
  ## [0.1.9] - 2026-10-05
6
32
 
7
33
  这一版跨了三天、74 笔改动,主体是**设置页整屏化与插件页**、**「完整」显示模式与执行轨迹**,以及一串观感与读取速度的修补。
package/README.en.md CHANGED
@@ -29,7 +29,7 @@ Here is what the interface looks like — dark and light, switchable in settings
29
29
 
30
30
  <img width="290" alt="Dark: one conversation, carrying only your instruction and the conclusion" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/screen-dark.png" /> <img width="290" alt="Light: the same conversation, in the palette taken from the promo artwork" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/screen-light.png" />
31
31
 
32
- It is **not** a second screen for the desktop interface. Approval dialogs, file diffs and tool-call chains exist on the computer and nowhere on the phone — deliberately. It does very little.
32
+ It is **not** a second screen for the desktop interface. Approval dialogs, file diffs and tool-call chains exist on the computer and nowhere on the default page — deliberately. It does very little. (If you want the **whole** desktop interface, there is an off-by-default entry in settings — see the last paragraph of [What the phone can do](#what-the-phone-can-do).)
33
33
 
34
34
  Other phone clients copy the computer's execution process over to the phone in full. This one does the opposite: **it sends only the two lines you asked for.** That is the single biggest difference between it and them.
35
35
 
@@ -66,7 +66,7 @@ If you already have the source on disk, you can point it at the folder:
66
66
  dsh plugin --profile web add <the folder you put the source in>
67
67
  ```
68
68
 
69
- **Which DSH versions it fits.** This plugin was developed on DSH **0.1.5-rc.2** and re-checked item by item on **0.1.7-rc.2** (every interface it uses was unchanged); **versions below 0.1.5 have not been tried**. `rc` is the tag the project puts on preview builds, and DSH as a whole is still a developer preview — minor versions may break things, so versions newer than 0.1.7 are not guaranteed either. If it ever does hit an incompatibility, the usual symptom is one entry point going missing (the model line at the top, say) while everything else keeps working: the plugin is written so that a missing service switches off that one feature rather than the whole thing.
69
+ **Which DSH versions it fits.** This plugin was developed on DSH **0.1.5-rc.2** and re-checked item by item on **0.1.7-rc.2**; **0.2.0 itself was built on 0.2.0-rc.1 / 0.2.0-rc.2** (every interface it uses was unchanged, and the full check suite ran on both sides). **Versions below 0.1.5 have not been tried**, and the declared ceiling is **0.3.0**. `rc` is the tag the project puts on preview builds, and DSH as a whole is still a developer preview — minor versions may break things, so versions newer than 0.2.0 are not guaranteed either. If it ever does hit an incompatibility, the usual symptom is one entry point going missing (the model line at the top, say) while everything else keeps working: the plugin is written so that a missing service switches off that one feature rather than the whole thing. The phone-side interface adaptations also come with a dependency manifest and an **upgrade self-check** (`tools/structure-check.mjs`): run it against the live page after an upgrade and it walks every dependency entry by entry.
70
70
 
71
71
  Settings follow the same rule: the settings are declared the standard DSH way, and on the 0.1.5-era library that ability does not exist yet — the plugin **still installs and works exactly as before**, it just loses the "applies immediately" behaviour mentioned below. On the **0.1.7 generation** that ability is fully there.
72
72
 
@@ -131,7 +131,7 @@ Open the link and that's the whole interface: one input box, and the latest repl
131
131
 
132
132
  **She speaks up while she works.** On the computer, the model often mutters a line between steps ("let me look at this file first"). That line is now spoken by the whale girl through her speech bubble — the talking pose is a newly drawn sprite, the rotation pauses for it, and afterwards picks up from where it stopped. It stays **in the bubble** and never bleeds into the answer area below. Only the line it deliberately writes *for a human reader* is spoken; its own working thoughts — often English, often a whole paragraph — never reach the phone at all. Should that one narration line itself be in English, it is shown as-is, untranslated (translating would mean putting words in its mouth).
133
133
 
134
- **Two appearances, switchable anytime.** Settings has an "Appearance" row: **Dark** (the default), **Light**, and **Follow system**. Dark is deep-sea navy; the light palette is taken from the promo artwork — near-white ice blue for the ground, deep royal blue for the text, and the same royal blue on primary buttons. Your choice is stored on the phone, so it is still there next time you open the page.
134
+ **Two appearances, switchable anytime.** Settings has an "Appearance" row: **Dark**, **Light** (the default), and **Follow system**. Dark is deep-sea navy; the light palette is taken from the promo artwork — near-white ice blue for the ground, deep royal blue for the text, and the same royal blue on primary buttons. Your choice is stored on the phone, so it is still there next time you open the page.
135
135
 
136
136
  <img width="290" alt="Settings (dark): display mode and appearance" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/settings-dark.png" /> <img width="290" alt="Settings (light): the selected states and the primary button turn royal blue too" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/settings-light.png" />
137
137
 
@@ -139,9 +139,7 @@ Open the link and that's the whole interface: one input box, and the latest repl
139
139
 
140
140
  <img width="290" alt="Running (dark, photographed on a phone): the whale girl running, 15s elapsed" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/running-dark-phone.jpg" /> <img width="290" alt="Running (light, photographed on a phone): the whale girl running, 7m43s elapsed, one instruction queued" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/running-light-phone.jpg" />
141
141
 
142
- Both are phone photographs (the browser's own chrome is in frame, so they sit slightly taller than a render would).
143
-
144
- **Two display modes.** Tap the ⚙ in the top right to switch. "Single frame" (the default) keeps only the newest reply on screen — good for "I just want to see how this one turned out". "Chat" is a back-and-forth bubble list — good for several rounds of follow-up questions. Use it for a while and you'll know which you prefer.
142
+ **Three display modes.** Tap the ⚙ in the top right to switch. "Single frame" (the default) keeps only the newest reply on screen — good for "I just want to see how this one turned out". "Chat" is a back-and-forth bubble list — good for several rounds of follow-up questions. "Full" takes that same list and **puts each step's process back in its original place** (the same shape the desktop app uses), with layers you can open one by one to see what that step read — and it is searchable. Use it for a while and you'll know which you prefer.
145
143
 
146
144
  **Open a session and you see what was said in it before.** Switch to a session that has existed for a while and Chat mode lists every earlier round (the instructions you sent, the conclusion of each round); Single frame shows only the last conclusion. **Very large sessions show only the most recent stretch**: reading one of those in full — tens of thousands of events — would drag the DSH process on your computer down, so the plugin reads just the tail and says so at the top ("this is not everything, only the most recent stretch"). That line is not boilerplate; it is the truth.
147
145
 
@@ -151,7 +149,9 @@ Both are phone photographs (the browser's own chrome is in frame, so they sit sl
151
149
 
152
150
  **Three things you can change in passing.** Tap the line at the top to switch the current session's model and reasoning effort; switch the permission preset (View Only / Workspace Write / Full Access); browse the computer's folders to register a new workspace and start a session in it.
153
151
 
154
- **You're reading conclusions, not the process.** There's no tool-call chain and no file diff on the page. The one exception is the line the whale girl says out loud — it goes into her bubble, never into the answer area (see above). **System-level confirmation dialogs cannot be answered from the phone** (approving a dangerous command, for instance) — those still need the computer. What the phone can answer is the multiple-choice question the AI puts to you; the two are not the same thing. It's a remote control: the TV still has to be on for the remote to be any use.
152
+ **By default you're reading conclusions, not the process.** In Single frame and Chat there is no tool-call chain and no file diff; the one exception is the line the whale girl says out loud — it goes into her bubble, never into the answer area (see above). To read the process you switch to "Full" yourself. **System-level confirmation dialogs cannot be answered from the phone** (approving a dangerous command, for instance) — those still need the computer. What the phone can answer is the multiple-choice question the AI puts to you; the two are not the same thing. It's a remote control: the TV still has to be on for the remote to be any use.
153
+
154
+ **The whole desktop interface can be brought over too ("Advanced settings").** Settings has an **off-by-default** "Advanced settings" entry: turn it on and a "Desktop interface" row appears below it; tap "Open" and the computer's full DSH page is embedded right there on the phone — no jump, no new tab — so model configuration, plugin install and removal, session records and anything else the remote page doesn't have can be done from here. **It is the exact opposite of the positioning above** (switch it on and the whole process is on screen), which is why it starts off: turning it on spells the consequence out (the password's reach extends to the entire desktop settings surface) and takes two taps to confirm. When the plugin cannot learn the desktop address, the entry does not appear at all.
155
155
 
156
156
  ---
157
157
 
@@ -233,6 +233,12 @@ So you can spend your time more freely — instead of being stuck in that small
233
233
 
234
234
  ---
235
235
 
236
+ ## For maintainers
237
+
238
+ After upgrading DSH, run `node tools/structure-check.mjs` once. It reports entry by entry: which ones still hold, which have drifted, and whether a drifted one actually needs attention. When anything has drifted the exit code is 1, so the check can be wired into a pipeline later.
239
+
240
+ ---
241
+
236
242
  ## Credits
237
243
 
238
244
  This plugin's design draws on [dsh-pocket](https://www.npmjs.com/package/dsh-pocket) by shaobeichen — in particular the shape of the configuration page, where each connection route gets its own link, its own QR code, and its own password.
package/README.md CHANGED
@@ -29,7 +29,7 @@
29
29
 
30
30
  <img width="290" alt="深色:一段对话,手机上只留你的指令和它的结论" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/screen-dark.png" /> <img width="290" alt="浅色:同一段对话,色调跟宣传图那张画统一" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/screen-light.png" />
31
31
 
32
- 它**不是**:电脑端界面的备用屏。电脑上有的审批弹窗、文件 diff、工具调用链,手机上都没有——它刻意做得很少。
32
+ 它**不是**:电脑端界面的备用屏。电脑上有的审批弹窗、文件 diff、工具调用链,默认那张页面上都没有——它刻意做得很少。(想要**整个**电脑端界面,设置里有一个默认关着的入口,见[手机上能做什么](#手机上能做什么)的最后一段。)
33
33
 
34
34
  别的手机端会把电脑上的执行过程原样搬过来,它反过来:**只把你要的那两句送过去**。这是它和它们最不一样的地方。
35
35
 
@@ -66,7 +66,7 @@ dsh plugin --profile web add github:xingzhen199186/dsh-mini-remote
66
66
  dsh plugin --profile web add <你放源码的目录>
67
67
  ```
68
68
 
69
- **它适配哪些 DSH 版本。** 这个插件在 DSH **0.1.5-rc.2** 上开发,在 **0.1.7-rc.2** 上重新逐项核对过(插件用到的那些接口,一个都没变),**低于 0.1.5 的版本没试过**。`rc` 是官方给预览版编的号,DSH 目前整体还是开发者预览版,跨小版本可能有破坏性改动,所以比 0.1.7 更新的版本同样不保证。真遇上不兼容,症状一般是某一块入口不见了(比如顶栏那行模型),其余部分照常——插件是按「少了哪个服务就关掉哪块功能」写的,不会整个起不来。
69
+ **它适配哪些 DSH 版本。** 这个插件在 DSH **0.1.5-rc.2** 上开发,在 **0.1.7-rc.2** 上重新逐项核对过,**0.2.0 这一版就是在 0.2.0-rc.1 / 0.2.0-rc.2 上做出来的**(插件用到的那些接口,一个都没变,两端各跑了一遍全套检查);**低于 0.1.5 的版本没试过**,声明的上限是 **0.3.0**。`rc` 是官方给预览版编的号,DSH 目前整体还是开发者预览版,跨小版本可能有破坏性改动,所以比 0.2.0 更新的版本同样不保证。真遇上不兼容,症状一般是某一块入口不见了(比如顶栏那行模型),其余部分照常——插件是按「少了哪个服务就关掉哪块功能」写的,不会整个起不来。手机端那些界面适配另有一份清单和一个**升级自检**(`tools/structure-check.mjs`),升级完对着活界面逐条查一遍,就知道哪条被改坏了。
70
70
 
71
71
  设置也是这样处理的:设置清单按 DSH 的标准方式声明,而在 0.1.5 那一代的库还没有相应能力,插件**照样装得上、功能照旧**,只是少了下面提到的「改完立即生效」。**0.1.7 这一代**上这条能力是齐的。
72
72
 
@@ -131,7 +131,7 @@ dsh plugin --profile web add <你放源码的目录>
131
131
 
132
132
  **鲸鱼娘动画。** 电脑上的模型在步骤之间常会自言自语一句(「我先看一下这个文件」这类),这一句现在由鲸鱼娘念出来——说话的动作是新画的一张立绘,轮播会为它停一下,说完接着从停下的地方往下走。
133
133
 
134
- **两套外观,随手换。** 设置里有一行「外观」:**深色**(默认)、**浅色**、**跟随系统**。深色是深海墨蓝打底;浅色那套的色是从宣传图那张画上取的——近白的冰蓝地、深宝蓝的字,主按钮也换成同一个宝蓝。选哪套记在手机上,下次打开还是它。
134
+ **两套外观,随手换。** 设置里有一行「外观」:**深色**、**浅色**(默认)、**跟随系统**。深色是深海墨蓝打底;浅色那套的色是从宣传图那张画上取的——近白的冰蓝地、深宝蓝的字,主按钮也换成同一个宝蓝。选哪套记在手机上,下次打开还是它。
135
135
 
136
136
  <img width="290" alt="设置(深色):显示模式与外观" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/settings-dark.png" /> <img width="290" alt="设置(浅色):选中态和主按钮都跟着换成宝蓝" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/settings-light.png" />
137
137
 
@@ -139,9 +139,7 @@ dsh plugin --profile web add <你放源码的目录>
139
139
 
140
140
  <img width="290" alt="执行中(深色,手机实拍):鲸鱼娘正在跑、已计时 15 秒" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/running-dark-phone.jpg" /> <img width="290" alt="执行中(浅色,手机实拍):鲸鱼娘正在跑、已计时 7 分 43 秒、队列排着一条" src="https://raw.githubusercontent.com/xingzhen199186/dsh-mini-remote/main/docs/running-light-phone.jpg" />
141
141
 
142
- 两张都是手机实拍(浏览器自带的状态栏也在画面上,所以比渲染图略高一点)。
143
-
144
- **两种显示模式。** 点右上角的 ⚙ 切换。「单帧」(默认)整屏只留最新一条回复,适合「我就想看这次的结果」;「聊天」是一来一回的气泡列表,适合连着追问好几轮。用一阵子你会知道自己偏哪个。
142
+ **三种显示模式。** 点右上角的 ⚙ 切换。「单帧」(默认)整屏只留最新一条回复,适合「我就想看这次的结果」;「聊天」是一来一回的气泡列表,适合连着追问好几轮;「完整」在聊天那一串里**把每一步过程插回原位**(和电脑端同一套长相),可以一层层点开看「这一步读了什么文件」,也能搜。用一阵子你会知道自己偏哪个。
145
143
 
146
144
  **点开一个会话,能看到它之前聊过什么。** 在左边切到一个早就存在的会话,聊天模式会把之前的每一轮都列出来(你发过的指令、每一轮的结论),单帧模式只显示最后一条结论。**特别大的会话只给最近一段**:那种几万个事件的会话,整份读下来会把电脑上的 DSH 拖垮,所以只读末尾那一小段,并在最上面写明「这不是全部,只显示了最近一段」——你看到的那句话不是客套话,是实情。
147
145
 
@@ -151,7 +149,9 @@ dsh plugin --profile web add <你放源码的目录>
151
149
 
152
150
  **顺手能改的三样。** 点顶栏那一行,换当前会话的模型和思考强度;切权限档位(仅可查看 / 工作区内修改 / 完全权限);浏览电脑上的目录,登记新的工作区和会话。
153
151
 
154
- **看的是结论,不是过程。** 页面上没有工具调用链、没有文件 diff。唯一的例外是鲸鱼娘嘴里那一句自言自语——它只进气泡,不进回答区(见上面那一段)。**系统层面的确认弹窗手机上点不了**(比如危险命令的审批),那类还得回电脑处理——手机上能答的是 AI 主动提给你的选择题,这两件事不一样。它的定位是一根遥控器:电视还得在那儿开着,遥控器才有用。
152
+ **默认看的是结论,不是过程。** 单帧和聊天这两个模式里没有工具调用链、没有文件 diff;唯一的例外是鲸鱼娘嘴里那一句自言自语——它只进气泡,不进回答区(见上面那一段)。想看过程,得自己切到「完整」模式。**系统层面的确认弹窗手机上点不了**(比如危险命令的审批),那类还得回电脑处理——手机上能答的是 AI 主动提给你的选择题,这两件事不一样。它的定位是一根遥控器:电视还得在那儿开着,遥控器才有用。
153
+
154
+ **想要整个电脑端界面,也搬得动(「进阶设置」)。** 设置页里有一项**默认关着**的「进阶设置」:打开它,下面会多出一行「电脑端界面」,点「打开」,电脑上那个完整的 DSH 页面就原样嵌在手机这半边,不跳转也不开新标签——改模型配置、装卸插件、翻会话记录这些手机页上没有的事,都能在这儿办。**它和上面那条定位正好相反**(一开,中间过程就全在屏幕上),所以默认关着:打开时页面会把后果说清楚(这个访问口令的权限会扩到整个电脑端设置面),要点两次确认;电脑端地址拿不到时,这一项不显示。
155
155
 
156
156
  ---
157
157
 
@@ -240,6 +240,12 @@ dsh plugin --profile web add <你放源码的目录>
240
240
 
241
241
  ---
242
242
 
243
+ ## 给维护者
244
+
245
+ 升级 DSH 之后,跑一次 `node tools/structure-check.mjs`。它会逐条报出来:哪些还在、哪些已经失配、失配的那条要不要管。有失配时退出码是 1,以后能直接接进流程里。
246
+
247
+ ---
248
+
243
249
  ## 致谢
244
250
 
245
251
  这个插件在设计思路上参考过 [dsh-pocket](https://www.npmjs.com/package/dsh-pocket)(作者 shaobeichen)——尤其是「每条连接路径各配一条链接、一个二维码、一个密码」这种配置形态。
package/lib/index.js CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  invalidCommandMessage, unknownCommandMessage,
50
50
  } from './commands.js'
51
51
  import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
52
+ import { createMirror } from './mirror.js'
52
53
  import { sendExternalNotification } from './notify.js'
53
54
  import { createFeishuChannel, feishuQuestionText, approvalText, FEISHU_TEXT_LIMIT } from './lark.js'
54
55
  import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
@@ -987,6 +988,27 @@ export function apply(ctx, config = {}) {
987
988
  return '这个会话现在叫不醒(电脑那边没答应),等一会儿再试。'
988
989
  }
989
990
 
991
+ /**
992
+ * 「繁忙时的发送行为」当前是哪个值——**发送那一刻现读,不缓存**。
993
+ *
994
+ * 为什么现读:这个值是**电脑端和手机端共用**的(就是 PC「设置 → 通用」里那一条),
995
+ * 用户在电脑上改了我们这边不一定知道。发送是低频动作,读一次 describe() 不值几个钱,
996
+ * 而缓存一份就会有「设置改了但发送还用老值」这种最难查的不一致。
997
+ *
998
+ * 读不到一律当 `queue`(排队)——那是 DSH 自己的默认值,也是最保守的那个:
999
+ * 宁可多等一轮,也不要莫名其妙打断正在跑的一轮。
1000
+ */
1001
+ function busyEnterMode() {
1002
+ try {
1003
+ const settings = invServices.settings ?? ctx.get('settings')
1004
+ const doc = (settings?.describe?.() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation')
1005
+ const value = doc?.value && typeof doc.value === 'object' ? doc.value : {}
1006
+ return value.busyEnter === 'steer' ? 'steer' : 'queue'
1007
+ } catch (err) {
1008
+ return 'queue'
1009
+ }
1010
+ }
1011
+
990
1012
  async function onInstruction(text, uploadIds) {
991
1013
  const sessionId = targetSessionId()
992
1014
  if (!sessionId) {
@@ -1012,7 +1034,7 @@ export function apply(ctx, config = {}) {
1012
1034
  }
1013
1035
 
1014
1036
  /**
1015
- * 跑着的时候**不再拒绝**,改成排队。
1037
+ * 跑着的时候**不再拒绝**,改成「排队」或「插话」——按设置里那一条走。
1016
1038
  *
1017
1039
  * 原来这里有一条 `agent.status === 'running'` 的挡板,回一句「上一个任务还在跑,
1018
1040
  * 等它完成再发」。那条挡板是多余的,而且**比 DSH 自己还严**:`followup()` 的
@@ -1020,9 +1042,26 @@ export function apply(ctx, config = {}) {
1020
1042
  * "Queue an ordinary follow-up turn and wake the driver"),跑着的时候照样收,
1021
1043
  * 排进 inbox.nextTurn,等当前这一轮结束再跑。DSH 桌面端同一时刻就能接着排。
1022
1044
  *
1023
- * 所以这里只是把「拒绝」换成「收下并说清楚它排队了」。
1045
+ * **2026-10-06 补上真正的「插话」。** 原来这里只会 `followup()`——那是**永远排队**。
1046
+ * 手机设置里那条「繁忙时的发送行为」当时只做了开关、没接上线,所以选了插话也照样排队
1047
+ * (用户实机发现:「插话发送似乎没实现,依然是排队发生」)。
1048
+ *
1049
+ * 现在按设置分岔,两条都是 DSH 自己的接口(`dsh-agent` 的类型注释原文):
1050
+ * · `followup(message)` —— "Queue an ordinary follow-up turn and wake the driver",
1051
+ * 进 inbox.nextTurn,等这一轮跑完;
1052
+ * · `steer(message)` —— "Submit steering for the nearest step",进 inbox.nextStep,
1053
+ * 跑着的驱动**在下一个步骤边界**就取走它(一个工具调用就是一个步骤,所以是秒级)。
1054
+ *
1055
+ * 两处刻意的保守:
1056
+ * ① 只有**真在跑**(queued)才插话。不跑的时候没什么可插的,走正常发送;
1057
+ * ② 老版本 DSH 上 `agent.steer` 可能不存在(它是后加的),所以要探一下类型——
1058
+ * 没有就退回排队,不能让一条指令发不出去。
1059
+ * PC 源码那边还留了一句定心的话:"Direct `steer` is intentionally best-effort:
1060
+ * AgentLoop turns a closed-window submission into the next waking Queue item."
1061
+ * ——**最坏情况它会自己退回排队**,不会丢。
1024
1062
  */
1025
1063
  const queued = agent.status === 'running'
1064
+ const steered = queued && busyEnterMode() === 'steer' && typeof agent.steer === 'function'
1026
1065
 
1027
1066
  const message = userMessage(text, files)
1028
1067
  // 必须在 followup 之前登记。
@@ -1038,7 +1077,10 @@ export function apply(ctx, config = {}) {
1038
1077
  // 正好记一次、也只在那时候出现。
1039
1078
  if (!queued) rememberInjected(message.id)
1040
1079
  try {
1041
- agent.followup(message)
1080
+ // 跑着的时候按设置分岔:插话进 nextStep(下一个步骤边界就取走),
1081
+ // 排队进 nextTurn(等这一轮跑完)。两条都是 DSH 自己的接口,见上面那段注释。
1082
+ if (steered) agent.steer(message)
1083
+ else agent.followup(message)
1042
1084
  } catch (err) {
1043
1085
  return { ok: false, error: `注入失败:${err?.message ?? err}` }
1044
1086
  }
@@ -1054,8 +1096,11 @@ export function apply(ctx, config = {}) {
1054
1096
  // 排队的那条也要推快照:队列列表是从 agent 收件箱读的,不推手机上就看不到
1055
1097
  // 它排在哪儿。区别只在于**不往 history 里记**,所以它不会长成一条气泡。
1056
1098
  server?.broadcast('state', store.snapshot())
1057
- // queued 只是给界面上那句提示用的:现在就在跑,所以这条得等下一轮。
1058
- return { ok: true, sessionId, queued }
1099
+ // queued / steered 只是给界面上那句提示用的:
1100
+ // queued —— 现在就在跑,所以这条得等下一轮;
1101
+ // steered —— 现在就在跑,但这条会在**下一个步骤边界**被取走(通常几秒)。
1102
+ // 两个不会同时为真。
1103
+ return { ok: true, sessionId, queued: queued && !steered, steered }
1059
1104
  }
1060
1105
 
1061
1106
  /**
@@ -3031,6 +3076,36 @@ export function apply(ctx, config = {}) {
3031
3076
  return { ok: true, current: next }
3032
3077
  },
3033
3078
 
3079
+ /**
3080
+ * 「进阶设置(电脑端界面)」的当前状态。
3081
+ *
3082
+ * `available` 和 `enabled` 是**两件事**,界面要分开说:
3083
+ * available=false —— 这台机器上就没有这个能力(没拿到 DSH_WEB_URL),整条不显示;
3084
+ * available=true、enabled=false —— 有能力,但用户没开(默认就是这个)。
3085
+ */
3086
+ mirrorSetting: () => ({
3087
+ ok: true,
3088
+ // `available` 问的是**此刻**能不能用(上游地址解析得出来),不是「建没建出这个对象」——
3089
+ // webServer 就绪得晚,插件刚加载那一刻问不到端口,那时候要如实说不可用。
3090
+ available: mirror.available(),
3091
+ enabled: store.state.mirrorEnabled === true,
3092
+ }),
3093
+
3094
+ /**
3095
+ * 开/关「进阶设置」。**这是权限开关,不是普通偏好**——见 store 里那段说明。
3096
+ *
3097
+ * 只认布尔值,别的一律拒绝(和 busyEnter 同一条口径:不替用户猜一个)。
3098
+ * 关掉时不做任何「清理」:那条路由是每请求现读开关的,关掉就等于不存在了,
3099
+ * 不需要再去撤销什么。
3100
+ */
3101
+ setMirror: (enabled) => {
3102
+ if (typeof enabled !== 'boolean') return { ok: false, reason: 'invalid' }
3103
+ if (!mirror.available()) return { ok: false, reason: 'unavailable' }
3104
+ const value = store.setMirrorEnabled(enabled)
3105
+ log.info?.(value ? '手机开了「进阶设置」:现在可以打开电脑端界面。' : '手机关掉了「进阶设置」。')
3106
+ return { ok: true, enabled: value }
3107
+ },
3108
+
3034
3109
  subagentTranscript: (childId, sessionId) => subagentTranscript(childId, sessionId),
3035
3110
  stopSubagent: (childId, sessionId, mode) => stopSubagent(childId, sessionId, mode),
3036
3111
  askSubagent: (childId, sessionId, mode, text) => askSubagent(childId, sessionId, mode, text),
@@ -3179,6 +3254,62 @@ export function apply(ctx, config = {}) {
3179
3254
  },
3180
3255
  }
3181
3256
 
3257
+ // ------------------------------------------------------------------
3258
+ // 「进阶设置」:电脑端界面的镜像(2026-10-06)
3259
+ //
3260
+ // 手机上那张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
3261
+ // 这一块是退路:**要改那些设置时,把电脑端界面整个搬过来看**——不迁移页面、
3262
+ // 不逐个适配、同一份界面。原委和认证怎么走,都写在 lib/mirror.js 开头。
3263
+ //
3264
+ // **地址从 webServer 服务问,不从环境变量猜。** 这一条是实测撞出来的:
3265
+ // 第一版读 `process.env.DSH_WEB_URL`,结果 `available=false`——那个变量是
3266
+ // **命令行工具那一侧**才有的(DSH 给子进程注入的),**宿主进程里根本没有**。
3267
+ // 宿主自己报地址的地方就是 webServer(它有 `port` 和 `host`),而且**两端都成立**:
3268
+ // 桌面端它指向那个只绑回环的界面,`dsh web` 那边它就是界面本身。
3269
+ //
3270
+ // 而且**必须惰性**:boot 期各插件并行加载,apply 这一刻 webServer 常常还没 provide。
3271
+ // 所以端口在下面那个 `ctx.inject(['webServer'])` 回调里才填上,
3272
+ // 在那之前镜像会如实说自己「还没法用」,界面那一行也就不显示。
3273
+ // ------------------------------------------------------------------
3274
+ let mirrorAppPort = null
3275
+
3276
+ function mirrorUpstream() {
3277
+ // webServer 到位了就用它——**回环字面量写在这里**:上游必须是本机那一个,
3278
+ // 而 webServer 的 host 可能是 0.0.0.0(那是「绑哪张网卡」,不是「连哪个地址」)。
3279
+ //
3280
+ // 没有 webServer 就没有可镜像的界面(纯 headless 组合),如实回 null,
3281
+ // 界面那一行不显示。**这里刻意不留 `DSH_WEB_URL` 的退路**:实测那个变量只在
3282
+ // 命令行工具那一侧存在,宿主里没有;而且留着它会让测试在开发机上意外指到
3283
+ // 用户真实运行的界面上——测试不该有这种机会。
3284
+ if (typeof mirrorAppPort === 'number' && mirrorAppPort > 0) {
3285
+ return `http://127.0.0.1:${mirrorAppPort}`
3286
+ }
3287
+ return null
3288
+ }
3289
+
3290
+ const mirror = createMirror({
3291
+ upstream: mirrorUpstream,
3292
+ /**
3293
+ * 取「带官方令牌的地址」。**惰性求值**:这一刻 connection 服务可能还没 provide,
3294
+ * 但转发是真发生请求时才问,那时候它一定在。
3295
+ */
3296
+ tokenUrl: () => {
3297
+ const connection = ctx.get('connection')
3298
+ if (!connection || typeof connection.authenticatedUrl !== 'function') return null
3299
+ const base = mirrorUpstream()
3300
+ if (!base) return null
3301
+ try {
3302
+ return connection.authenticatedUrl(`${base}/`)
3303
+ } catch (err) {
3304
+ log.warn?.('取电脑端界面的访问凭据失败:' + (err?.message ?? err))
3305
+ return null
3306
+ }
3307
+ },
3308
+ log: (msg) => log.warn?.('[镜像] ' + msg),
3309
+ })
3310
+
3311
+ log.info?.('「进阶设置(电脑端界面)」已就绪,默认关;等 webServer 报出端口后可用。')
3312
+
3182
3313
  // ------------------------------------------------------------------
3183
3314
  // 起服务
3184
3315
  // ------------------------------------------------------------------
@@ -3191,6 +3322,11 @@ export function apply(ctx, config = {}) {
3191
3322
  bindAddresses: resolveBindAddresses(settings.bindAddress),
3192
3323
  tunnelHost,
3193
3324
  log,
3325
+ // 镜像那一路:实例 + **每个请求现读的开关**(关着时那条路由根本不存在)。
3326
+ // 上游地址没解析出来时也当关着——宁可回 404(查无此路),也不要回一个
3327
+ // 「有这条路但它坏了」的 502:前者是实话,后者会让用户以为功能坏了。
3328
+ mirror,
3329
+ mirrorEnabled: () => store.state.mirrorEnabled === true && mirror.available(),
3194
3330
  onInstruction,
3195
3331
  onCommands,
3196
3332
  onCommand,
@@ -3296,6 +3432,14 @@ export function apply(ctx, config = {}) {
3296
3432
  return
3297
3433
  }
3298
3434
 
3435
+ // 「进阶设置」的镜像要往**这台服务器**转发——它就是电脑端界面本身
3436
+ // (桌面端那个只绑回环的 19387;`dsh web` 那边就是 3080)。
3437
+ // 端口到这里才知道,所以镜像那边是惰性求值的(见上面 mirrorUpstream)。
3438
+ if (typeof webServer.port === 'number' && webServer.port > 0) {
3439
+ mirrorAppPort = webServer.port
3440
+ log.info?.(`「进阶设置」的上游定为这台界面:127.0.0.1:${webServer.port}`)
3441
+ }
3442
+
3299
3443
  // 面板和开关都带着 token,所以两者都只允许从这台电脑自己访问。否则同一个
3300
3444
  // Wi-Fi 下谁都能把密码取走,token 就白设了。
3301
3445
  function localOnly(req, res) {