dsh-mini-remote 0.1.8 → 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/lib/server.js CHANGED
@@ -12,11 +12,30 @@
12
12
  * - 只需要服务端→手机单向推送;手机发指令用普通 POST 即可
13
13
  */
14
14
  import { createServer } from 'node:http'
15
- import { timingSafeEqual } from 'node:crypto'
15
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
16
+ import { appendFile, mkdir } from 'node:fs/promises'
17
+ import { dirname } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
16
19
  import { renderPage, readArt, readImage, readSw } from './page.js'
17
20
 
18
21
  const COOKIE = 'dsh_mini_token'
19
22
 
23
+ /**
24
+ * 镜像页「诊断模式」那份样本落在哪。
25
+ *
26
+ * **`scratch/` 是 gitignore 的**,所以诊断日志扔在这里不会入库;
27
+ * 路径按本文件的位置算(`lib/server.js` 的上一级就是插件根目录),
28
+ * 装到哪儿都不用再配一次。
29
+ *
30
+ * `DSH_MINI_DIAG_LOG` 可以整体换掉它——**测试用它写到临时文件**,
31
+ * 免得测试的样本混进真机上那一份里,让读日志的人分不清哪条是真机报的。
32
+ */
33
+ function diagLogPath() {
34
+ const override = process.env.DSH_MINI_DIAG_LOG
35
+ if (override) return override
36
+ return fileURLToPath(new URL('../scratch/diag.log', import.meta.url))
37
+ }
38
+
20
39
  /**
21
40
  * 配置的端口被占时,往后最多再试几个。
22
41
  *
@@ -137,6 +156,97 @@ export function sendJson(res, status, payload) {
137
156
  res.end(body)
138
157
  }
139
158
 
159
+ /**
160
+ * 镜像那几条路径**此刻走不通**时,回给手机的那一页(调用处见 `handle` 里的兜底 404)。
161
+ *
162
+ * ## 为什么非要有它(2026-10-08 真机截图)
163
+ *
164
+ * `/mini/mirror` 那一整块路由是挂在开关上的(见 `handle` 里 `mirrorEnabled()` 那一行),
165
+ * 所以「开关关着」和「电脑端还没就绪」这两种时候,`/mini/mirror/…` 会掉进兜底 404,
166
+ * 原来回的是 `{"error":"not found"}`。**而手机那一页是把镜像嵌在 iframe 里的**:
167
+ * 这个 JSON 于是成了框里的**文档**,被浏览器当成页面渲染出来——用户看到的是框里一条
168
+ * 外星文(华为浏览器还给它套了个「美观输出」的 JSON 查看器),外面那条
169
+ * 「电脑端界面 / 返回」却还好端端地在,看起来就像界面坏了。**给程序看的正文跑到了人眼前。**
170
+ *
171
+ * ## 三条要求
172
+ *
173
+ * ① **人话**:现在打不开、以及下一步做什么;
174
+ * ② **自己会重试**:宿主刚重启那几十秒里上游地址还没解析出来,等一下它自己就好了
175
+ * ——真机上「有时候打得开、有时候打不开」就是这一段(见 lib/mirror.js 的 resolveReady);
176
+ * ③ **有出口**:那个框里没有别的按钮,所以给一个「返回手机页」——它给外层页发一条消息,
177
+ * `lib/page.html` 里接住、把那整屏收起来。
178
+ *
179
+ * **状态码仍然是 404**:这条路此刻确实不存在(开关关着时它就该像不存在一样),
180
+ * 这里换掉的只是**正文**,不是「这条路存不存在」这件事本身。
181
+ */
182
+ export function sendMirrorUnavailable(res) {
183
+ // 重试次数放在地址里(`&retry=n`):不依赖 localStorage,也不会让框里的历史变长
184
+ // (用 location.replace)。**次数用尽就停下**——一直空转会烧手机的电,也永远不给交代。
185
+ // 入口那一下会把整个查询串丢掉,所以这个参数绝不会漏到上游去。
186
+ const html = `<!doctype html>
187
+ <html lang="zh-CN">
188
+ <head>
189
+ <meta charset="utf-8">
190
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
191
+ <title>电脑端界面现在打不开</title>
192
+ <style>
193
+ :root { color-scheme: light dark; }
194
+ body { margin: 0; padding: 24px 18px; color: #1b1b1b; background: #fff;
195
+ font: 15px/1.7 system-ui, -apple-system, "Noto Sans CJK SC", sans-serif; }
196
+ h1 { margin: 0 0 12px; font-size: 17px; }
197
+ p { margin: 0 0 10px; color: #555; }
198
+ .acts { display: flex; gap: 10px; margin-top: 20px; }
199
+ button { font: inherit; padding: 10px 16px; border-radius: 2px; color: inherit;
200
+ border: 1px solid #d6d6d6; background: #f6f6f6; }
201
+ @media (prefers-color-scheme: dark) {
202
+ body { color: #eee; background: #111; }
203
+ p { color: #aaa; }
204
+ button { border-color: #3a3a3a; background: #1e1e1e; }
205
+ }
206
+ </style>
207
+ </head>
208
+ <body>
209
+ <h1>电脑端界面现在打不开</h1>
210
+ <p id="why">正在等电脑端就绪…</p>
211
+ <p>电脑端刚起来的那几十秒里会是这样,等一下通常就好。如果一直打不开:回到手机页,在「进阶设置」里确认「电脑端界面」是开着的,再点一次「打开」。</p>
212
+ <div class="acts">
213
+ <button id="retry" type="button">重试</button>
214
+ <button id="back" type="button">返回手机页</button>
215
+ </div>
216
+ <script>(function () {
217
+ var MAX = 5; // 自己最多重试几次
218
+ var GAP = 2500; // 每次之间隔多久
219
+ var why = document.getElementById('why');
220
+ var url = new URL(location.href);
221
+ var n = Number(url.searchParams.get('retry') || 0);
222
+ if (!(n >= 0)) n = 0;
223
+ document.getElementById('retry').addEventListener('click', function () {
224
+ var clean = new URL(location.href);
225
+ clean.searchParams.delete('retry');
226
+ location.replace(clean.toString());
227
+ });
228
+ document.getElementById('back').addEventListener('click', function () {
229
+ try { window.parent.postMessage('mini-mirror-close', '*'); } catch (e) {}
230
+ });
231
+ if (n >= MAX) {
232
+ why.textContent = '还没等到电脑端就绪(也可能是手机那边的开关关着)。';
233
+ return;
234
+ }
235
+ why.textContent = '正在等电脑端就绪…(第 ' + (n + 1) + ' 次,共 ' + MAX + ' 次)';
236
+ url.searchParams.set('retry', String(n + 1));
237
+ setTimeout(function () { location.replace(url.toString()); }, GAP);
238
+ })();</script>
239
+ </body>
240
+ </html>`
241
+ const buf = Buffer.from(html, 'utf8')
242
+ res.writeHead(404, {
243
+ 'Content-Type': 'text/html; charset=utf-8',
244
+ 'Content-Length': buf.length,
245
+ 'Cache-Control': 'no-store',
246
+ })
247
+ res.end(buf)
248
+ }
249
+
140
250
  /**
141
251
  * @param {object} opts
142
252
  * @param {import('./store.js').createStore extends (...a:any)=>infer R ? R : never} opts.store
@@ -170,17 +280,73 @@ export const miniControl = {
170
280
  * @param {(id:string)=>{ok:boolean, error?:string, sessionId?:string}} opts.onUnqueue
171
281
  * @param {() => (string|undefined)[]} [opts.imageRoots] 正文里相对路径图的额外候选根
172
282
  * (由插件递进「当前已注册工作区的目录」,见 extraImageRoots 那段)
173
- * @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void}>}
283
+ * @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void, broadcastTrajectory:(payload:object)=>void, broadcastTrajectoryLive:(payload:object)=>void}>}
174
284
  */
