dsh-mini-remote 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1206 @@
1
+ /**
2
+ * DSH 极简移动端遥控器 —— 插件端入口。
3
+ *
4
+ * 设计上的三个「不」:
5
+ * - 不代理 DSH 桌面界面(那是 dsh-pocket 的做法),手机只拿两样东西:
6
+ * 用户发出的指令 + Agent 的最终回复。
7
+ * - 不监听 waterfall 事件。只订阅纯 emit 事件,避免误吞 agent 的默认行为。
8
+ * - 不 import 任何 @deepseek-ai/* 的运行时值。DSH 事件契约已经用 .d.ts 核实,
9
+ * 消息对象自己造(MessageId 的 brand 在运行时是恒等函数、不做校验),
10
+ * 这样插件加载期零外部依赖,也不会有 cordis 双副本的坑。
11
+ *
12
+ * 事件契约(DSH 0.1.5-rc.2 实测):
13
+ * session/event(session, event) event = { type, seq, time, data }
14
+ * ├─ turn/start data { turn }
15
+ * ├─ user/message data UserMessage(source.kind === 'user' 才是人发的)
16
+ * ├─ assistant/message data { turn, step, message, stream, usage? }
17
+ * └─ turn/end data { turn, reason: { kind } }
18
+ * agent/status({ agent, status }) status ∈ 'idle' | 'running'
19
+ */
20
+ import { join } from 'node:path'
21
+ import { homedir } from 'node:os'
22
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { randomBytes, randomUUID } from 'node:crypto'
24
+
25
+ import { createStore } from './store.js'
26
+ import { createTurnTracker, visibleText } from './events.js'
27
+ import { createMiniServer, sendJson, readBody } from './server.js'
28
+ import { sendExternalNotification } from './notify.js'
29
+ import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
30
+ import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
31
+ import { ensureCloudflared } from './cloudflared.js'
32
+ import { startTunnel, TUNNEL_HINT } from './tunnel.js'
33
+ import { listWorkspaces, listSessionsOf } from './tree.js'
34
+ import { buildId } from './build.js'
35
+
36
+ export const name = 'dsh-mini-remote'
37
+ export const inject = ['agents']
38
+
39
+ /** 公网隧道起不来时重试几次——向 Cloudflare 注册会偶发超时,实测大约一半概率。 */
40
+ const TUNNEL_ATTEMPTS = 3
41
+
42
+ /**
43
+ * 手机上最多同时挂着几个「传了但还没发出去」的文件。
44
+ *
45
+ * 传完到按下发送之间,那张小票得留在内存里。要是用户传了却不发,这些引用就会一直
46
+ * 挂着。20 是个宽松的上限——同时挂二十个待发文件不现实,而超了就淘汰最早的。
47
+ */
48
+ const MAX_PENDING_UPLOADS = 20
49
+
50
+ /**
51
+ * 这份代码的构建指纹,加载时算一次。
52
+ * 用来回答「现在应答我的进程,跑的是不是我改的这份代码」——重启期间旧进程还在
53
+ * 应答,「以为在验新代码其实在验旧的」已经踩过两次。
54
+ */
55
+ const BUILD = buildId()
56
+
57
+ const DEFAULTS = {
58
+ port: 3090,
59
+ /**
60
+ * 'auto' = 自动挑出内网和 Tailscale 的地址分别监听,本机也留着(配对页要
61
+ * 在电脑上打得开)。这样手机在家连 Wi-Fi、出门连 Tailscale 都能用,而 WSL
62
+ * 之类的虚拟网卡不开门。也可以填 '127.0.0.1'(只有本机)或具体某个 IP。
63
+ */
64
+ bindAddress: 'auto',
65
+ /**
66
+ * 公网访问:用 cloudflared 快速隧道,让没装 Tailscale 的用户也能从外面连进来。
67
+ *
68
+ * 默认关。开了等于把这个服务挂到公网上——虽然有密码和限速挡着,但那是用户
69
+ * 自己的安全边界,该由他自己决定,不能替他默认打开。
70
+ *
71
+ * 和 Tailscale 的关系是「二选一或都要」:内网那条路一直都在,Tailscale 和
72
+ * 隧道各自解决「出门怎么连」,可以只开一个,也可以都开。
73
+ */
74
+ tunnel: {
75
+ enabled: false,
76
+ /** 留空 = 自动找(系统 PATH → dsh-pocket 下过的那份 → 自己下载) */
77
+ cloudflaredPath: '',
78
+ },
79
+ token: '', // 留空 = 首次启动自动生成 32 位 hex 并落盘
80
+ maxHistory: 50,
81
+ defaultMode: 'minimal',
82
+ notify: {
83
+ enabled: false,
84
+ channel: 'none', // none | bark | ntfy | telegram
85
+ barkUrl: '',
86
+ ntfyUrl: 'https://ntfy.sh',
87
+ ntfyTopic: '',
88
+ telegramBotToken: '',
89
+ telegramChatId: '',
90
+ onlyWhenBackground: true,
91
+ maxLength: 200,
92
+ sound: 'default',
93
+ },
94
+ }
95
+
96
+ function deepMerge(base, ...overrides) {
97
+ const out = { ...base }
98
+ for (const patch of overrides) {
99
+ if (!patch || typeof patch !== 'object') continue
100
+ for (const [key, value] of Object.entries(patch)) {
101
+ if (value === undefined) continue
102
+ out[key] = value && typeof value === 'object' && !Array.isArray(value)
103
+ ? deepMerge(out[key] ?? {}, value)
104
+ : value
105
+ }
106
+ }
107
+ return out
108
+ }
109
+
110
+ function dataDir() {
111
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
112
+ return join(home, 'dsh-mini-remote')
113
+ }
114
+
115
+ function readJson(file) {
116
+ try {
117
+ return JSON.parse(readFileSync(file, 'utf8'))
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /** 首次启动生成一个 32 位 hex 的 token 并落盘,之后一直复用。 */
124
+ function loadOrCreateToken(dir, configured) {
125
+ if (configured) return configured
126
+ const file = join(dir, 'token')
127
+ const existing = (() => {
128
+ try { return readFileSync(file, 'utf8').trim() } catch { return '' }
129
+ })()
130
+ if (existing) return existing
131
+ const token = randomBytes(16).toString('hex')
132
+ writeFileSync(file, token, { mode: 0o600 })
133
+ return token
134
+ }
135
+
136
+ function deepFreeze(value) {
137
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
138
+ Object.freeze(value)
139
+ for (const child of Object.values(value)) deepFreeze(child)
140
+ }
141
+ return value
142
+ }
143
+
144
+ /**
145
+ * 造一条用户消息。等价于 @deepseek-ai/dsh-llm 的 createUserMessage:
146
+ * createMessage = deepFreeze(structuredClone({ ...input, id: brandString(randomUUID()) }))
147
+ * 而 MessageId 的 brand 在运行时不做任何校验(见 dsh-llm/lib/types/brand.d.ts)。
148
+ *
149
+ * `files` 是手机传上来的附件引用(`FileAttachmentRef`,原样来自 DSH 的附件服务,
150
+ * 绝不自己构造——那个结构里有文件名消毒和引用校验,手搓会在校验上失败)。
151
+ * 消息的 content 是块数组,附件就是其中一种块(`{type:'file', attachment}`);
152
+ * DSH 在发请求前会自动把它换成一段带路径的提示文字,模型靠自己的文件工具去读。
153
+ */
154
+ function userMessage(text, files = []) {
155
+ return deepFreeze({
156
+ id: randomUUID(),
157
+ role: 'user',
158
+ content: [
159
+ ...(text ? [{ type: 'text', text }] : []),
160
+ ...files.map((attachment) => ({ type: 'file', attachment })),
161
+ ],
162
+ source: { kind: 'user' },
163
+ })
164
+ }
165
+
166
+ export function apply(ctx, config = {}) {
167
+ const dir = dataDir()
168
+ mkdirSync(dir, { recursive: true })
169
+
170
+ const settingsFile = join(dir, 'settings.json')
171
+ const stored = readJson(settingsFile)
172
+ const settings = deepMerge(DEFAULTS, stored ?? {}, config)
173
+ // 首次启动把默认配置落一份出来,用户才有东西可以改。
174
+ if (stored === null) {
175
+ writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`)
176
+ }
177
+ const token = loadOrCreateToken(dir, settings.token)
178
+ const store = createStore({ file: join(dir, 'state.json'), maxHistory: settings.maxHistory })
179
+ const tracker = createTurnTracker()
180
+ // 队列的真相在 agent 的收件箱里,store 拿不到 ctx,所以给它一个回调去问。
181
+ // 不缓存、每次取快照现读:没有第二份状态,也就不会和电脑上不一致。
182
+ store.setQueueSource(queuedOf)
183
+
184
+ /**
185
+ * 输出一律走 console。
186
+ *
187
+ * 原因一:真机上 cordis logger 的 info 不会出现在 stdout(只进它自己的 buffer /
188
+ * 宿主侧通道),而「手机打开下面这条网址」这种话必须让用户当场看见。dsh-pocket、
189
+ * dsh-cost-meter 也都是直接 console 打。
190
+ *
191
+ * 原因二(坑,别再踩):真机上 `(ctx.logger?.warn ?? console.warn)(m)` 这种
192
+ * 「先把方法取出来再裸调」的写法会抛 "this is not a function"——cordis 的 logger
193
+ * 是 createCallable 造的可调用对象,warn 内部要 `this()` 把自己再当函数调一次,
194
+ * this 一丢就崩。真要用 ctx.logger,必须保住接收者:`const l = ctx.logger;
195
+ * l.warn(m)`。
196
+ */
197
+ const log = {
198
+ info: (m) => console.log(`[dsh-mini-remote] ${m}`),
199
+ warn: (m) => console.warn(`[dsh-mini-remote] ${m}`),
200
+ }
201
+
202
+ /** @type {Awaited<ReturnType<typeof createMiniServer>> | null} */
203
+ let server = null
204
+
205
+ /**
206
+ * 服务起不来时的原因;null = 没出错。
207
+ *
208
+ * 有这个字段是为了把「还在启动」和「起不来了」分开说。原来两种情况都回一句
209
+ * 「手机服务还在启动,过几秒刷新一下」——但失败之后那个 server 永远不会建好,
210
+ * 用户会一直刷新,而且提示里没有半个字告诉他出了什么事。
211
+ */
212
+ let serverError = null
213
+
214
+ /**
215
+ * 手机传上来的文件:小票 id → DSH 的附件引用。
216
+ *
217
+ * 为什么中间要绕一张小票,而不是把附件 id 直接给手机:桌面端也是这么做的。
218
+ * DSH 那边的原话是「一个线端调用者绝不能引用它没上传过的附件」——客户端能报什么
219
+ * 就得由服务端说了算,否则改一个字符串就能让模型去读电脑上任意一个已有附件。
220
+ * 表只在内存里,重启就清空,这也正合适。
221
+ */
222
+ const uploads = new Map()
223
+
224
+ /** DSH 的附件服务;纯 headless 组合里没有它,所以是可选的。 */
225
+ let attachments = null
226
+
227
+ /**
228
+ * 正在流出来的那一段。
229
+ *
230
+ * 手机上有一条硬要求:**显示的必须是最终回答,不能是中间步骤的旁白**
231
+ * (2026-09-21 用户实机报的「回答过来的是思考过程」)。麻烦在于流式阶段拿不到
232
+ * 完整内容,没法提前判断这一步最后会不会调工具——而模型调工具前几乎都会先写
233
+ * 一句旁白。等工具调用出现时才知道它是旁白,那时候它已经流到屏幕上了。
234
+ *
235
+ * 与其让一句话冒出来又缩回去(比不流式更让人困惑),不如先按兵不动一小会儿:
236
+ * 一段文字连续流这么久还没出现工具调用,才认为它是回答、开始往外露。
237
+ * 最终答案通常要写好几秒甚至几十秒,等这一下几乎看不出来;旁白通常很短,
238
+ * 多半等不到就露馅了。
239
+ *
240
+ * **这是启发式,不是判据。** 真正作数的还是 events.js 里那条「不含工具调用的
241
+ * 那一步才是回答」——这里只决定「什么时候开始显示」。万一旁白比这个时间还长,
242
+ * 它会先露出来,然后在工具调用出现的一瞬间被撤回。
243
+ */
244
+ const STREAM_GRACE_MS = 1200
245
+
246
+ /**
247
+ * 少到这个字数以下,一律不往外露。
248
+ *
249
+ * **为什么光靠时间不够。** 上面那个「连续流 1.2 秒」挡不住旁白——模型完全可以在
250
+ * 一步里先写一句「我先看看这个文件」,流够 1.2 秒,然后才发出工具调用。
251
+ * 用户 2026-09-22 实机报的正是这个:「非结论回答的那些内容会以流式生成的形态
252
+ * 出现又迅速消失,就像泄露出来的一样。」
253
+ *
254
+ * **为什么只能用长度兜。** 判据本身(这一步有没有工具调用)要到这一步末尾才出现,
255
+ * 等知道了,字早就流出去了。所以退一步用长度:旁白通常一两句话(几十个字),
256
+ * 回答动辄几百字。
257
+ *
258
+ * **40 这个数是被两头夹出来的,别再往上调。** 第一版定的 120,理由写的是「留很宽
259
+ * 的余量」,结果用户当天就报「流式生成又没了」——真实日志是「开始往外露(287 字)」:
260
+ * 答案早就越过了 120,可那时候它已经是半篇回答,一次性砸出来和没有流式一样。
261
+ * 门槛越高,用户越看不到流式;门槛越低,越可能让旁白闪一下。40 是这两者之间
262
+ * 取的值:绝大多数旁白是「我先看看这个文件」这种十几个字的,死在 40 以下。
263
+ *
264
+ * 万一还是有个超长旁白越过了这条线,它照样会在工具调用出现的一瞬间被撤回,
265
+ * 日志里记着它有多少字——下次要调这个数,看日志里那几行就知道该调多少。
266
+ */
267
+ const STREAM_MIN_CHARS = 40
268
+
269
+ /**
270
+ * 流式**关着**。
271
+ *
272
+ * 用户 2026-09-22 的裁决:「流式的效果不好,先不做流式了吧,改回一次性把最终回答抛出来,
273
+ * 过程中的自言自语、执行语句就不要在手机端出现了。」
274
+ *
275
+ * 关掉它之后,手机上**只会**收到落定的那条回答(判据在 events.js:不含工具调用的那一步),
276
+ * 中途一个字都不推。这正是他一直要的东西——流式这一路从上线起就在漏旁白,
277
+ * 靠长度门槛(STREAM_MIN_CHARS)和「出现工具调用就撤回」两条补丁勉强压着,
278
+ * 压不住的时候他就报一次。与其继续调参,不如把这条路关掉。
279
+ *
280
+ * **为什么留着代码而不是删掉**:他说的是「先不做」。下面这一整套(门槛、宽限定时器、
281
+ * 撤回、限流推送)是好几轮排查才弄对的,而本机不用 git——删掉就真没了。
282
+ * 想再试回来,把这里改成 true 即可;那条路本身没坏。
283
+ */
284
+ const STREAMING_ENABLED = false
285
+
286
+ /**
287
+ * 往外推的节奏。
288
+ *
289
+ * 模型一秒能吐几十个分片,原样转发会把 SSE 打爆,手机也会跟着抖。150 毫秒大约
290
+ * 每秒 7 帧,肉眼已经是连续的,而推送量降到十分之一以下。
291
+ */
292
+ const STREAM_PUSH_MS = 150
293
+
294
+ /** @type {{ text: string, shown: boolean, timer: NodeJS.Timeout | null }} */
295
+ const streaming = { text: '', shown: false, timer: null }
296
+ let lastLivePush = 0
297
+ /** 只报一次「事件送到了」,用来把「订阅没生效」和「后面某一步坏了」分开。 */
298
+ let sawStreamEvent = false
299
+
300
+ /**
301
+ * 按兵不动到点了,来看看该不该往外露。
302
+ *
303
+ * **收到文字之前不能收手,要再等一轮。** 这是实机上查出来的 bug(2026-09-22):
304
+ * 第一版是个一次性定时器,到点了只要还没收到文字就 return,定时器就此没了。
305
+ * 而模型常常先想一会儿才吐第一个字——等它真开始写,已经没人在等着把它露出来了。
306
+ * 结果就是整段回答从头到尾一次都不显示,最后靠落盘那条路一次性蹦出来,
307
+ * 看着和没做流式一模一样。日志里的症状很好认:一堆「撤回还没露出的旁白」,
308
+ * 外加「一个字都没收到」,而「开始往外露」一条都没有。
309
+ *
310
+ * 所以这里没文字就再排一轮。真正该收手的时候(这一步跑完、或者出现工具调用)
311
+ * stopStreaming 会把定时器清掉,不会一直空转。
312
+ */
313
+ function revealLive(sessionId) {
314
+ streaming.timer = null
315
+ if (streaming.shown) return
316
+ // 字不够多就再等——旁白多半死在这条线上,压根不会露出来。
317
+ if (streaming.text.length < STREAM_MIN_CHARS) {
318
+ streaming.timer = setTimeout(() => revealLive(sessionId), STREAM_GRACE_MS)
319
+ return
320
+ }
321
+ streaming.shown = true
322
+ store.setLive(sessionId, streaming.text)
323
+ log.info(`流式:开始往外露(${streaming.text.length} 字)`)
324
+ server?.broadcast('state', store.snapshot())
325
+ }
326
+
327
+ /** 正在流的那一段收工:停表、清干净、通知手机。 */
328
+ function stopStreaming(sessionId) {
329
+ if (streaming.timer) {
330
+ clearTimeout(streaming.timer)
331
+ streaming.timer = null
332
+ }
333
+ streaming.text = ''
334
+ streaming.shown = false
335
+ store.setLive(sessionId, '')
336
+ server?.broadcast('state', store.snapshot())
337
+ }
338
+
339
+ /** 公网隧道;null = 没开。 */
340
+ let tunnel = null
341
+ /** 用户是不是**希望**隧道开着——用来区分「我们自己关的」和「它自己断了」。 */
342
+ let tunnelWanted = false
343
+ let tunnelStarting = false
344
+ let tunnelError = null
345
+ /**
346
+ * 公网隧道到底通不通。
347
+ *
348
+ * **cloudflared 打印出网址 ≠ 隧道能用。** 2026-09-22 实测遇到:进程好好活着,但它到
349
+ * Cloudflare 边缘的连接已经断了(一条 ESTABLISHED 都没有),Cloudflare 那边没人应答,
350
+ * 手机打开就是 Error 1033——而插件只认「网址打印出来了」,面板上照样写着「已开启」。
351
+ * 用户就是照着一个写着「已开启」的面板去扫码的。
352
+ *
353
+ * 原来的代码只在 cloudflared **退出**时报错(见下面的 onExit)。它不退出,就永远发现不了。
354
+ * 所以补一道:网址有了之后隔一会儿真去打一次,只有打得到才算通。
355
+ *
356
+ * `null` = 还不知道(刚起来,还没公布出去)。连不上两次才判死——第一次很可能是太早。
357
+ */
358
+ let tunnelHealthy = null
359
+ let tunnelProbeTimer = null
360
+ let tunnelProbeFails = 0
361
+ const TUNNEL_PROBE_MS = 30000
362
+ const TUNNEL_PROBE_TIMEOUT_MS = 8000
363
+ const TUNNEL_PROBE_FAILS = 2
364
+
365
+ /** 手机当前该看哪个会话:用户绑定的优先,没绑过才退回最近活跃的。 */
366
+ function targetSessionId() {
367
+ // 绑定了就认绑定,不再看「谁最近活跃」——否则你在电脑上另开一个会话,
368
+ // 手机上的内容就被悄悄换掉了。绑定的会话如果真的没了,
369
+ // 发指令时会明确提示「这个会话已经结束了」。
370
+ if (store.state.boundSessionId) return store.state.boundSessionId
371
+ const recent = store.mostRecentSession()
372
+ if (recent) return recent.id
373
+ // 还没从事件流里见过任何会话(插件是热加载进来的,或者会话早就开着了),
374
+ // 就退回问 agent 注册表要一个——否则手机会莫名其妙地「没有可遥控的会话」。
375
+ const live = ctx.agents.list().filter((a) => a.session?.header?.origin !== 'subagent')
376
+ return live[0]?.session?.id ?? null
377
+ }
378
+
379
+ function broadcastStatus() {
380
+ const id = targetSessionId()
381
+ const running = id ? (store.sessions.get(id)?.running ?? false) : false
382
+ server?.broadcast('status', { running })
383
+ }
384
+
385
+ // ------------------------------------------------------------------
386
+ // 公网访问(cloudflared 快速隧道)
387
+ // ------------------------------------------------------------------
388
+ /** 当前公网域名,没开隧道时 null。只给限速分桶用。 */
389
+ function tunnelHost() {
390
+ if (!tunnel) return null
391
+ try {
392
+ return new URL(tunnel.url).hostname
393
+ } catch {
394
+ return null
395
+ }
396
+ }
397
+
398
+ /** 把 settings 里隧道相关的状态回写,用户在设置页点了开关要能持久化。 */
399
+ function persistSettings() {
400
+ try {
401
+ writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`)
402
+ } catch (err) {
403
+ log.warn(`配置写不进去:${err?.message ?? err}`)
404
+ }
405
+ }
406
+
407
+ async function bringTunnelUp() {
408
+ if (tunnel || tunnelStarting || !server) return
409
+ tunnelStarting = true
410
+ tunnelError = null
411
+ tunnelWanted = true
412
+ try {
413
+ const found = await ensureCloudflared({
414
+ configuredPath: settings.tunnel?.cloudflaredPath,
415
+ dataDir: dir,
416
+ log,
417
+ })
418
+ if (!found.path) {
419
+ tunnelError = found.error
420
+ log.warn(`公网访问没起来:${found.error}`)
421
+ return
422
+ }
423
+ if (found.source === 'downloaded') log.info('cloudflared 准备好了。')
424
+
425
+ // 实测向 api.trycloudflare.com 注册会偶发超时(大约一半的概率),
426
+ // cloudflared 碰到就直接退出码 1 结束。这是对方网络的问题,不是配置错了,
427
+ // 所以重试几次——否则用户会以为是自己哪里没弄对。
428
+ let lastError = null
429
+ for (let attempt = 1; attempt <= TUNNEL_ATTEMPTS; attempt += 1) {
430
+ try {
431
+ const handle = await startTunnel({ binPath: found.path, port: server.port, log })
432
+ tunnel = handle
433
+ log.info(`公网访问已开:${handle.url}`)
434
+ log.info('这个地址每次重启 DSH 都会换一个,换了之后到设置页重新扫一下码。')
435
+ // 拿到网址只是「它说它开了」,从这里开始盯它是不是真的通。
436
+ startTunnelProbe(handle.url)
437
+ handle.onExit((code) => {
438
+ stopTunnelProbe()
439
+ if (tunnel !== handle) return
440
+ tunnel = null
441
+ tunnelHealthy = null
442
+ // 我们自己关的不算故障
443
+ if (!tunnelWanted) return
444
+ tunnelError = `隧道断了(cloudflared 退出码 ${code}),公网那边暂时连不上。\n\n${TUNNEL_HINT}`
445
+ log.warn(tunnelError)
446
+ })
447
+ return
448
+ } catch (err) {
449
+ lastError = err
450
+ if (attempt < TUNNEL_ATTEMPTS) {
451
+ log.warn(`第 ${attempt} 次没起来,再试一次…`)
452
+ await new Promise((resolve) => setTimeout(resolve, 2000))
453
+ }
454
+ }
455
+ }
456
+ tunnelError = `${TUNNEL_ATTEMPTS} 次都没起来。${lastError?.message ?? lastError}\n\n${TUNNEL_HINT}`
457
+ log.warn(`公网访问没起来:${tunnelError}`)
458
+ } catch (err) {
459
+ tunnelError = err?.message ?? String(err)
460
+ log.warn(`公网访问没起来:${tunnelError}`)
461
+ } finally {
462
+ tunnelStarting = false
463
+ }
464
+ }
465
+
466
+ function bringTunnelDown() {
467
+ tunnelWanted = false
468
+ stopTunnelProbe()
469
+ tunnelHealthy = null
470
+ tunnel?.stop()
471
+ tunnel = null
472
+ tunnelError = null
473
+ }
474
+
475
+ function stopTunnelProbe() {
476
+ if (tunnelProbeTimer) clearInterval(tunnelProbeTimer)
477
+ tunnelProbeTimer = null
478
+ tunnelProbeFails = 0
479
+ }
480
+
481
+ /**
482
+ * 盯着隧道是不是真的通。
483
+ *
484
+ * 打的是自己那个版本接口:它够轻,而且**只有整条路都活着才会有 200**——
485
+ * 从本机出去、经 Cloudflare、再回到本机的手机服务。任何一段断了都拿不到 200。
486
+ */
487
+ function startTunnelProbe(url) {
488
+ stopTunnelProbe()
489
+ tunnelHealthy = null
490
+ const probe = async () => {
491
+ try {
492
+ const res = await fetch(`${url}/mini/api/version?token=${encodeURIComponent(token)}`, {
493
+ signal: AbortSignal.timeout(TUNNEL_PROBE_TIMEOUT_MS),
494
+ })
495
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
496
+ tunnelProbeFails = 0
497
+ tunnelHealthy = true
498
+ tunnelError = null
499
+ } catch (err) {
500
+ const code = err?.cause?.code ?? err?.code ?? ''
501
+ // **域名解析不出来 ≠ 隧道坏了。**
502
+ // 2026-09-22 实测:cloudflared 连着 3 条边缘连接、域名在 1.1.1.1 / 8.8.8.8 / 223.5.5.5
503
+ // 上都解析得出、强制指到真实 IP 打过去是 200——可本机路由器(192.168.1.1)就是查不到
504
+ // *.trycloudflare.com。这种时候**绝对不能判死**:手机在外面走的是运营商的 DNS,
505
+ // 很可能好好的,把网址收回去反而让用户连地址都看不到。
506
+ if (tunnelProbeVerdict(code) === 'dns') {
507
+ tunnelError = '隧道本身是通的,但这台电脑解析不了这个域名。\n\n'
508
+ + '多半是代理或路由器的问题。先试试关掉代理;还不行就在手机上换成流量,'
509
+ + '或者把电脑的 DNS 改成 1.1.1.1。'
510
+ log.warn(tunnelError)
511
+ return
512
+ }
513
+ tunnelProbeFails += 1
514
+ if (tunnelProbeFails < TUNNEL_PROBE_FAILS) return
515
+ tunnelHealthy = false
516
+ tunnelError = '公网隧道还开着,但已经连不上了:'
517
+ + `${code || err?.message || err}。手机打开会看到 Cloudflare 的 Error 1033。\n\n${TUNNEL_HINT}`
518
+ log.warn(tunnelError)
519
+ // 判死之后就别再打了:结果不会变,白跑网络。
520
+ stopTunnelProbe()
521
+ }
522
+ }
523
+ tunnelProbeTimer = setInterval(probe, TUNNEL_PROBE_MS)
524
+ // 这个定时器不该拖着进程不让它退出。
525
+ if (tunnelProbeTimer.unref) tunnelProbeTimer.unref()
526
+ probe()
527
+ }
528
+
529
+ function maybeNotify(text) {
530
+ const notify = settings.notify
531
+ if (!notify?.enabled) return
532
+ // 手机页面正开着就靠页面自己的提示音,不用再发系统推送,免得响两次。
533
+ if (notify.onlyWhenBackground !== false && store.isForeground()) return
534
+ sendExternalNotification(notify, text).then((result) => {
535
+ if (!result.ok && result.error !== 'disabled') {
536
+ log.warn(`外部推送失败:${result.error}`)
537
+ }
538
+ })
539
+ }
540
+
541
+ // ------------------------------------------------------------------
542
+ // 手机发来的指令 → 注入 DSH
543
+ // ------------------------------------------------------------------
544
+
545
+ /**
546
+ * 手机发来的指令,我们自己已经记过一笔;它注入之后还会以 `user/message` 事件的
547
+ * 形式回来一趟,事件流那一路会**再记一笔**——聊天模式里就成了同一条显示两次。
548
+ *
549
+ * 用消息 id 精确认出来,不靠「时间差不多、文字一样」这种猜法。
550
+ */
551
+ const injectedIds = new Set()
552
+ const INJECTED_KEEP = 50
553
+
554
+ function rememberInjected(id) {
555
+ if (!id) return
556
+ injectedIds.add(id)
557
+ // Set 是插入序,超了就从最早的开始丢。手机发指令是人手速度,这点量足够了。
558
+ while (injectedIds.size > INJECTED_KEEP) {
559
+ injectedIds.delete(injectedIds.values().next().value)
560
+ }
561
+ }
562
+
563
+ /**
564
+ * 排队中的指令——真相在 agent 的收件箱里,这里只负责翻译成人话。
565
+ *
566
+ * `agent.inbox.nextTurn` 是「等着各自开一轮」的那些(`followup()` 排进去的),
567
+ * `nextStep` 是「插到下一步」的那些(steering,手机端目前不产生,但电脑上可能有)。
568
+ * 两个都列出来,手机上看到的队列才和电脑上一致。
569
+ */
570
+ function queuedOf(sessionId) {
571
+ const agent = ctx.agents.get(sessionId)
572
+ if (!agent?.inbox) return []
573
+ const rows = []
574
+ for (const [placement, list] of [['next-turn', agent.inbox.nextTurn], ['next-step', agent.inbox.nextStep]]) {
575
+ for (const m of list ?? []) {
576
+ rows.push({ id: m.id, text: visibleText(m.content), placement })
577
+ }
578
+ }
579
+ return rows
580
+ }
581
+
582
+ async function onInstruction(text, uploadIds) {
583
+ const sessionId = targetSessionId()
584
+ if (!sessionId) {
585
+ return { ok: false, error: '电脑上还没有可遥控的会话,请先在 DSH 里开一个。' }
586
+ }
587
+ const agent = ctx.agents.get(sessionId)
588
+ if (!agent) {
589
+ return { ok: false, error: '这个会话已经结束了,请在手机上重新选一个会话。' }
590
+ }
591
+
592
+ // 先把小票换成真正的附件引用。认不出来的小票会被丢掉——宁可当没传过,
593
+ // 也不能让手机报什么就信什么。
594
+ const files = takeUploads(uploadIds)
595
+ if (!text && !files.length) {
596
+ return { ok: false, error: '指令为空' }
597
+ }
598
+
599
+ /**
600
+ * 跑着的时候**不再拒绝**,改成排队。
601
+ *
602
+ * 原来这里有一条 `agent.status === 'running'` 的挡板,回一句「上一个任务还在跑,
603
+ * 等它完成再发」。那条挡板是多余的,而且**比 DSH 自己还严**:`followup()` 的
604
+ * 语义本来就是「排队一个后续轮次并唤醒驱动」(dsh-agent 的类型注释原话:
605
+ * "Queue an ordinary follow-up turn and wake the driver"),跑着的时候照样收,
606
+ * 排进 inbox.nextTurn,等当前这一轮结束再跑。DSH 桌面端同一时刻就能接着排。
607
+ *
608
+ * 所以这里只是把「拒绝」换成「收下并说清楚它排队了」。
609
+ */
610
+ const queued = agent.status === 'running'
611
+
612
+ const message = userMessage(text, files)
613
+ // 必须在 followup 之前登记。
614
+ // 消息是**原样**进 agent 收件箱的(dsh-agent-loop 的 send() 直接把它 splice 进
615
+ // inbox,不克隆、不换 id),所以事件回来时带的还是这个 id,认得出。
616
+ // 但事件什么时候回来不确定——驱动是异步 kick 起来的(wakeDriver 返回 Promise)。
617
+ // 先登记,就不必去赌这个时序。
618
+ //
619
+ // **排队的那条不登记。** 登记是为了「等它跑起来时别记两遍」,而排队的那条
620
+ // 现在**还不该在手机上出现**——用户 2026-09-22 实机提的:排队的指令会立刻
621
+ // 显示成一条「像已经发出去」的气泡,其实还在队列里等着。
622
+ // 不登记、也不记账,等它真轮到时 session/event 那条路会把它补进 history,
623
+ // 正好记一次、也只在那时候出现。
624
+ if (!queued) rememberInjected(message.id)
625
+ try {
626
+ agent.followup(message)
627
+ } catch (err) {
628
+ return { ok: false, error: `注入失败:${err?.message ?? err}` }
629
+ }
630
+ // 手机上回显的那条:附件也得看得见。不然发完一条「只有文件、没写文字」的指令,
631
+ // 气泡是空的,用户会以为没发出去。
632
+ const shown = files.length
633
+ ? `${text}${text ? '\n' : ''}📎 ${files.map((f) => f.name).join('、')}`
634
+ : text
635
+ if (!queued) store.pushUser({ text: shown, sessionId, id: message.id })
636
+ if (!store.state.boundSessionId) store.bind(sessionId)
637
+ store.setRunning(sessionId, true)
638
+ broadcastStatus()
639
+ // 排队的那条也要推快照:队列列表是从 agent 收件箱读的,不推手机上就看不到
640
+ // 它排在哪儿。区别只在于**不往 history 里记**,所以它不会长成一条气泡。
641
+ server?.broadcast('state', store.snapshot())
642
+ // queued 只是给界面上那句提示用的:现在就在跑,所以这条得等下一轮。
643
+ return { ok: true, sessionId, queued }
644
+ }
645
+
646
+ /**
647
+ * 把一条还在排队的指令撤回来。
648
+ *
649
+ * `agent.inbox.remove()` 返回的是「它当时是否还在队列里」——**不能不看这个返回值**:
650
+ * 手机上那条队列是几百毫秒前推过去的,这中间它完全可能已经轮到自己、开始跑了。
651
+ * 那种情况下它已经不在收件箱里,remove 返回 false,界面必须如实说「它已经开跑了」,
652
+ * 而不是假装撤掉了。
653
+ */
654
+ function onUnqueue(id) {
655
+ const sessionId = targetSessionId()
656
+ if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
657
+ const agent = ctx.agents.get(sessionId)
658
+ if (!agent) return { ok: false, error: '这个会话已经结束了,请在手机上重新选一个会话。' }
659
+ if (!id) return { ok: false, error: '没说要撤哪一条。' }
660
+ let removed = false
661
+ try {
662
+ removed = agent.inbox.remove(id) === true
663
+ } catch (err) {
664
+ return { ok: false, error: `撤不回来:${err?.message ?? err}` }
665
+ }
666
+ if (!removed) {
667
+ // 没撤成也要把最新队列推一遍:手机上那份是上一次推送时的样子,这条已经开跑了,
668
+ // 界面得跟着纠正,不能一直挂着一条其实早就不在队列里的东西。
669
+ server?.broadcast('state', store.snapshot())
670
+ return { ok: false, error: '这条已经轮到自己开始跑了,撤不回来了。' }
671
+ }
672
+ server?.broadcast('state', store.snapshot())
673
+ return { ok: true, sessionId }
674
+ }
675
+
676
+ /**
677
+ * 停掉正在跑的那一轮。
678
+ *
679
+ * 和 DSH 自己的停止按钮**完全一致**——照抄 dsh-api-session-controller 里
680
+ * commands.cancel 的写法:`agent.cancel({ kind: 'user' }, { keepInbox: true })`。
681
+ * 那个 keepInbox 是照抄的重点:中止当前这一轮,但**收件箱里还没跑的东西留着**。
682
+ * 不加的话,你在电脑上排好的下一条会被手机这一下悄悄吃掉,而且是无声的。
683
+ *
684
+ * cancel() 是「递个请求」,不是「等它停」:它立刻返回,真正停下要等驱动收敛,
685
+ * 到那时候 agent/status 才翻成 idle、turn/end 才带着 aborted 出来。
686
+ * 所以返回的 ok 只表示请求递进去了,界面上得等状态翻——不能按完就当停好了。
687
+ *
688
+ * 停下来之后**不会**推出什么「回答」:中止的那一轮通常卡在工具调用里,
689
+ * events.js 那边本来就不会把它当回答(见 isIntermediateStep)。推空的。
690
+ */
691
+ function onStop() {
692
+ const sessionId = targetSessionId()
693
+ if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
694
+ const agent = ctx.agents.get(sessionId)
695
+ if (!agent) return { ok: false, error: '这个会话已经结束了,请在手机上重新选一个会话。' }
696
+ // 子 Agent 不归手机管:导航栏里不显示它们,手机上也不该能停它们。
697
+ // DSH 自己那条路也是这么挡的(hasApiSessionSubagentOwner)。
698
+ if (agent.session?.header?.origin === 'subagent') {
699
+ return { ok: false, error: '这是个子 Agent,手机上停不了它。' }
700
+ }
701
+ try {
702
+ agent.cancel({ kind: 'user' }, { keepInbox: true })
703
+ } catch (err) {
704
+ return { ok: false, error: `停不下来:${err?.message ?? err}` }
705
+ }
706
+ return { ok: true, sessionId }
707
+ }
708
+
709
+ // ------------------------------------------------------------------
710
+ // 事件订阅:只取「最后一帧」
711
+ // ------------------------------------------------------------------
712
+ ctx.on('session/event', (session, event) => {
713
+ // 子 agent 的会话不进手机——用户遥控的是自己的主会话,不是它派出去的小弟。
714
+ if (session.header?.origin === 'subagent') return
715
+ const sessionId = session.id
716
+
717
+ // 会话标题(可选事件,拿到就用,拿不到就用 id 前 8 位兜底)
718
+ if (event.type === 'session/title') {
719
+ const title = event.data?.title ?? event.data?.text
720
+ if (typeof title === 'string' && title.trim()) {
721
+ store.touchSession(sessionId, { title: title.trim() })
722
+ }
723
+ return
724
+ }
725
+
726
+ const action = tracker.feed(sessionId, event)
727
+ if (!action) return
728
+ store.touchSession(sessionId)
729
+
730
+ // 手机还没绑会话时,谁先有动静就绑谁。
731
+ if (!store.state.boundSessionId) store.bind(sessionId)
732
+
733
+ // 下面只处理手机正在遥控的那个会话。别的会话(比如你在电脑上另开的)
734
+ // 既不该震手机,也不该顶掉单帧模式显示的内容——实时推送和刷新后拿到的
735
+ // 状态必须是同一套口径,否则刷新一下就看到别人的结果了。
736
+ if (sessionId !== targetSessionId()) return
737
+
738
+ if (action.kind === 'user') {
739
+ // 手机自己发的那条:onInstruction 里已经记过一笔了,这里跳过。
740
+ // 不跳的话聊天模式里同一条指令会显示两次(用户实机报过)。
741
+ if (action.id && injectedIds.has(action.id)) {
742
+ injectedIds.delete(action.id)
743
+ // **但不能直接 return**:这条事件的意思是「它轮到、开始跑了」,
744
+ // 队列里少了一条。不推这一下,手机上那块「排队中」就会一直挂着
745
+ // 一条其实已经在跑的东西。(内容那一笔确实已经在 onInstruction 里记过了,
746
+ // 所以只推快照、不重复记。)
747
+ server?.broadcast('state', store.snapshot())
748
+ return
749
+ }
750
+ // 电脑上自己敲的指令也记一笔,聊天模式才完整。
751
+ // 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
752
+ store.pushUser({ text: action.text, sessionId, id: action.id })
753
+ server?.broadcast('state', store.snapshot())
754
+ return
755
+ }
756
+
757
+ // action.kind === 'reply'
758
+ store.pushReply({
759
+ text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
760
+ })
761
+ server?.broadcast('reply', {
762
+ text: action.text,
763
+ sessionId,
764
+ reason: action.reason,
765
+ // 这是「你按停的那半句」,不是完整回答。手机要标出来,别让人以为是模型答崩了。
766
+ interrupted: action.interrupted === true,
767
+ timestamp: Date.now(),
768
+ })
769
+ maybeNotify(action.text)
770
+ })
771
+
772
+ ctx.on('agent/status', ({ agent, status }) => {
773
+ const sessionId = agent?.session?.id
774
+ if (!sessionId) return
775
+ if (agent.session.header?.origin === 'subagent') return
776
+ store.setRunning(sessionId, status === 'running')
777
+ broadcastStatus()
778
+ })
779
+
780
+ /**
781
+ * 模型每吐一小段,DSH 就发一帧。这是**唯一**能拿到增量文本的地方。
782
+ *
783
+ * 别的路都不行:`session/event` 是落盘之后才发的(等它到,整段已经写完了),
784
+ * 消息账本里那条 assistant/message 也是收尾时才写。所以「流式」只能挂在这儿。
785
+ *
786
+ * 两个已知的限制,都绕不开,只能自己兜着:
787
+ * ① 它是**进程本地**的,不重放。手机中途刷新就接不上了——所以流出来的那段
788
+ * 自己在 store 里留一份,塞进快照,任何一条广播都能把它补回去。
789
+ * ② 它不带 sessionId,得从 agent 上拿。和 session/event 同一套过滤口径:
790
+ * 子 Agent 的流不往手机上推。
791
+ */
792
+ ctx.on('agent/assistant-stream', ({ agent, frame }) => {
793
+ // 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
794
+ // 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
795
+ if (!STREAMING_ENABLED) return
796
+
797
+ const session = agent?.session
798
+ const sessionId = session?.id
799
+ if (!sessionId) return
800
+ if (session.header?.origin === 'subagent') return
801
+
802
+ // 只报一次:这条日志回答的是「事件到底有没有送到插件」。一条都没有,就说明
803
+ // 问题在订阅那一层,不用再往下查。
804
+ if (!sawStreamEvent) {
805
+ sawStreamEvent = true
806
+ log.info('流式:收到第一个增量事件了')
807
+ }
808
+
809
+ if (frame?.type === 'start') {
810
+ streaming.text = ''
811
+ streaming.shown = false
812
+ if (streaming.timer) clearTimeout(streaming.timer)
813
+ // 先按兵不动。到点了再看这段文字还在不在、有没有被工具调用打断。
814
+ streaming.timer = setTimeout(() => revealLive(sessionId), STREAM_GRACE_MS)
815
+ return
816
+ }
817
+
818
+ if (frame?.type === 'chunk') {
819
+ const chunk = frame.chunk
820
+ if (chunk?.type === 'tool-call-delta') {
821
+ // 出现工具调用 = 这一步是中间步骤,刚才流出来的那句是旁白。
822
+ // 立刻撤回,不等它跑完——早一帧撤回,用户就少看一眼不该看的东西。
823
+ log.info(`流式:出现工具调用,撤回${streaming.shown ? '已露出的' : '还没露出的'}旁白(${streaming.text.length} 字)`)
824
+ stopStreaming(sessionId)
825
+ return
826
+ }
827
+ if (chunk?.type !== 'text-delta' || !chunk.text) return
828
+ streaming.text += chunk.text
829
+ // 没露过的话,**每收到一个字就问一次够不够长**,而不是干等定时器。
830
+ //
831
+ // 实机踩过(2026-09-22):原来只在定时器到点时才检查,而定时器 1.2 秒一轮,
832
+ // 模型一秒能写几十个字。于是越过门槛的那一刻没人发现,等定时器醒来时字已经
833
+ // 堆到 287——日志里就是「开始往外露(287 字)」,用户看到的是半篇回答一次性
834
+ // 砸出来,报「流式生成又没了」。现在跨过门槛的那一瞬间就露。
835
+ if (!streaming.shown) {
836
+ if (streaming.text.length < STREAM_MIN_CHARS) return
837
+ revealLive(sessionId)
838
+ return
839
+ }
840
+ store.setLive(sessionId, streaming.text)
841
+ // 限流:模型一秒能吐几十个分片,原样转发会把 SSE 打爆。
842
+ const now = Date.now()
843
+ if (now - lastLivePush < STREAM_PUSH_MS) return
844
+ lastLivePush = now
845
+ server?.broadcast('state', store.snapshot())
846
+ return
847
+ }
848
+
849
+ // frame.type === 'end'
850
+ //
851
+ // 收工时**不需要**判断这一步是不是回答:如果是,落盘的 assistant/message 早就
852
+ // 先一步到(走的 session/event),latest 已经换成了完整的那一段,屏幕上自然
853
+ // 接上;如果不是,本来也没显示过什么。两种情况都只要把临时的清掉。
854
+ //
855
+ // 注意 end 里的 outcome 即使被用户按停也仍是 'committed',所以这里不能拿它
856
+ // 判断「是不是正常结束」——那是 events.js 里 interrupted 的活。
857
+ if (frame?.type === 'end') {
858
+ if (streaming.shown) log.info(`流式:这一步写完了,共 ${streaming.text.length} 字`)
859
+ stopStreaming(sessionId)
860
+ return
861
+ }
862
+
863
+ // 走到这儿说明帧的形状和我们以为的不一样。把前 200 字打出来——比起猜,
864
+ // 看一眼真实的东西更快。
865
+ log.warn(`流式:不认识的帧 ${String(JSON.stringify(frame)).slice(0, 200)}`)
866
+ })
867
+
868
+ // 会话一建出来就登记,这样手机上的会话选择器不用等到有活动才出现。
869
+ ctx.on('agent/created', ({ agent }) => {
870
+ const sessionId = agent?.session?.id
871
+ if (!sessionId || agent.session.header?.origin === 'subagent') return
872
+ store.touchSession(sessionId, {
873
+ running: agent.status === 'running',
874
+ })
875
+ server?.broadcast('state', store.snapshot())
876
+ })
877
+
878
+ ctx.on('agent/disposed', ({ agent }) => {
879
+ const sessionId = agent?.session?.id
880
+ if (!sessionId) return
881
+ store.forgetSession(sessionId)
882
+ server?.broadcast('state', store.snapshot())
883
+ })
884
+
885
+ // ------------------------------------------------------------------
886
+ // 左侧导航栏要用的两个服务
887
+ // ------------------------------------------------------------------
888
+ // 同样走 ctx.inject,不写进 inject 数组:这两个服务只在 web 组合里有,写进去
889
+ // 的话 headless 组合下服务永远不来,这个插件就一直挂着不激活——启动失败那个
890
+ // 坑刚踩过一次,不再踩第二次。服务没到位时导航栏退回「只知道当前会话」。
891
+ //
892
+ // 拿到的时机可能晚于建服务,所以存进一个可变的持有对象,让服务端每次请求
893
+ // 现读,而不是建服务时读一次快照。
894
+ const treeServices = { registry: null, query: null }
895
+ ctx.inject(['sessionQuery', 'workspaceRegistry'], (treeCtx) => {
896
+ treeServices.registry = treeCtx.workspaceRegistry
897
+ treeServices.query = treeCtx.sessionQuery
898
+ // 这行日志是有用的:服务到底有没有到位,只能在这里确定地看到。
899
+ // 「配置和源码里都有」不等于「运行时真的提供了」,前者是推断,后者才是事实。
900
+ console.log('[dsh-mini-remote] 会话树已就绪:workspaceRegistry='
901
+ + Boolean(treeServices.registry) + ' sessionQuery=' + Boolean(treeServices.query))
902
+ })
903
+
904
+ // ------------------------------------------------------------------
905
+ // 手机上传文件要用的附件服务
906
+ // ------------------------------------------------------------------
907
+ // 同样走 ctx.inject、不写进 inject 数组(理由同上)。没有它的时候上传按钮会
908
+ // 如实说「这台电脑传不了文件」,而不是让用户点了没反应。
909
+ ctx.inject(['attachments'], (attachCtx) => {
910
+ attachments = attachCtx.attachments
911
+ console.log('[dsh-mini-remote] 附件服务已就绪:可以接收手机上传的文件')
912
+ })
913
+
914
+ /**
915
+ * 把手机传来的字节交给 DSH 存,换一张小票回来。
916
+ *
917
+ * 为什么不自己存到某个目录、再在指令文字里写一行路径:DSH 的消息结构里本来就有
918
+ * 附件这种块,它会在发请求前自动把附件换成一段带路径的提示给模型读。文件名消毒、
919
+ * 内容寻址、只读副本、引用校验全是它的事。自己拼路径等于把这些统统绕过去,还要
920
+ * 自己管清理、自己保证不把文件塞进工作区。
921
+ *
922
+ * `data` 是请求流本身(Node 的 IncomingMessage 就是异步可迭代的字节流),DSH 那边
923
+ * 是边收边落盘,不在内存里攒整份。
924
+ */
925
+ async function onUpload({ data, name }) {
926
+ if (!attachments) throw new Error('这台电脑上的 DSH 没装附件服务')
927
+ const ref = await attachments.saveFileStream({ data, ...(name ? { name } : {}) })
928
+ const uploadId = randomUUID()
929
+ uploads.set(uploadId, ref)
930
+ // 别让它无限涨:手机传完却一直不发指令的话,这些引用就一直挂着。
931
+ // 按插入顺序淘汰最早的——手机上同时挂着二十个待发文件不现实。
932
+ if (uploads.size > MAX_PENDING_UPLOADS) {
933
+ uploads.delete(uploads.keys().next().value)
934
+ }
935
+ return { uploadId, name: ref.name, bytes: ref.bytes }
936
+ }
937
+
938
+ /** 把小票换成附件引用。认不出来的小票直接丢掉,不猜。 */
939
+ function takeUploads(uploadIds) {
940
+ const refs = []
941
+ for (const id of Array.isArray(uploadIds) ? uploadIds : []) {
942
+ const ref = uploads.get(id)
943
+ if (ref) {
944
+ refs.push(ref)
945
+ uploads.delete(id) // 用掉就作废,同一条不能发两次
946
+ }
947
+ }
948
+ return refs
949
+ }
950
+
951
+ // 插件自己在事件流里见过的标题——内存里就有,不用去读日志。
952
+ function knownTitles() {
953
+ const map = new Map()
954
+ for (const s of store.snapshot().sessions ?? []) {
955
+ if (s.title) map.set(s.id, s.title)
956
+ }
957
+ return map
958
+ }
959
+
960
+ const nav = {
961
+ listWorkspaces: (runningIds, force) =>
962
+ listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force }),
963
+ listSessionsOf: (workspaceId, limit, force) =>
964
+ listSessionsOf({
965
+ registry: treeServices.registry,
966
+ query: treeServices.query,
967
+ agents: ctx.agents,
968
+ knownTitles: knownTitles(),
969
+ workspaceId,
970
+ limit,
971
+ force,
972
+ }),
973
+ }
974
+
975
+ // ------------------------------------------------------------------
976
+ // 起服务
977
+ // ------------------------------------------------------------------
978
+ ctx.effect(() => {
979
+ let closed = false
980
+ createMiniServer({
981
+ store,
982
+ config: settings,
983
+ token,
984
+ bindAddresses: resolveBindAddresses(settings.bindAddress),
985
+ tunnelHost,
986
+ log,
987
+ onInstruction,
988
+ onUpload,
989
+ onStop,
990
+ onUnqueue,
991
+ tree: nav,
992
+ build: BUILD,
993
+ })
994
+ .then((instance) => {
995
+ if (closed) {
996
+ instance.close()
997
+ return
998
+ }
999
+ server = instance
1000
+ serverError = null
1001
+ for (const f of instance.failed) {
1002
+ log.warn(`地址 ${f.address} 没绑上,已跳过:${f.message}`)
1003
+ }
1004
+ const usable = reachableAddresses().filter((a) => instance.addresses.includes(a.address))
1005
+ if (usable.length) {
1006
+ console.log(`[dsh-mini-remote] 构建指纹 ${BUILD}`)
1007
+ log.info('已启动。手机打开下面任意一条(或到 DSH 设置 → 手机遥控 扫码):')
1008
+ for (const a of usable) {
1009
+ log.info(` ${a.label} http://${a.address}:${instance.port}/mini?token=${token}`)
1010
+ }
1011
+ } else {
1012
+ log.info(`已启动,但只绑上了本机:http://127.0.0.1:${instance.port}/mini?token=${token}`)
1013
+ log.warn('没找到手机能连上的地址——检查 Wi-Fi/网线,或者 Tailscale 是否已登录。')
1014
+ }
1015
+ // 服务起来了才谈得上开隧道:cloudflared 要指向这个端口
1016
+ if (settings.tunnel?.enabled) bringTunnelUp()
1017
+ })
1018
+ .catch((err) => {
1019
+ // 记下来。设置页那边要拿它把「还在启动」和「起不来了」分开说——
1020
+ // 只写日志的话,用户看到的永远是那句「过几秒刷新一下」。
1021
+ serverError = err?.message ?? String(err)
1022
+ log.warn(`启动失败:${serverError}`)
1023
+ })
1024
+
1025
+ return () => {
1026
+ closed = true
1027
+ bringTunnelDown()
1028
+ server?.close()
1029
+ server = null
1030
+ }
1031
+ })
1032
+
1033
+ // ------------------------------------------------------------------
1034
+ // DSH 设置页的「手机遥控」标签从这里取数据
1035
+ // ------------------------------------------------------------------
1036
+ // 用 ctx.inject 而不是 ctx.effect + 直接读 ctx.webServer,两个理由:
1037
+ // 1. cordis 的 ctx 代理对「没写进 inject 的服务名」是**抛错**
1038
+ // (cannot get property "webServer" without inject),不是给 undefined,
1039
+ // 所以「不注入、拿不到就降级」这条路走不通;
1040
+ // 2. boot 期各插件是并行加载的,apply 这一刻 webServer 常常还没 provide,
1041
+ // ctx.get('webServer') 只会拿到 undefined 而且永不重试(dsh-config-manager
1042
+ // 现在就在 boot 时打这条 warn)。
1043
+ // ctx.inject 会在 webServer 就绪后回调;纯 headless 组合里它永远不来,这个子
1044
+ // fiber 就一直不激活——不报错,也不影响手机端自己那个 3090 服务。
1045
+ ctx.inject(['webServer'], (webCtx) => {
1046
+ const webServer = webCtx.webServer
1047
+ if (typeof webServer?.register !== 'function') {
1048
+ log.warn('拿不到 webServer 服务,设置页里的扫码面板用不了(手机端不受影响)。')
1049
+ return
1050
+ }
1051
+
1052
+ // 面板和开关都带着 token,所以两者都只允许从这台电脑自己访问。否则同一个
1053
+ // Wi-Fi 下谁都能把密码取走,token 就白设了。
1054
+ function localOnly(req, res) {
1055
+ if (isLocalRequest(req)) return true
1056
+ sendJson(res, 403, { ok: false, error: '配对信息只能在这台电脑上看。' })
1057
+ return false
1058
+ }
1059
+
1060
+ async function pairingPayload() {
1061
+ if (!server) {
1062
+ // 分清「还在启动」和「起不来了」。原来两种情况都回同一句「过几秒刷新一下」,
1063
+ // 但失败之后 server 永远不会建好——用户会一直刷新,而且提示里没有半个字
1064
+ // 告诉他出了什么事。失败时把真实原因说出来,并告诉他去哪儿改。
1065
+ return {
1066
+ status: 503,
1067
+ body: {
1068
+ ok: false,
1069
+ error: serverError
1070
+ ? `手机服务起不来:${serverError}\n端口可以在设置文件里改(settings.json 里的 port)。`
1071
+ : '手机服务还在启动,过几秒刷新一下。',
1072
+ },
1073
+ }
1074
+ }
1075
+ const payload = await buildPairing({
1076
+ port: server.port,
1077
+ token,
1078
+ bound: server.addresses,
1079
+ tunnel: {
1080
+ enabled: Boolean(settings.tunnel?.enabled),
1081
+ // 判死之后**不再把网址交出去**(判断在 pairing.js 的 tunnelUrlFor 里,
1082
+ // 抽出去是为了能单测——它决定了用户会不会扫到一个扫不开的码)。
1083
+ url: tunnelUrlFor(tunnelHealthy, tunnel?.url),
1084
+ starting: tunnelStarting,
1085
+ error: tunnelError,
1086
+ },
1087
+ })
1088
+ return { status: payload.ok ? 200 : 500, body: payload }
1089
+ }
1090
+
1091
+ const offGet = webServer.register({
1092
+ kind: 'exact',
1093
+ path: '/mini-remote/pairing',
1094
+ handler: async (req, res) => {
1095
+ if (!localOnly(req, res)) return
1096
+ try {
1097
+ const { status, body } = await pairingPayload()
1098
+ sendJson(res, status, body)
1099
+ } catch (err) {
1100
+ sendJson(res, 500, { ok: false, error: `读取配对信息失败:${err?.message ?? err}` })
1101
+ }
1102
+ },
1103
+ })
1104
+
1105
+ const offTunnel = webServer.register({
1106
+ kind: 'exact',
1107
+ path: '/mini-remote/tunnel',
1108
+ handler: async (req, res) => {
1109
+ if (!localOnly(req, res)) return
1110
+ if (req.method !== 'POST') {
1111
+ sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
1112
+ return
1113
+ }
1114
+ let want
1115
+ try {
1116
+ want = await readBody(req)
1117
+ } catch (err) {
1118
+ sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
1119
+ return
1120
+ }
1121
+ const enabled = Boolean(want?.enabled)
1122
+ settings.tunnel = { ...settings.tunnel, enabled }
1123
+ persistSettings()
1124
+ if (enabled) await bringTunnelUp()
1125
+ else bringTunnelDown()
1126
+ try {
1127
+ const { status, body } = await pairingPayload()
1128
+ sendJson(res, status, body)
1129
+ } catch (err) {
1130
+ sendJson(res, 500, { ok: false, error: `切换之后读不到状态:${err?.message ?? err}` })
1131
+ }
1132
+ },
1133
+ })
1134
+
1135
+ /**
1136
+ * 密码最短多少位。
1137
+ *
1138
+ * 12 位是「好记」和「猜不出」之间的折中。之所以不能像普通登录框那样放开短的:
1139
+ * 密码是编在网址里的(`?token=...`),猜的人不受表单那套限速的约束——他可以直接
1140
+ * 对着地址栏试,而限速只在服务端挡。用户想设成自己记得住的东西,这个需求是合理的,
1141
+ * 但「123456」这种等于把门拆了。
1142
+ */
1143
+ const MIN_TOKEN_LEN = 12
1144
+
1145
+ /**
1146
+ * 换一个自己设的密码。
1147
+ *
1148
+ * **要重启 DSH 才生效。** 服务在启动时就把 token 读进内存了(见上面的
1149
+ * `loadOrCreateToken`),改文件不影响正在跑的那个。做成「立刻生效」得改服务内部的
1150
+ * 取值方式,影响面比这个需求本身大,所以这里如实告诉用户要重启,而不是假装换好了。
1151
+ *
1152
+ * 返回的 payload 里带一个 `pendingToken`:界面据此显示「新密码已保存,重启后生效」。
1153
+ * 面板上那些二维码仍然是**当前在用的**旧密码编出来的——它们在重启前确实还能用,
1154
+ * 显示成新的就成了骗人。
1155
+ */
1156
+ const offToken = webServer.register({
1157
+ kind: 'exact',
1158
+ path: '/mini-remote/token',
1159
+ handler: async (req, res) => {
1160
+ if (!localOnly(req, res)) return
1161
+ if (req.method !== 'POST') {
1162
+ sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
1163
+ return
1164
+ }
1165
+ let want
1166
+ try {
1167
+ want = await readBody(req)
1168
+ } catch (err) {
1169
+ sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
1170
+ return
1171
+ }
1172
+ const next = typeof want?.token === 'string' ? want.token.trim() : ''
1173
+ if (next.length < MIN_TOKEN_LEN) {
1174
+ sendJson(res, 400, { ok: false, error: `密码至少要 ${MIN_TOKEN_LEN} 位,现在这串只有 ${next.length} 位。` })
1175
+ return
1176
+ }
1177
+ if (/[\s?#&/]/.test(next)) {
1178
+ // 密码要拼进网址,这些字符会把网址拆坏,或者让密码只生效一半
1179
+ sendJson(res, 400, { ok: false, error: '密码里不能有空格,也不能有 ? # & / 这几个符号。' })
1180
+ return
1181
+ }
1182
+ settings.token = next
1183
+ persistSettings()
1184
+ // token 文件也一起写:将来要是把 settings.json 里的 token 清空,读到的该是现在这个,
1185
+ // 而不是上一个。两处不一致的话,用户会莫名其妙被挡在门外。
1186
+ try {
1187
+ writeFileSync(join(dir, 'token'), next, { mode: 0o600 })
1188
+ } catch (err) {
1189
+ sendJson(res, 500, { ok: false, error: `密码写进配置文件了,但 token 文件没写成:${err?.message ?? err}` })
1190
+ return
1191
+ }
1192
+ try {
1193
+ const { status, body } = await pairingPayload()
1194
+ sendJson(res, status, { ...body, pendingToken: next })
1195
+ } catch (err) {
1196
+ sendJson(res, 500, { ok: false, error: `改完之后读不到状态:${err?.message ?? err}` })
1197
+ }
1198
+ },
1199
+ })
1200
+
1201
+ return () => {
1202
+ offGet?.()
1203
+ offTunnel?.()
1204
+ }
1205
+ })
1206
+ }