175
- export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots }) {
285
+ export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots, mirror, mirrorEnabled }) {
176
286
  /** @type {Set<import('node:http').ServerResponse>} */
177
287
  const clients = new Set()
178
288
 
289
+ /**
290
+ * 这条连接到底是谁——**订阅要按连接记,就得先有身份**。
291
+ *
292
+ * SSE 那边只有一个 `res`,一次普通的 POST 认不出「我是哪条流」。所以由客户端自己
293
+ * 生成一个随机串,两处都带上:连流时 `?clientId=…`,之后每一次开关订阅也带它。
294
+ * 客户端没报的(比如现在这版手机页面)服务端也给它编一个,只为日志里能对上号——
295
+ * 那种连接**订不了轨迹**,也就不会为这份体积付钱。
296
+ */
297
+ const clientsById = new Map()
298
+ /**
299
+ * 想要执行轨迹推送的那几条连接。
300
+ *
301
+ * 为什么必须按连接分流:轨迹的体积是回答正文的 **90.7 倍**(单轮最高约 946KB,
302
+ * 单条输出最大 49,930 字符),而现在的 `state` 是「每条广播都发整份快照」。
303
+ * 把它塞进那条路会把手机压垮,所以它走单独的增量帧,并且**只有声明要「完整」模式的
304
+ * 那几台**才推。声明有两条路:
305
+ * ① 连上来时带 `?full=1`——断线重连之后自动重新对齐,不用先握手一次;
306
+ * ② 连上之后 `POST /mini/api/trajectory/subscribe`——切模式时用,不必重连。
307
+ */
308
+ const trajectorySubscribers = new Set()
309
+ let clientSeq = 0
310
+
311
+ /**
312
+ * 「这一代服务」的编号。进程重启、服务重起都会换一个。
313
+ *
314
+ * 客户端拿它认出「对面换人了」:那种时候帧序号会从头开始,手里的轨迹该整个重拉一次。
315
+ * 光看 seq 是不够的——重启后 seq 也是从 1 开始,看着就像什么都没发生。
316
+ */
317
+ const EPOCH = randomBytes(4).toString('hex')
318
+
179
319
  // 导航栏的数据比普通状态更适合做 stale-while-revalidate:工作区和会话不会因为
180
320
  // 用户打开一次菜单就必须重新清点。第一次没有缓存时仍然等待真实结果;之后先回上次
181
321
  // 整理好的列表,再在后台刷新。手动点刷新时由请求方显式 force,保持原来的语义。
182
322
  const NAV_CACHE_TTL_MS = 30_000
183
323
  let navWorkspaceCache = { value: null, at: 0, inflight: null }
324
+ const SUBAGENT_CACHE_TTL_MS = 5_000
325
+ const subagentCaches = new Map()
326
+ function invalidateNavigation() {
327
+ navWorkspaceCache = { value: null, at: 0, inflight: null }
328
+ }
329
+
330
+ function invalidateSubagents(sessionId) {
331
+ if (sessionId) subagentCaches.delete(sessionId)
332
+ else subagentCaches.clear()
333
+ }
334
+
335
+ async function loadSubagents(sessionId) {
336
+ if (!sessionId) return nav.subagents(sessionId)
337
+ const hit = subagentCaches.get(sessionId)
338
+ if (hit && Date.now() - hit.at < SUBAGENT_CACHE_TTL_MS) return hit.value
339
+ if (hit?.inflight) return hit.inflight
340
+ const inflight = Promise.resolve(nav.subagents(sessionId, { includeMetrics: false, scheduleMetrics: true })).then((value) => {
341
+ subagentCaches.set(sessionId, { value, at: Date.now(), inflight: null })
342
+ return value
343
+ }).finally(() => {
344
+ const current = subagentCaches.get(sessionId)
345
+ if (current?.inflight === inflight) current.inflight = null
346
+ })
347
+ subagentCaches.set(sessionId, { value: hit?.value ?? null, at: hit?.at ?? 0, inflight })
348
+ return hit?.value ?? inflight
349
+ }
184
350
 
185
351
  async function loadWorkspaces(runningIds, force = false) {
186
352
  const now = Date.now()
@@ -280,6 +446,58 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
280
446
  }
281
447
  }
282
448
 
449
+ /**
450
+ * 只发给「要轨迹」的那几台连接。**别人一个字节都不收**。
451
+ *
452
+ * 帧里只有这一批的变化(新条目 + 改动),不是整份:整份单轮最高约 946KB,而
453
+ * 一次工具调用也就是一条几 KB 的条目。`seq` 让客户端认出漏帧,`epoch` 认出重启。
454
+ */
455
+ function broadcastTrajectory(payload) {
456
+ if (!trajectorySubscribers.size) return
457
+ const frame = `event: trajectory\ndata: ${JSON.stringify({ epoch: EPOCH, ...payload })}\n\n`
458
+ for (const res of trajectorySubscribers) {
459
+ try {
460
+ res.write(frame)
461
+ } catch {
462
+ trajectorySubscribers.delete(res)
463
+ clients.delete(res)
464
+ }
465
+ }
466
+ }
467
+
468
+ /**
469
+ * 轨迹的活片段(2026-10-04 用户解禁「流式不做」,只给完整模式看):模型正在想的
470
+ * 那一句,随打字顺出来。和上面那条增量帧走**同一批连接**(就是完整模式那几条),
471
+ * 但**不占它的 seq/epoch**——它是「还没落定」的影子,落定的字由增量帧接管。
472
+ */
473
+ function broadcastTrajectoryLive(payload) {
474
+ if (!trajectorySubscribers.size) return
475
+ const frame = `event: trajectory-live\ndata: ${JSON.stringify(payload)}\n\n`
476
+ for (const res of trajectorySubscribers) {
477
+ try {
478
+ res.write(frame)
479
+ } catch {
480
+ trajectorySubscribers.delete(res)
481
+ clients.delete(res)
482
+ }
483
+ }
484
+ }
485
+
486
+ /**
487
+ * 按身份名开/关轨迹推送。
488
+ * @returns 点到的连接数。**0 = 那条连接已经不在了**,客户端据此重连一次
489
+ * (不然会出现「以为订上了,其实一条都收不到」这种最难查的静默状态)。
490
+ */
491
+ function setTrajectorySubscription(clientId, on) {
492
+ const group = clientsById.get(clientId)
493
+ if (!group) return 0
494
+ for (const res of group) {
495
+ if (on) trajectorySubscribers.add(res)
496
+ else trajectorySubscribers.delete(res)
497
+ }
498
+ return group.size
499
+ }
500
+
283
501
  // ---- 手机上答题 ------------------------------------------------------
284
502
  //
285
503
  // **这两样必须放在这一层**(和 `clients`、`broadcast` 同一层,全程只建一次)。
@@ -590,6 +808,49 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
590
808
  attempts.delete(clientKey(req, tunnelHost?.()))
591
809
  }
592
810
 
811
+ /**
812
+ * 把**我们自己那个** `token` 查询参数摘掉,其余一个字符都不动。
813
+ *
814
+ * 为什么必须摘:那个界面的插件脚本走一条合并加载路径(`plugins/??a/client.js&rev=x`),
815
+ * 上游对它是**精确匹配**——多一个 `&token=...` 就当场 404,脚本一 404,界面就卡在
816
+ * 启动画面报「HTML did not preload @deepseek-ai/dsh-client-modules/client.js」。
817
+ * 这是真机上试出来的,不是推的。
818
+ *
819
+ * **逐段切、再拼回去,不走 URLSearchParams**:那条合并路径里有 `@`、`/`、`,` 这些字符,
820
+ * 交给它解析再序列化会被百分号编码,路径就变了——那等于换个法子把它弄坏。
821
+ * 只在值确实等于我们的令牌时摘,免得误伤界面自己可能用到的同名参数。
822
+ */
823
+ function stripOurToken(rawUrl) {
824
+ const at = rawUrl.indexOf('?')
825
+ if (at < 0) return rawUrl
826
+ const head = rawUrl.slice(0, at)
827
+ const parts = rawUrl.slice(at + 1).split('&')
828
+ const encoded = encodeURIComponent(token)
829
+ const kept = parts.filter((p) => p !== `token=${token}` && p !== `token=${encoded}`)
830
+ if (kept.length === parts.length) return rawUrl
831
+ return kept.length ? `${head}?${kept.join('&')}` : head
832
+ }
833
+
834
+ /**
835
+ * 把**我们自己那个** `diag=1` 摘掉,其余一个字符都不动。
836
+ *
837
+ * 和上面的 token 是同一个道理(真机上试出来的):上游对一部分地址是**精确匹配**,
838
+ * 多带一个参数就当场 404。`diag` 是我们读的开关,上游不认识它,
839
+ * 所以往上游转之前必须摘掉。**入口那一下本来就把查询串整个丢了**,
840
+ * 这里管的是前缀下面的那些地址(`/mini/mirror/assets/x?diag=1` 这种)。
841
+ *
842
+ * 只摘 `diag=1` 这一个形状,别的一律不碰——上游自己要用同名参数也不至于被误伤。
843
+ */
844
+ function stripDiagFlag(rawUrl) {
845
+ const at = rawUrl.indexOf('?')
846
+ if (at < 0) return rawUrl
847
+ const head = rawUrl.slice(0, at)
848
+ const parts = rawUrl.slice(at + 1).split('&')
849
+ const kept = parts.filter((p) => p !== 'diag=1')
850
+ if (kept.length === parts.length) return rawUrl
851
+ return kept.length ? `${head}?${kept.join('&')}` : head
852
+ }
853
+
593
854
  async function handle(req, res) {
594
855
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`)
595
856
  const path = url.pathname
@@ -714,8 +975,150 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
714
975
  return
715
976
  }
716
977
 
978
+ // ---- 「进阶设置」:电脑端界面的镜像 --------------------------------
979
+ //
980
+ // 见 lib/mirror.js 开头的说明。这里只负责三件事:**认路、鉴权、放行**。
981
+ //
982
+ // ## 认路这件事比想的绕,两次才做对
983
+ //
984
+ // 那个界面里的请求有**两种**写法,两种都要能通:
985
+ //
986
+ // ① **相对的**:外壳里写的是 `<base href="./">` + `./assets/index-xxx.js`。
987
+ // 它本来就是按「可以被挂在任意子路径下」设计的。所以入口必须**带尾斜杠**,
988
+ // 并且把 `/mini/mirror/` 这个前缀剥掉再转发——否则 `./` 会解析到 `/mini/`,
989
+ // 那些脚本就跑到`/mini/assets/...` 上、被当成我们自己的地盘,全 404。
990
+ // **第一版就是这么白屏的**(真浏览器打开验出来的:DOM 拿到了、但一片空白)。
991
+ // ② **绝对的**:它调 API 走 `/api/...`、插件资源走 `/plugins/...`。
992
+ // 这些没法靠前缀,所以反过来——**凡是不是我们自己的路径,一律转给它**。
993
+ // 我们自己的地盘只有 `/`、`/mini`、`/mini/*`。
994
+ //
995
+ // **鉴权不能省**:这条路由等于把整个电脑端设置面(模型配置、凭据页、
996
+ // 插件装卸、会话记录……)交给持有我们令牌的人。所以要求和 `/mini/api/*` 同一道门。
997
+ // 手机那边不必每次都带参数——入口那一下写好了 cookie,而 cookie 的 Path 是 `/`,
998
+ // 界面自己发的那些请求会自然带上。
999
+ //
1000
+ // ## 还有一条:**我们自己的 `token` 参数不许跟着转发上去**
1001
+ //
1002
+ // 这是真机上试出来的:那个界面的插件脚本走一条合并加载路径
1003
+ // (`plugins/??@a/client.js&rev=xxx`),**上游对它是精确匹配**——多带一个
1004
+ // `&token=...` 就当场 404。而脚本一 404,界面就卡在启动画面报
1005
+ // 「HTML did not preload @deepseek-ai/dsh-client-modules/client.js」。
1006
+ // 所以转发前先把自己那个参数摘掉(**只在它确实是我们的令牌时摘**,
1007
+ // 免得误伤界面自己可能用到的同名参数)。
1008
+ if (mirror && typeof mirrorEnabled === 'function' && mirrorEnabled()) {
1009
+ const MIRROR_PREFIX = '/mini/mirror'
1010
+ /**
1011
+ * **诊断模式开关**(`?diag=1`)。
1012
+ *
1013
+ * 之后注入的那段采样脚本只看这一个参数(见 lib/mirror.js 的 DIAG_SCRIPT),
1014
+ * **不带它时行为完全不变**——这一条有测试钉着。
1015
+ *
1016
+ * 必须**在这里、在改写 `req.url` 之前**读出来:入口那一下会把查询串整个丢掉
1017
+ * (上游那条精确匹配的路由不认我们这些参数),丢掉之后就没得读了。
1018
+ */
1019
+ const diag = url.searchParams.get('diag') === '1'
1020
+ const mirrorEntry = req.method === 'GET'
1021
+ && (path === MIRROR_PREFIX || path === `${MIRROR_PREFIX}/`)
1022
+ const mirrorUnder = path.startsWith(`${MIRROR_PREFIX}/`)
1023
+ const ours = path === '/' || path === '/mini' || path === '/mini/'
1024
+ || path === '/mini/sw.js' || path.startsWith('/mini/art/')
1025
+ // 三种都要放行:入口、挂在 `/mini/mirror/` 下面的相对路径、以及界面自己发的
1026
+ // 绝对路径(`/api/...`、`/plugins/...`)。**第二种漏掉过一次**——剥前缀那段
1027
+ // 就成了死代码,测试当场抓住。
1028
+ //
1029
+ // **第二种曾经还写错过一次**(2026-10-06 真机):当时限制成「只收 GET」,
1030
+ // 而界面外壳写着 `<base href="./">`,**它所有请求都带这个前缀**——于是那些 POST
1031
+ // (`/mini/mirror/api/session/list`、`settings/describe`、`agentPresets/list` …)
1032
+ // 全掉进我们自己的 404,界面上就是一片「加载不出来」。
1033
+ // 前缀下面的路径**本来就只可能是界面的**(我们自己的接口都在 `/mini/api/`),
1034
+ // 所以这里不该按方法设限。
1035
+ // **我们自己的客户端插件在镜像里也在跑**,它按同源取 `/mini-remote/*`
1036
+ // ——那几条是**宿主那边的**路由(`lib/index.js` 注册的),不是我们的。
1037
+ //
1038
+ // 可这些路径以 `/mini` 开头,会被下面那条「凡不是我们自己的地盘就转上游」判成
1039
+ // **我们的地盘**,于是回我们自己的 404 JSON:镜像里的「手机遥控」那一节就永远读不到
1040
+ // 配对/隧道/飞书的状态。2026-10-08 在活服务上量到:同一条路径在宿主那边是 200
1041
+ // (7360 字节 JSON),在我们这边是 `{"error":"not found"}`。
1042
+ //
1043
+ // 所以把它一并转上游。**它挂在开关里面**——开关关着时一个都不接,和别的路径同一条口径。
1044
+ const miniRemote = path === '/mini-remote' || path.startsWith('/mini-remote/')
1045
+ const toUpstream = mirrorEntry || mirrorUnder || miniRemote
1046
+ || (!ours && !path.startsWith('/mini'))
1047
+ if (toUpstream) {
1048
+ if (!isAuthorized(req, url)) {
1049
+ if (hasCredential(req, url)) noteFailure(req)
1050
+ sendJson(res, 401, { error: '未授权:token 不正确' })
1051
+ return
1052
+ }
1053
+ noteSuccess(req)
1054
+
1055
+ if (mirrorEntry) {
1056
+ // **入口这一下要把 cookie 写上**,和首页那一条同款。
1057
+ //
1058
+ // 为什么非要这样:那个界面进去之后,它自己会发一堆 `/api/...`、
1059
+ // `/plugins/...` 的请求——**那些请求不会带 `?token=`**,只能靠 cookie 认。
1060
+ // 入口这一刻是唯一一次「用户带着 token 主动进来」的机会,错过它,
1061
+ // 界面能打开但里面每一格都是 401。
1062
+ const provided = url.searchParams.get('token')
1063
+ if (provided && tokenEquals(provided, token)) {
1064
+ res.setHeader('Set-Cookie',
1065
+ `${COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=31536000; SameSite=Lax`)
1066
+ }
1067
+ if (path === MIRROR_PREFIX) {
1068
+ // 补上尾斜杠。**这一步不是洁癖**:外壳写着 `<base href="./">`,
1069
+ // 在 `/mini/mirror` 上它解析成 `/mini/`,在 `/mini/mirror/` 上才是
1070
+ // `/mini/mirror/`——差这一个字符,里面的脚本会全 404、界面白屏。
1071
+ // 补完就带上 cookie 了,所以这里不再带查询串——
1072
+ // **只有诊断开关要带过去**:用户拿到的那条链接可能没写尾斜杠,
1073
+ // 跳一下就把「诊断模式」丢了的话,真机上跑一次就白跑了。
1074
+ res.writeHead(302, {
1075
+ location: diag ? `${MIRROR_PREFIX}/?diag=1` : `${MIRROR_PREFIX}/`,
1076
+ 'cache-control': 'no-store',
1077
+ })
1078
+ res.end()
1079
+ return
1080
+ }
1081
+ // 带斜杠的入口:就是上游的首页,**查询串整个丢掉**(里面只有我们的令牌,
1082
+ // 上游不认识它,留着反而会被它的精确路由判成不匹配)。
1083
+ const saved = req.url
1084
+ req.url = '/'
1085
+ try {
1086
+ await mirror.handle(req, res, { diag })
1087
+ } finally {
1088
+ req.url = saved
1089
+ }
1090
+ return
1091
+ }
1092
+
1093
+ // 剥掉挂载前缀:`/mini/mirror/assets/x` → 上游的 `/assets/x`。
1094
+ // **只有这一种要改路径。** 绝对路径(`/api/...`、`/plugins/...`)原样转发——
1095
+ // 第一版这里写成了「不是前缀就映射到 `/`」,等于把每个 API 调用都打到首页上,
1096
+ // 是测试当场抓住的。
1097
+ const saved = req.url
1098
+ req.url = stripDiagFlag(stripOurToken(mirrorUnder ? req.url.slice(MIRROR_PREFIX.length) : req.url))
1099
+ try {
1100
+ await mirror.handle(req, res)
1101
+ } finally {
1102
+ req.url = saved
1103
+ }
1104
+ return
1105
+ }
1106
+ }
1107
+
717
1108
  // ---- 其余一律要鉴权 -------------------------------------------------
718
1109
  if (!path.startsWith('/mini/api/')) {
1110
+ // **镜像那几条路径走不通时,不许回那个给程序看的 JSON。**
1111
+ //
1112
+ // 上面那一整块镜像路由挂在开关上(`mirrorEnabled()` 同时看「开关」和「上游地址
1113
+ // 解没解析出来」),所以「开关关着」和「电脑端还没就绪」这两种时候,`/mini/mirror/…`
1114
+ // 会掉到这里。原来回的是 `{"error":"not found"}`——而手机那边是把镜像**嵌在 iframe 里**的,
1115
+ // 这个 JSON 就成了框里的**文档**,被浏览器当页面渲染(2026-10-08 用户真机截图:
1116
+ // 框里一条 `{"error":"not found"}`、外面那条「电脑端界面 / 返回」还好好地在)。
1117
+ // 这里改回一页人话;**状态码仍然是 404**(此刻这条路确实不存在),换的只是正文。
1118
+ if (path === '/mini/mirror' || path.startsWith('/mini/mirror/')) {
1119
+ sendMirrorUnavailable(res)
1120
+ return
1121
+ }
719
1122
  sendJson(res, 404, { error: 'not found' })
720
1123
  return
721
1124
  }
@@ -737,6 +1140,15 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
737
1140
  })
738
1141
  res.write(': connected\n\n')
739
1142
  clients.add(res)
1143
+ // 这条连接的身份 + 它要不要轨迹。身份是客户端自己报的随机串(见 clientsById),
1144
+ // 没报就编一个:编出来的那种订不了轨迹,也就不用替这份体积付钱。
1145
+ const askedClientId = String(url.searchParams.get('clientId') ?? '').slice(0, 64)
1146
+ const clientId = askedClientId || `anon${++clientSeq}`
1147
+ let sameId = clientsById.get(clientId)
1148
+ if (!sameId) clientsById.set(clientId, (sameId = new Set()))
1149
+ sameId.add(res)
1150
+ // `?full=1` = 「我这一端是完整模式」:连上就订上,重连之后自动重新对齐。
1151
+ if (url.searchParams.get('full') === '1') trajectorySubscribers.add(res)
740
1152
  // 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
741
1153
  // 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
742
1154
  // 还要看**是谁**连的、**活了多久**:2026-09-27 日志里一分钟几十次成对出现,
@@ -773,6 +1185,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
773
1185
  said = true
774
1186
  clearInterval(beat)
775
1187
  clients.delete(res)
1188
+ trajectorySubscribers.delete(res)
1189
+ const group = clientsById.get(clientId)
1190
+ if (group) {
1191
+ group.delete(res)
1192
+ if (!group.size) clientsById.delete(clientId)
1193
+ }
776
1194
  console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`
777
1195
  + `(${who},活了 ${Date.now() - openedAt} ms)`)
778
1196
  }
@@ -789,6 +1207,58 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
789
1207
  return
790
1208
  }
791
1209
 
1210
+ /**
1211
+ * 执行轨迹:手机「完整」模式要的那一组一组过程。
1212
+ *
1213
+ * **它不在 `state` 快照里**,这是这次改动最要紧的一条:实测轨迹正文是回答正文的
1214
+ * 90.7 倍(单轮最高约 946KB),而快照是**每条广播都发整份**的——塞进去等于把
1215
+ * 每一次工具调用都变成一次几百 KB 的推送(公网隧道下尤其不可接受)。
1216
+ * 所以全量快照照旧,轨迹走增量帧(`event: trajectory`),这份接口只负责
1217
+ * 「重连 / 切到完整模式 / 换个会话时,一次把当前这一段补齐」。
1218
+ *
1219
+ * `turn=N` 只给那一轮:手机切回来时想对齐的是**当前正在跑的那一轮**,不必把
1220
+ * 前面一百多条条目再拖一遍。
1221
+ *
1222
+ * 和 /state 同一条口径:没读过这个会话的记录就先读一次(`onSessionOpened`),
1223
+ * 读完会广播 state;手机据此再拉一次,那时 `loading` 就不是 true 了。
1224
+ */
1225
+ if (req.method === 'GET' && path === '/mini/api/trajectory') {
1226
+ const asked = url.searchParams.get('sessionId')
1227
+ const sessionId = asked || store.targetSessionId()
1228
+ if (!sessionId) {
1229
+ sendJson(res, 200, {
1230
+ ok: true, sessionId: null, epoch: EPOCH, seq: 0, turn: null,
1231
+ turns: [], truncated: false, note: null, loading: false,
1232
+ })
1233
+ return
1234
+ }
1235
+ let onlyTurn = null
1236
+ if (url.searchParams.has('turn')) {
1237
+ onlyTurn = Number(url.searchParams.get('turn'))
1238
+ if (!Number.isFinite(onlyTurn)) {
1239
+ sendJson(res, 400, { ok: false, error: 'turn 要是一个数字。' })
1240
+ return
1241
+ }
1242
+ }
1243
+ onSessionOpened?.(sessionId)
1244
+ const view = store.trajectoryOf(sessionId)
1245
+ const turns = onlyTurn === null
1246
+ ? view.turns
1247
+ : view.turns.filter((g) => g.turn === onlyTurn)
1248
+ sendJson(res, 200, {
1249
+ ok: true,
1250
+ sessionId,
1251
+ epoch: EPOCH,
1252
+ seq: view.seq,
1253
+ turn: onlyTurn,
1254
+ turns,
1255
+ truncated: view.truncated,
1256
+ note: view.note ?? null,
1257
+ loading: view.loading === true,
1258
+ })
1259
+ return
1260
+ }
1261
+
792
1262
  // 构建指纹。重启期间旧进程还在应答,这个接口用来确认「跑的是不是新代码」。
793
1263
  //
794
1264
  // `canCreateSession` 是**运行时**事实,不是配置推断:它问的是「这一刻 DSH 真的
@@ -803,7 +1273,38 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
803
1273
  // 探测本身不该把接口带崩:问不到就当没有。
804
1274
  canCreateSession = false
805
1275
  }
806
- sendJson(res, 200, { ok: true, build: build ?? 'unknown', canCreateSession })
1276
+ // 版本号问**正在跑的这只 Host**(见 index.js 的 dshVersion)。
1277
+ // 早先这里是读磁盘上的 package.json,桌面端读不到,一直报 unknown。
1278
+ const version = (await nav.dshVersion?.()) || 'unknown'
1279
+ sendJson(res, 200, { ok: true, build: build ?? 'unknown', dshVersion: version, canCreateSession })
1280
+ return
1281
+ }
1282
+
1283
+ // 插件列表:手机端「插件」页面用。分三块——
1284
+ // bundles :npm 包(官方可装的 + 用户装的第三方)
1285
+ // cards :官方那几张**设置卡片**(终端 / Agent 循环 / 子智能体 / 网页搜索)。
1286
+ // 它们不是包,是注册了设置页的官方插件,宿主这边只能照 DSH 客户端
1287
+ // 源码列一张表 + 按「宿主 served 了那个命名空间没」筛选。
1288
+ // entries/presets:**内置插件**(Cordis Loader 的实时条目表),也就是
1289
+ // 「全局插件」和「会话插件」两块。2026-10-04 用户指出:内置插件
1290
+ // 不能拿 npm 包充数(那只有几个),DSH 是「一切皆插件,无特权核心」。
1291
+ // 三块各带各的错:一块读不到不该把另两块也弄没。
1292
+ if (req.method === 'GET' && path === '/mini/api/plugins') {
1293
+ const [list, inv, official] = await Promise.all([
1294
+ nav.pluginList?.() ?? { ok: false, reason: 'unavailable' },
1295
+ nav.pluginInventory?.() ?? { ok: false, reason: 'unavailable' },
1296
+ nav.officialPlugins?.() ?? { ok: false, reason: 'unavailable' },
1297
+ ])
1298
+ sendJson(res, 200, {
1299
+ ok: list.ok !== false || inv.ok !== false,
1300
+ bundles: list.bundles ?? [],
1301
+ bundlesError: list.ok === false ? String(list.reason || '读不到') : '',
1302
+ official: official.cards ?? [],
1303
+ officialError: official.ok === false ? String(official.reason || '读不到') : '',
1304
+ entries: inv.entries ?? [],
1305
+ presets: inv.presets ?? [],
1306
+ builtinError: inv.ok === false ? String(inv.reason || '读不到') : '',
1307
+ })
807
1308
  return
808
1309
  }
809
1310
 
@@ -824,7 +1325,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
824
1325
  // 失败一律 200 + ok:false:这是"这道能力现在没有"或"这台电脑没开这条路",
825
1326
  // 不是请求本身有错——用 4xx 会让页面上的重试逻辑把它当网络故障。
826
1327
  if (req.method === 'GET' && path === '/mini/api/subagents') {
827
- const result = await nav.subagents(store.snapshot().boundSessionId)
1328
+ const result = await loadSubagents(store.snapshot().boundSessionId)
828
1329
  sendJson(res, 200, result)
829
1330
  return
830
1331
  }
@@ -837,6 +1338,67 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
837
1338
  return
838
1339
  }
839
1340
 
1341
+ // ---- 繁忙时的发送行为 ------------------------------------------------
1342
+ // 智能体跑着的时候,你发的那句话是排队等这一轮跑完(queue),还是当场插进去
1343
+ // 让它调整方向(steer)。**PC 上的「设置 → 通用 → 繁忙时的发送行为」就是它**,
1344
+ // 两端共用宿主那一份用户设置文档(用户 2026-10-05 拍板)。
1345
+ //
1346
+ // 读不到就如实说 unavailable,手机那边整条不显示——**不摆一个假的开关**。
1347
+ if (req.method === 'GET' && path === '/mini/api/busy-enter') {
1348
+ const result = await nav.busyEnterSetting()
1349
+ sendJson(res, result.ok ? 200 : 503, result)
1350
+ return
1351
+ }
1352
+
1353
+ if (req.method === 'POST' && path === '/mini/api/busy-enter') {
1354
+ const body = await readBody(req)
1355
+ const result = await nav.setBusyEnter(body?.value)
1356
+ sendJson(res, result.ok ? 200 : 400, result)
1357
+ return
1358
+ }
1359
+
1360
+ // ---- 镜像页「诊断模式」的上报口 ---------------------------------------
1361
+ //
1362
+ // 只有 `?diag=1` 打开的那一页会往这里发东西(见 lib/mirror.js 的 DIAG_SCRIPT)。
1363
+ // **样本追加写到 scratch/diag.log**,一行一条、带时间戳——用户不用看,
1364
+ // 开发者读它就能把「首帧没字」的根因定死(点击前/点击后两份样本一比,
1365
+ // 到底哪个属性变了就露出来了)。
1366
+ //
1367
+ // 它不是「诊断模式」本身,只是一个**只写不读**的收件口,所以:
1368
+ // · 走和其他 `/mini/api/*` 同一道门(没令牌进不来);
1369
+ // · 不跟着镜像开关关——开关关着时根本没有哪一页会发东西过来,跟着关没意义;
1370
+ // · 写失败就**如实回错误**(前端那条小条会把原话显示出来),
1371
+ // 免得「真机跑一次、日志里什么都没有」这种事还要再猜一轮。
1372
+ if (req.method === 'POST' && path === '/mini/api/mirror-diag') {
1373
+ try {
1374
+ const payload = await readBody(req)
1375
+ const file = diagLogPath()
1376
+ await mkdir(dirname(file), { recursive: true })
1377
+ await appendFile(file, `\n=== ${new Date().toISOString()} ===\n${JSON.stringify(payload, null, 2)}\n`, 'utf8')
1378
+ sendJson(res, 200, { ok: true })
1379
+ } catch (err) {
1380
+ sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
1381
+ }
1382
+ return
1383
+ }
1384
+
1385
+ // ---- 「进阶设置(电脑端界面)」的开关 ---------------------------------
1386
+ // 只是那个开关的状态与读写;**真正的代理在 handle() 上面那一段**。
1387
+ // 两件事分开是有意的:状态随时可查(界面进来就要知道该画成什么样),
1388
+ // 而那条代理路由**关着时根本不存在**。
1389
+ if (req.method === 'GET' && path === '/mini/api/mirror') {
1390
+ const result = await nav.mirrorSetting()
1391
+ sendJson(res, result.ok ? 200 : 503, result)
1392
+ return
1393
+ }
1394
+
1395
+ if (req.method === 'POST' && path === '/mini/api/mirror') {
1396
+ const body = await readBody(req)
1397
+ const result = await nav.setMirror(body?.enabled)
1398
+ sendJson(res, result.ok ? 200 : 400, result)
1399
+ return
1400
+ }
1401
+
840
1402
  // ---- 斜杠指令列表 ----------------------------------------------------
841
1403
  // 手机在输入框里打 `/` 时要显示的那一份。**名单来自 DSH 自己的指令账本**,
842
1404
  // 所以电脑上新装一个带指令的插件,手机上刷新就有了,不用改这个插件。
@@ -869,6 +1431,13 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
869
1431
  return
870
1432
  }
871
1433
 
1434
+ // Agent 模式清单(2026-10-04):手机新建会话时挑模式用。拿不到能力时如实回
1435
+ // unavailable——手机那边会直接建默认模式,**不摆没得选的选择题**。
1436
+ if (path === '/mini/api/agent-presets' && req.method === 'GET') {
1437
+ sendJson(res, 200, await nav.agentPresets?.() ?? { ok: false, reason: 'unavailable' })
1438
+ return
1439
+ }
1440
+
872
1441
  const wsSessions = /^\/mini\/api\/workspaces\/([^/]+)\/sessions$/.exec(path)
873
1442
  if (req.method === 'GET' && wsSessions) {
874
1443
  const asked = Number(url.searchParams.get('limit'))
@@ -1194,6 +1763,26 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1194
1763
  return
1195
1764
  }
1196
1765
 
1766
+ if (path === '/mini/api/trajectory/subscribe') {
1767
+ // 开/关轨迹推送(见 trajectorySubscribers)。**只点到 clientId 名下那条连接**,
1768
+ // 别的连接一个字节都不受影响——单帧/聊天模式的手机不该为这份体积付钱。
1769
+ const clientId = typeof body.clientId === 'string' ? body.clientId.trim() : ''
1770
+ if (!clientId) {
1771
+ sendJson(res, 400, { ok: false, error: 'clientId 不能空——订阅是按连接记的。' })
1772
+ return
1773
+ }
1774
+ const on = body.on !== false
1775
+ const hit = setTrajectorySubscription(clientId, on)
1776
+ if (!hit) {
1777
+ // 认不出这条连接:多半是那条流已经断了(或者连流时没报 clientId)。
1778
+ // **如实说没订上**,别让手机以为订上了却一条都收不到——那是最难查的一种静默失败。
1779
+ sendJson(res, 404, { ok: false, error: '这条连接已经不在了,重新连一次流。' })
1780
+ return
1781
+ }
1782
+ sendJson(res, 200, { ok: true, clientId, subscribed: on, connections: hit, epoch: EPOCH })
1783
+ return
1784
+ }
1785
+
1197
1786
  // 在一个已有的工作区里新建会话,建完立刻把手机绑过去。
1198
1787
  //
1199
1788
  // 为什么排在这么靠后、而不是跟上面那条 GET 挨着:这两条都要读请求体,
@@ -1203,7 +1792,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1203
1792
  // 建完顺手 bind + 广播:手机发一次请求就完成了「建 + 切过去」,
1204
1793
  // 不用再补一次 bind,也就不会出现「建好了但没切过去」这种半截状态。
1205
1794
  if (req.method === 'POST' && wsSessions) {
1206
- const created = await nav.createSession(decodeURIComponent(wsSessions[1]))
1795
+ // agentPreset(2026-10-04,手机上也能选模式):选了才带,没选让 DSH 用默认。
1796
+ const preset = typeof body.agentPreset === 'string' && body.agentPreset.trim()
1797
+ ? body.agentPreset.trim()
1798
+ : undefined
1799
+ const created = await nav.createSession(decodeURIComponent(wsSessions[1]), preset)
1207
1800
  if (!created.ok) {
1208
1801
  const known = {
1209
1802
  'no-controller': [503, '这台电脑上的 DSH 没提供新建会话的能力。'],
@@ -1216,7 +1809,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1216
1809
  }
1217
1810
  store.bind(created.sessionId)
1218
1811
  // 新会话会改变工作区的数量;下次打开导航栏必须重新整理一次。
1219
- navWorkspaceCache = { value: null, at: 0, inflight: null }
1812
+ invalidateNavigation()
1220
1813
  const snap = store.snapshot()
1221
1814
  broadcast('state', snap)
1222
1815
  sendJson(res, 200, {
@@ -1362,6 +1955,41 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1362
1955
  let port = startPort
1363
1956
  for (const address of wanted) {
1364
1957
  const server = createServer(onRequest)
1958
+ // WebSocket 不走 onRequest——它是一条 `Upgrade` 握手,Node 单独发这个事件。
1959
+ //
1960
+ // **手机和宿主之间那条常驻连接就是 WebSocket**(`/api/remote.mux`)。少了这一段,
1961
+ // 镜像里的界面会「一直正在重新连接」,插件那一列只剩图标没有文字、权限那栏写
1962
+ // 「permission catalog has no active Host connection」——**看起来像一堆显示毛病,
1963
+ // 其实全是同一条连接没通**(2026-10-06 真机报上来的)。
1964
+ //
1965
+ // 路由口径和 HTTP 那边保持一致:**我们自己的地盘(`/mini/...`)不转**,
1966
+ // 开关关着就一个都不接(不给一个绕过开关的通道)。
1967
+ // 路由口径和 HTTP 那边一致,但有一处不能照抄:官方那条连接是按页面的 base 解析出来的,
1968
+ // 而 base 是 `/mini/mirror/`(外壳写着 base href="./"),所以它实际请求的是
1969
+ // `/mini/mirror/api/remote.mux`——挂在镜像前缀**下面**。照 HTTP 那样「凡是 /mini/ 都拒掉」
1970
+ // 会把它一起拒掉,界面就永远在重连。(第一版就是这么写的,靠测试改成真实路径形态才发现。)
1971
+ const MIRROR_PREFIX = '/mini/mirror'
1972
+ server.on('upgrade', (req, socket, head) => {
1973
+ if (!mirror || typeof mirror.upgrade !== 'function'
1974
+ || typeof mirrorEnabled !== 'function' || !mirrorEnabled()) {
1975
+ socket.destroy()
1976
+ return
1977
+ }
1978
+ const upPath = String(req.url ?? '/').split('?')[0]
1979
+ const under = upPath === MIRROR_PREFIX || upPath.startsWith(`${MIRROR_PREFIX}/`)
1980
+ const ours = !under
1981
+ && (upPath === '/mini' || upPath === '/mini/' || upPath.startsWith('/mini/'))
1982
+ if (ours) {
1983
+ socket.destroy()
1984
+ return
1985
+ }
1986
+ // 挂在挂载点下面的剥掉前缀,绝对路径(`/api/...`)原样转——和 HTTP 那边一致。
1987
+ // `diag=1` 同样要摘掉(和 HTTP 那边同一个道理:上游不认它)。
1988
+ req.url = stripDiagFlag(under
1989
+ ? stripOurToken(req.url.slice(MIRROR_PREFIX.length) || '/')
1990
+ : stripOurToken(req.url))
1991
+ mirror.upgrade(req, socket, head)
1992
+ })
1365
1993
  try {
1366
1994
  await new Promise((resolve, reject) => {
1367
1995
  server.once('error', reject)
@@ -1412,6 +2040,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1412
2040
  /** 想绑但失败的地址,交给调用方决定怎么提示。 */
1413
2041
  failed,
1414
2042
  broadcast,
2043
+ /** 执行轨迹的增量帧:只发给声明了「完整」模式的那几条连接(见 broadcastTrajectory)。 */
2044
+ broadcastTrajectory,
2045
+ /** 轨迹的活片段帧:同一批连接,不占 seq/epoch(见 broadcastTrajectoryLive)。 */
2046
+ broadcastTrajectoryLive,
2047
+ invalidateNavigation,
2048
+ invalidateSubagents,
1415
2049
  close: async () => {
1416
2050
  for (const res of clients) {
1417
2051
  try { res.end() } catch { /* 已经断了 */ }