dsh-mini-remote 0.1.0 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -5,9 +5,12 @@
5
5
  * - 不代理 DSH 桌面界面(那是 dsh-pocket 的做法),手机只拿两样东西:
6
6
  * 用户发出的指令 + Agent 的最终回复。
7
7
  * - 不监听 waterfall 事件。只订阅纯 emit 事件,避免误吞 agent 的默认行为。
8
- * - 不 import 任何 @deepseek-ai/* 的运行时值。DSH 事件契约已经用 .d.ts 核实,
9
- * 消息对象自己造(MessageId 的 brand 在运行时是恒等函数、不做校验),
10
- * 这样插件加载期零外部依赖,也不会有 cordis 双副本的坑。
8
+ * - 不 import 任何 @deepseek-ai/* 的运行时值,只有一个例外:@deepseek-ai/schemastery。
9
+ * 它只用来声明 Config(见下面 export const Config)——DSH 0.1.7 起,只有把字段标成
10
+ * volatile,改它才不需要重载插件,而声明 schema 绕不开 schemastery。它由宿主提供
11
+ * (写进 peerDependencies、不进 dependencies,免得出现第二份副本)。
12
+ * 其余照旧:事件契约已经用 .d.ts 核实,消息对象自己造(MessageId 的 brand 在运行时
13
+ * 是恒等函数、不做校验),插件加载期几乎没有外部依赖,也没有 cordis 双副本的坑。
11
14
  *
12
15
  * 事件契约(DSH 0.1.5-rc.2 实测):
13
16
  * session/event(session, event) event = { type, seq, time, data }
@@ -22,19 +25,36 @@ import { homedir } from 'node:os'
22
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
26
  import { randomBytes, randomUUID } from 'node:crypto'
24
27
 
28
+ import z from '@deepseek-ai/schemastery'
29
+
25
30
  import { createStore } from './store.js'
26
- import { createTurnTracker, visibleText } from './events.js'
27
- import { createMiniServer, sendJson, readBody } from './server.js'
31
+ import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
32
+ import { replayHistory, lastReply } from './history.js'
33
+ import { readTailEvents } from './log-tail.js'
34
+ import {
35
+ parseSlashLine, describeCommands, commandRow, commandPatch,
36
+ invalidCommandMessage, unknownCommandMessage,
37
+ } from './commands.js'
38
+ import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
28
39
  import { sendExternalNotification } from './notify.js'
29
40
  import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
30
41
  import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
42
+ import { serveState, serveEnable, serveDisable } from './serve.js'
31
43
  import { ensureCloudflared } from './cloudflared.js'
32
44
  import { startTunnel, TUNNEL_HINT } from './tunnel.js'
33
- import { listWorkspaces, listSessionsOf } from './tree.js'
45
+ import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt, sessionScale } from './tree.js'
46
+ import { listRoots, listDirectory, makeDirectory } from './browse.js'
47
+ import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
34
48
  import { buildId } from './build.js'
35
49
 
36
50
  export const name = 'dsh-mini-remote'
37
- export const inject = ['agents']
51
+ // `sessionController` 是 DSH 的宿主会话业务接口(`@deepseek-ai/dsh-api-session-controller`
52
+ // 挂上 Context 的那个),手机端要改每个会话的模型和思考强度就得靠它。
53
+ //
54
+ // **这一行单独加是有意的**:注入清单里写错名字不会报错,它会安静地让整个插件不加载——
55
+ // 那就不是一行功能坏了,是整个遥控器起不来。所以先只加这行、重启,确认插件还活着,
56
+ // 再往上加功能。真出问题也一眼知道是哪儿。
57
+ export const inject = ['agents', 'sessionController']
38
58
 
39
59
  /** 公网隧道起不来时重试几次——向 Cloudflare 注册会偶发超时,实测大约一半概率。 */
40
60
  const TUNNEL_ATTEMPTS = 3
@@ -93,6 +113,60 @@ const DEFAULTS = {
93
113
  },
94
114
  }
95
115
 
116
+ /**
117
+ * 给 schema 标上 volatile("改了不用重载")。
118
+ *
119
+ * 为什么要绕这一层判断:`.volatile()` 是 schemastery **3.18.3** 才加的方法,3.18.1/3.18.2
120
+ * 都没有。DSH 0.1.5 声明的依赖范围是 `^3.18.2`,是可以落到没有它的那一版上的——那种环境下
121
+ * 直接写 `.volatile()` 会在**加载插件的那一刻**抛异常,整个插件起不来,手机上什么都没有。
122
+ * 这个插件一贯的做法是"少了哪样就关掉哪一块,不整个倒下",所以这里降级处理:没有这个方法
123
+ * 就原样返回,插件照常可用,只是少了"改配置不用重载"这一条(`liveConfig` 那边也会因此
124
+ * 认不出 volatile 字段,读到的就是普通值,行为与从前一致)。
125
+ */
126
+ export function markVolatile(schema) {
127
+ return typeof schema?.volatile === 'function' ? schema.volatile() : schema
128
+ }
129
+
130
+ /**
131
+ * 插件设置的标准声明(DSH 0.1.7 起的做法)。宿主拿它校验 profile 补丁给我们的 config,
132
+ * 设置页那条编辑通道也按它生成表单——**只有标了 volatile 的字段才会出现在那张表单里**。
133
+ *
134
+ * **为什么一个 .default() 都没有**:schema 里的默认值会被 Loader 落进 config,而我们的
135
+ * 合并顺序是 `DEFAULTS < settings.json < config`——那样一来 schema 默认值就会盖住用户存在
136
+ * settings.json 里的值。留空即 undefined,deepMerge 会跳过 undefined,内置默认值照旧只在
137
+ * 下面 DEFAULTS 写一处。
138
+ *
139
+ * **为什么只有两个字段标 .volatile()**:标了就是承诺「改它不用重载插件」,而承诺得在代码里
140
+ * 兑现——这类字段必须用的时候现读(见 liveConfig)。端口和绑定地址要重新监听、隧道要重建、
141
+ * 口令的真相在 token 文件和手机页面的改密入口,这些改完确实要重建插件,就不标:DSH 会替我们
142
+ * 重载,那才是实话。宁可少标,不能乱标。
143
+ */
144
+ export const Config = z.object({
145
+ port: z.number().description('手机上打开的那个端口。改了要重载插件(得重新监听)。'),
146
+ bindAddress: z.string().description("监听哪个地址:'auto'、'127.0.0.1' 或具体某个 IP。改了要重载插件。"),
147
+ tunnel: z.object({
148
+ enabled: z.boolean(),
149
+ cloudflaredPath: z.string(),
150
+ }).description('公网隧道(cloudflared)。改了要重载插件。'),
151
+ token: z.string().role('secret')
152
+ .description('访问口令。留空 = 首次启动自动生成;改口令用手机页面上的改密入口,改完要重载插件。'),
153
+ maxHistory: z.number().description('手机端每个会话保留多少条历史。改了要重载插件。'),
154
+ defaultMode: markVolatile(z.string())
155
+ .description("手机打开时默认进哪个模式:'minimal' 或 'chat'。改了立即生效,不用重载。"),
156
+ notify: markVolatile(z.object({
157
+ enabled: z.boolean(),
158
+ channel: z.string(),
159
+ barkUrl: z.string(),
160
+ ntfyUrl: z.string(),
161
+ ntfyTopic: z.string(),
162
+ telegramBotToken: z.string().role('secret'),
163
+ telegramChatId: z.string(),
164
+ onlyWhenBackground: z.boolean(),
165
+ maxLength: z.number(),
166
+ sound: z.string(),
167
+ })).description('外部推送(Bark / ntfy / Telegram)。改了立即生效,不用重载。'),
168
+ })
169
+
96
170
  function deepMerge(base, ...overrides) {
97
171
  const out = { ...base }
98
172
  for (const patch of overrides) {
@@ -163,13 +237,44 @@ function userMessage(text, files = []) {
163
237
  })
164
238
  }
165
239
 
240
+ /**
241
+ * 把标了 volatile 的配置字段接到 settings 上:读的时候现取,不在加载时抄一份。
242
+ *
243
+ * 为什么必须现取:DSH 对 volatile 字段的处理是「就地提交、不重建插件」——它只把新值写进那个
244
+ * 引用里,再 emit 一次 loader/volatile-update。要是我们加载时抄了一份,就地更新就永远看不见,
245
+ * 那这个声明比不声明更糟:DSH 不再重载我们,而我们手里还是旧值。
246
+ *
247
+ * 取值仍沿用原来的合并顺序:config 里显式写了就用 config(undefined 算没写),没写就用
248
+ * settings.json / 内置默认值那一路(`localBase` 那一份,不能拿合并了 config 的那份,
249
+ * 否则会把 volatile 的引用对象自己也当成"本地值")。
250
+ */
251
+ function liveConfig(settings, config, localBase) {
252
+ for (const [key, ref] of Object.entries(config ?? {})) {
253
+ if (!Config.dict?.[key]?.meta?.volatile) continue
254
+ if (!ref || typeof ref.get !== 'function') continue
255
+ let local = localBase?.[key]
256
+ Object.defineProperty(settings, key, {
257
+ enumerable: true,
258
+ configurable: true,
259
+ get: () => (ref.get() === undefined ? local : ref.get()),
260
+ set: (value) => { local = value },
261
+ })
262
+ }
263
+ }
264
+
166
265
  export function apply(ctx, config = {}) {
167
266
  const dir = dataDir()
168
267
  mkdirSync(dir, { recursive: true })
169
268
 
170
269
  const settingsFile = join(dir, 'settings.json')
171
270
  const stored = readJson(settingsFile)
172
- const settings = deepMerge(DEFAULTS, stored ?? {}, config)
271
+ // 两份来源分清楚:`local` 是"我们这份"(内置默认 + settings.json),`settings` 再叠上
272
+ // profile 补丁给的 config。volatile 字段要拿 `local` 当兜底,所以基线单独留一份。
273
+ const local = deepMerge(DEFAULTS, stored ?? {})
274
+ const settings = deepMerge(local, config)
275
+ // 标了 volatile 的字段改成"读的时候现取"(见 liveConfig):这类字段改了不用重载插件。
276
+ liveConfig(settings, config, local)
277
+
173
278
  // 首次启动把默认配置落一份出来,用户才有东西可以改。
174
279
  if (stored === null) {
175
280
  writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`)
@@ -224,6 +329,13 @@ export function apply(ctx, config = {}) {
224
329
  /** DSH 的附件服务;纯 headless 组合里没有它,所以是可选的。 */
225
330
  let attachments = null
226
331
 
332
+ /**
333
+ * DSH 的斜杠指令账本(`ctx.commands`)。同样是可选的:DSH 明说无界面的组合
334
+ * 不提供这个面(headless / ACP 自动化),所以拿不到时就如实说「这台电脑上的
335
+ * DSH 没提供指令」,而不是给手机一个空名单。
336
+ */
337
+ let commandService = null
338
+
227
339
  /**
228
340
  * 正在流出来的那一段。
229
341
  *
@@ -714,6 +826,10 @@ export function apply(ctx, config = {}) {
714
826
  if (session.header?.origin === 'subagent') return
715
827
  const sessionId = session.id
716
828
 
829
+ // 手机正在看的这个会话:顺手让它的完整历史保持新鲜(一分钟最多重读一次)。
830
+ // 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
831
+ if (sessionId === targetSessionId()) ensureHistory(sessionId)
832
+
717
833
  // 会话标题(可选事件,拿到就用,拿不到就用 id 前 8 位兜底)
718
834
  if (event.type === 'session/title') {
719
835
  const title = event.data?.title ?? event.data?.text
@@ -723,8 +839,88 @@ export function apply(ctx, config = {}) {
723
839
  return
724
840
  }
725
841
 
842
+ // 这个会话现在用的是哪个模型、哪一档思考强度。
843
+ //
844
+ // **来源就是这儿**:DSH 每次发请求前都会广播一份 header,里面带着
845
+ // `config: { provider, model, reasoningEffort? }`——和 DSH 接口定义里那个
846
+ // `ModelSelection` 是同一个形状(见 tasks/todo.md 第 32 轮的侦察结论)。
847
+ // 所以读当前值不用另找接口,跟着事件走就行。
848
+ //
849
+ // 记下来给手机端显示用(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
850
+ // 改完之后 DSH 会再发一份新的 header,这里跟着刷新,两边不会走散。
851
+ if (event.type === 'request/header') {
852
+ const cfg = event.data?.header?.config
853
+ if (cfg && typeof cfg.model === 'string') {
854
+ store.touchSession(sessionId, {
855
+ provider: typeof cfg.provider === 'string' ? cfg.provider : null,
856
+ model: cfg.model,
857
+ // 这一项可能是空的:那表示「用 provider 自己的默认」,不是出错
858
+ reasoningEffort: typeof cfg.reasoningEffort === 'string' ? cfg.reasoningEffort : null,
859
+ })
860
+ }
861
+ return
862
+ }
863
+
864
+ // 斜杠指令:它**不是一轮对话**,上面那个提取器不认它(会走到 `if (!action) return`
865
+ // 直接丢掉),所以得在这儿单独接住。
866
+ //
867
+ // 为什么值得单独一条路:指令不产生模型消息(DSH 的契约),所以它既不该长成用户
868
+ // 气泡,也不该被单帧模式当成「最新回复」。它是第三种东西——手机上画成一条紧凑的
869
+ // 「指令」行,和电脑端画在正文里是同一个意思。
870
+ //
871
+ // 开始(command/run)和收尾(command/done)靠 commandId 配对。配对逻辑和
872
+ // 「重放硬盘日志」那条路共用 lib/commands.js 里的映射,两边不会走散。
873
+ if (event.type === 'command/run' || event.type === 'command/done') {
874
+ // 手机还没绑会话时,谁先有动静就绑谁(和下面那条同一套口径)。
875
+ if (!store.state.boundSessionId) store.bind(sessionId)
876
+ if (sessionId !== targetSessionId()) return
877
+ if (event.type === 'command/run') {
878
+ const row = commandRow(event)
879
+ if (row) store.pushCommand({ ...row, sessionId })
880
+ } else {
881
+ const patch = commandPatch(event)
882
+ if (patch) store.settleCommand({ sessionId, ...patch })
883
+ }
884
+ store.touchSession(sessionId)
885
+ server?.broadcast('state', store.snapshot())
886
+ return
887
+ }
888
+
889
+ /**
890
+ * 一轮跑完,台词就作废。
891
+ *
892
+ * 不广播也安全:这一轮结束必然跟着一条回复或一条状态推送,手机自己会重画;
893
+ * 而页面那边还认着「这句我念过了」(比 at),所以旧的残留不会诈尸。
894
+ */
895
+ if (event.type === 'turn/end' && sessionId === targetSessionId()) {
896
+ store.setThought(sessionId, null)
897
+ }
898
+
726
899
  const action = tracker.feed(sessionId, event)
727
- if (!action) return
900
+ if (!action) {
901
+ /**
902
+ * 到这里还不是「指令」也不是「回答」,但可能是别的会话都不管的第三种东西:
903
+ * 中间步骤的自言自语(鲸鱼娘的台词)。
904
+ *
905
+ * **必须在这儿接,不能等到下面。** 下面第一件事就是 `if (sessionId !==
906
+ * targetSessionId()) return`——那是给「会震动手机的内容」定的规矩,而台词也一样:
907
+ * 别的会话在想什么,跟手机正遥控的这个会话无关。
908
+ *
909
+ * 它取的是中间步骤的旁白或思考首行,**不落盘、不进历史**,所以回答区的口径
910
+ * (latest/history)一个字都不会因此改变——2026-09-22 用户否掉的是「过程的文字
911
+ * 跑进回答区」,不是「她说了句话」。
912
+ */
913
+ if (event.type === 'assistant/message') {
914
+ if (sessionId !== targetSessionId()) return
915
+ const talk = stepSelfTalk(event.data?.message?.content)
916
+ // 没话可说时**什么都不做**:留着上一句,比让气泡闪一下空要好。
917
+ if (!talk) return
918
+ store.setThought(sessionId, { text: talk.text, at: event.time ?? Date.now() })
919
+ store.touchSession(sessionId)
920
+ server?.broadcast('state', store.snapshot())
921
+ }
922
+ return
923
+ }
728
924
  store.touchSession(sessionId)
729
925
 
730
926
  // 手机还没绑会话时,谁先有动静就绑谁。
@@ -749,7 +945,9 @@ export function apply(ctx, config = {}) {
749
945
  }
750
946
  // 电脑上自己敲的指令也记一笔,聊天模式才完整。
751
947
  // 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
752
- store.pushUser({ text: action.text, sessionId, id: action.id })
948
+ // 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
949
+ // 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
950
+ store.pushUser({ text: action.text, sessionId, id: action.id, timestamp: event.time })
753
951
  server?.broadcast('state', store.snapshot())
754
952
  return
755
953
  }
@@ -757,6 +955,8 @@ export function apply(ctx, config = {}) {
757
955
  // action.kind === 'reply'
758
956
  store.pushReply({
759
957
  text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
958
+ // 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
959
+ timestamp: event.time,
760
960
  })
761
961
  server?.broadcast('reply', {
762
962
  text: action.text,
@@ -901,6 +1101,289 @@ export function apply(ctx, config = {}) {
901
1101
  + Boolean(treeServices.registry) + ' sessionQuery=' + Boolean(treeServices.query))
902
1102
  })
903
1103
 
1104
+ // ------------------------------------------------------------------
1105
+ // 点开一个会话,就把它**完整的历史**读出来(2026-09-25 用户要求)
1106
+ //
1107
+ // 为什么不能只靠手里那份记录:手机能点开全部会话(一个工作区常有上百条),
1108
+ // 而插件手里那份只有「它在场时看见过的事件」——重启前跑过的、在电脑上开的,
1109
+ // 点进去是空的。用户要的是:点开就该看到它之前的每一轮。
1110
+ //
1111
+ // 数据来源是 DSH 自己的会话记录(`sessionQuery.readSession`)。它有个很好的性质:
1112
+ // **会话活着就从内存给(不读盘),睡着才去读它的日志**,而且日志是完整的
1113
+ // (多帧 zstd 由 DSH 自己解)。所以对正在跑的那个会话,这条路几乎不要钱。
1114
+ //
1115
+ // 读出来的是原始事件,用 lib/history.js 重放成聊天记录——**和实时那条路
1116
+ // 共用同一个提取器**,两条路对「哪条才算最终回答」的判断不会走散。
1117
+ // ------------------------------------------------------------------
1118
+
1119
+ /** sessionId -> { at, entries, latest }。只活在内存里,不落盘(理由见 store.setReplaySource)。 */
1120
+ const historyCache = new Map()
1121
+ /** 正在读的那些:用户连点几下也只读一遍。 */
1122
+ const historyPending = new Set()
1123
+ /** 多久重读一次。会话跑着的时候历史会变长,一分钟够跟上;睡着的不变,重读也只是白读一次。 */
1124
+ const HISTORY_TTL_MS = 60000
1125
+
1126
+ /**
1127
+ * 整份读的两条红线(2026-09-25 事故后加的护栏)。
1128
+ *
1129
+ * 事故:手机绑定的会话是**带 310 个子会话的父会话**(26MB 压缩 / 2.2 万事件),
1130
+ * 开机预热历史时 `readSession` 触发 v3→v4 迁移,把每个子会话整份解出来,
1131
+ * 默认约 4GB 堆 20 秒 OOM。这两条阈值是「手机上宁可少看一点,也别把 DSH 拖死」
1132
+ * 的取舍,都写在这儿方便调。
1133
+ */
1134
+ const HISTORY_MAX_CHILDREN = 20
1135
+ const HISTORY_MAX_BYTES = 8 * 1024 * 1024
1136
+
1137
+ /**
1138
+ * 手机端读历史的**上限**(2026-09-25 用户要求:读历史要有上限)。
1139
+ *
1140
+ * 上面两条红线决定「要不要绕开内核那条整份读」,这里三条决定
1141
+ * 「自己读的时候最多读多少、最多给手机多少」。三层**都跟会话总量无关**:
1142
+ * - 窗口:只读日志末尾这么多**压缩**字节;
1143
+ * - 事件:窗口里最多留多少条事件(再向前对齐到一条 user 消息,让重放从一轮的开头起);
1144
+ * - 条目:重放成聊天记录后,最多给手机多少条(≈ 轮数 × 2)。
1145
+ */
1146
+ // 窗口按「本机最坏的那条日志」实测选的(26MB / 2.2 万事件):16MB 窗口 → 约 1.2 万事件、
1147
+ // 197 条聊天记录、472ms、堆峰 ~400MB;4MB 窗口只有 29 条(太少)。事件上限只作极端兜底。
1148
+ const HISTORY_TAIL_WINDOW_BYTES = 16 * 1024 * 1024
1149
+ const HISTORY_TAIL_MAX_EVENTS = 20000
1150
+ const HISTORY_MAX_ENTRIES = 200
1151
+
1152
+ /** 去 DSH 的会话记录里读一次,重放成聊天记录。读不出来返回 null。 */
1153
+ async function readHistory(sessionId) {
1154
+ const q = treeServices.query
1155
+ if (typeof q?.readSession !== 'function') return null
1156
+ try {
1157
+ const snap = await q.readSession(sessionId)
1158
+ // 子 agent 的会话不进手机——和实时那条路同一条口径(见 session/event 里那句)。
1159
+ if (snap?.session?.origin === 'subagent') return null
1160
+ const events = Array.isArray(snap?.events) ? snap.events : []
1161
+ const entries = replayHistory(events)
1162
+ const capped = capEntries(entries)
1163
+ return {
1164
+ entries: capped.entries,
1165
+ latest: lastReply(capped.entries, sessionId),
1166
+ truncated: capped.dropped > 0,
1167
+ // 说「最近一段」而不是「最近 200 条」:这条记录读完之后,store 还会把
1168
+ // 「刚发生、还没落进日志」的那一两条实时记录接在后面(见 mergeHistory),
1169
+ // 于是手机上真看到的条数可能比这里多一两条。数字对不上就是假话,
1170
+ // 所以这里只声称**读取的上限**,不声称列表长度。
1171
+ note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
1172
+ }
1173
+ } catch (err) {
1174
+ // 老日志格式对不上、文件坏了,都会走到这儿。退回「手里那份」,**不编**。
1175
+ console.log('[dsh-mini-remote] 这个会话的完整记录没读出来(' + sessionId + '):'
1176
+ + (err?.message ?? err))
1177
+ return null
1178
+ }
1179
+ }
1180
+
1181
+ /** 聊天记录条数上限:从**尾部**留(手机上看的是最近发生的事)。 */
1182
+ function capEntries(entries) {
1183
+ if (!Array.isArray(entries) || entries.length <= HISTORY_MAX_ENTRIES) {
1184
+ return { entries, dropped: 0 }
1185
+ }
1186
+ return { entries: entries.slice(-HISTORY_MAX_ENTRIES), dropped: entries.length - HISTORY_MAX_ENTRIES }
1187
+ }
1188
+
1189
+ /**
1190
+ * 有上限的读法:只解日志**尾部**的若干 zstd 帧,自己重放(见 lib/log-tail.js)。
1191
+ *
1192
+ * 什么时候用它:会话「大」的时候。那条整份读的路会把整份日志连同**所有直接子会话**
1193
+ * 解出来(v3→v4 迁移要它们),代价一旦付出就收不回来——所以宁可少读一点。
1194
+ * 拿不到就返回 null:由调用方如实说「没读到」,**不编内容**。
1195
+ */
1196
+ function readTailHistory(sessionId) {
1197
+ let tail
1198
+ try {
1199
+ tail = readTailEvents(sessionId, {
1200
+ windowBytes: HISTORY_TAIL_WINDOW_BYTES,
1201
+ maxEvents: HISTORY_TAIL_MAX_EVENTS,
1202
+ })
1203
+ } catch (err) {
1204
+ log.warn(`尾部读失败 ${sessionId}:${err?.message ?? err}`)
1205
+ return null
1206
+ }
1207
+ if (!tail) return null
1208
+ const entries = replayHistory(tail.events)
1209
+ const capped = capEntries(entries)
1210
+ if (!capped.entries.length) return null
1211
+ const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
1212
+ return {
1213
+ entries: capped.entries,
1214
+ latest: lastReply(capped.entries, sessionId),
1215
+ // 老实说清「读到的是不是全部」:窗口把整份都盖住了就不该再说「不是全部」。
1216
+ truncated: tail.truncated,
1217
+ note: tail.truncated
1218
+ ? `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`
1219
+ : null,
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * 确保某个会话的完整历史就位。
1225
+ *
1226
+ * 不 await:这是个补数据的后台动作。刚切过去时界面先显示手里那份(或「正在读」),
1227
+ * 读完广播一份新快照,手机上那一串自然就补全了。
1228
+ */
1229
+ async function ensureHistory(sessionId) {
1230
+ if (!sessionId || historyPending.has(sessionId)) return
1231
+ // 服务没到位(headless 组合)就没得读。**不能装作在读**——那会让手机上一直
1232
+ // 显示「正在读取这个会话的记录…」,而其实谁也没去读。
1233
+ if (typeof treeServices.query?.readSession !== 'function') return
1234
+ const hit = historyCache.get(sessionId)
1235
+ if (hit && Date.now() - hit.at < HISTORY_TTL_MS) return
1236
+
1237
+ // 先占坑:手机显示「正在读」靠的就是这个标记,**必须同步到位**——
1238
+ // 不能等下面那次异步量规模回来(否则刚绑定那一下会显示成空会话)。
1239
+ historyPending.add(sessionId)
1240
+
1241
+ // 读之前先量规模:内核那条整份读会把子会话一起解出来,代价收不回来。
1242
+ // 「大」→ 走有上限的尾部读;「小」→ 仍走内核的完整读(日志权威,会话活着还免读盘)。
1243
+ const scale = await sessionScale({ query: treeServices.query, sessionId })
1244
+ const tooBigForWholeRead =
1245
+ scale.children > HISTORY_MAX_CHILDREN || (scale.bytes ?? 0) > HISTORY_MAX_BYTES
1246
+ let got = null
1247
+ try {
1248
+ if (tooBigForWholeRead) {
1249
+ const size = scale.bytes == null ? '未知' : (scale.bytes / 1048576).toFixed(1) + 'MB'
1250
+ log.info(`规模上限:${sessionId} 改走尾部读(子会话 ${scale.children},日志 ${size})`)
1251
+ got = readTailHistory(sessionId)
1252
+ } else {
1253
+ got = await readHistory(sessionId)
1254
+ }
1255
+ } finally {
1256
+ // 读不出来也记一笔(空结果),免得每次点进来都去读一遍坏文件。
1257
+ if (historyCache.size >= 20) historyCache.clear()
1258
+ historyCache.set(sessionId, {
1259
+ at: Date.now(),
1260
+ entries: got?.entries ?? null,
1261
+ latest: got?.latest ?? null,
1262
+ truncated: got?.truncated === true,
1263
+ note:
1264
+ got?.note ??
1265
+ (tooBigForWholeRead ? '这个会话很大,最近这一段也没读出来' : null),
1266
+ })
1267
+ historyPending.delete(sessionId)
1268
+ server?.broadcast('state', store.snapshot())
1269
+ }
1270
+ }
1271
+
1272
+ // store 取「完整历史」的地方(它拿不到 ctx,所以由插件注入一个回调)。
1273
+ store.setReplaySource((sessionId) => {
1274
+ const hit = historyCache.get(sessionId)
1275
+ if (hit) {
1276
+ return {
1277
+ entries: hit.entries,
1278
+ latest: hit.latest,
1279
+ pending: false,
1280
+ // 「这不是全部」要和内容一起给出去:手机得说清楚,不能让人以为
1281
+ // 这个会话只有这么点内容(读历史有上限,见 HISTORY_MAX_* 那组常量)。
1282
+ truncated: hit.truncated === true,
1283
+ note: hit.note ?? null,
1284
+ }
1285
+ }
1286
+ if (historyPending.has(sessionId)) return { entries: null, latest: null, pending: true }
1287
+ return null
1288
+ })
1289
+
1290
+ // ------------------------------------------------------------------
1291
+ // 手机上答题:把电脑这边的提问推到手机,等它答,再把答案交还给电脑。
1292
+ //
1293
+ // 三条要点,都是踩过或想清楚了才这么写的:
1294
+ //
1295
+ // ① **必须 `{ prepend: true }`**。这条链是「遇到第一个愿意接的人就停」,而电脑上的
1296
+ // 浏览器(经由 `dsh-api-remotes`)本来就接这个提问——排在它后面就永远轮不到我。
1297
+ // 实测过:不带 prepend 时,挂根上、挂到 Agent 范围内,**两处都收不到**。
1298
+ //
1299
+ // ② **不接就让路**(`next()`)。手机没连着、问的不是手机上绑着的那个会话、
1300
+ // 提问是空的——任何一种情况都原样让给浏览器,电脑那边照常弹窗,行为不变。
1301
+ // **插队不是为了抢,是为了有得选。**
1302
+ //
1303
+ // ③ **手机没了就把问题还回去**。用户选的是「一直等,不还回电脑」,但那不等于
1304
+ // 「手机都关了他还等」——那种情况下电脑会永远卡住、而且没人提醒他。所以判据
1305
+ // 不是倒计时,是「手机还在不在」。
1306
+ // ------------------------------------------------------------------
1307
+
1308
+ // **注意这个监听器不是 async,这是刻意的,不是漏写。**
1309
+ //
1310
+ // 顾问指出的关键一条(对照 DSH 自己那个转发给远端的监听器,写法完全一致):
1311
+ // **「接不接」必须在同步的那一瞬间定下来,异步只用来产答案。**
1312
+ // 它的形状是:同步判断 → 不接就同步 `return next()` → 接就同步 `return 一个将来会有答案的东西`。
1313
+ //
1314
+ // 我上一版是先 `await` 拿到服务、再判断、再 `return next()`——**让路的动作发生在异步之后**,
1315
+ // 交不回派发的那个链条,于是表现为"完全没接管",而且一句错都不报。
1316
+ //
1317
+ // 另外一条同样是顾问点出来的:**异步函数抛的错,同步的 try/catch 抓不到**(它是被拒绝的
1318
+ // Promise,不是抛出的异常)。所以我之前说"日志里没报错",**根本不能证明没出错**。
1319
+ ctx.on('user-questions/request', function (request, next) {
1320
+ const agentId = request?.agent?.id ?? null
1321
+ const bound = store.snapshot().boundSessionId
1322
+ const questions = Array.isArray(request?.questions) ? request.questions : []
1323
+ // ——— 同步阶段:只做"接不接"的判断,绝不 await ———
1324
+ // 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
1325
+ // **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
1326
+ const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
1327
+ /**
1328
+ * **让路的那三种情况,每一种都留一行。**
1329
+ *
1330
+ * 2026-09-27 用户报「电脑上弹出了选项,手机上什么都没有」。当时这条链上只有
1331
+ * 「答好了」和「出错」两句日志——正好把**让路**这半边全留白了,于是只能靠猜:
1332
+ * 是手机没连、还是问的不是手机上绑的那个会话,日志里看不出来。
1333
+ * 现在每次被叫到都记一行「问的是谁、手机绑的是谁、题数、手机在不在」。
1334
+ * **不记题目正文**——那是用户自己的话。
1335
+ */
1336
+ if (!agentId || agentId !== bound || questions.length === 0) {
1337
+ console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
1338
+ return next()
1339
+ }
1340
+ if (!phoneReady) {
1341
+ console.log('[dsh-mini-remote] 提问让路:手机上此刻没有连接(页面没开、锁屏或断网),交给电脑')
1342
+ return next()
1343
+ }
1344
+ if (typeof miniControl.askPhone !== 'function') {
1345
+ console.log('[dsh-mini-remote] 提问让路:拿不到 askPhone(手机服务还没起来),交给电脑')
1346
+ return next()
1347
+ }
1348
+ // ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
1349
+ console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`)
1350
+ return miniControl.askPhone(questions, agentId).then((answer) => {
1351
+ // 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
1352
+ // 这一句日志是「手机上答了、电脑上没动静」那类现象唯一能落下来的痕迹:
1353
+ // 有它才知道手机那份答案没接上(断线、或者答晚了一步),而不是插件没在跑。
1354
+ if (!answer) {
1355
+ console.log('[dsh-mini-remote] 手机上没答成(连接断了、或者答晚了),这题还给电脑')
1356
+ return next()
1357
+ }
1358
+ console.log('[dsh-mini-remote] 手机上答好了,交给电脑')
1359
+ return answer
1360
+ }).catch((err) => {
1361
+ // 我这边出任何问题都不能把提问吞掉,一律让回电脑。
1362
+ console.log('[dsh-mini-remote] 接管提问出错,让回电脑:' + String(err))
1363
+ return next()
1364
+ })
1365
+ }, { prepend: true })
1366
+
1367
+ // ------------------------------------------------------------------
1368
+ // 手机端「新建会话」要用的服务
1369
+ // ------------------------------------------------------------------
1370
+ // **单独 inject,绝不能并进上面那个数组**:`ctx.inject` 是「等齐了才回调」,
1371
+ // 把一个可能不来的服务并进去,连工作区列表都会一起没了——本来只是少个按钮,
1372
+ // 结果整个导航栏都空掉。分开就互不牵连。
1373
+ //
1374
+ // 拿到的是 `sessionController`(`@deepseek-ai/dsh-api-session-controller` 注册的名字),
1375
+ // 就是网页端新建会话走的那条路。它的 `create({ workspaceId })` 内部会拼好会话、
1376
+ // 装模型、挂 preset,最后交给 `ctx.agents.create()`。
1377
+ //
1378
+ // 这行日志是运行时事实的唯一来源:服务到底来没来、`create` 是不是函数,
1379
+ // 「配置里挂了」只是推断,这里打出来的才算数。
1380
+ const canCreate = { controller: null }
1381
+ ctx.inject(['sessionController'], (createCtx) => {
1382
+ canCreate.controller = createCtx.sessionController
1383
+ console.log('[dsh-mini-remote] 新建会话已就绪:sessionController='
1384
+ + Boolean(canCreate.controller) + ' create=' + typeof canCreate.controller?.create)
1385
+ })
1386
+
904
1387
  // ------------------------------------------------------------------
905
1388
  // 手机上传文件要用的附件服务
906
1389
  // ------------------------------------------------------------------
@@ -911,6 +1394,85 @@ export function apply(ctx, config = {}) {
911
1394
  console.log('[dsh-mini-remote] 附件服务已就绪:可以接收手机上传的文件')
912
1395
  })
913
1396
 
1397
+ // ------------------------------------------------------------------
1398
+ // 斜杠指令账本
1399
+ // ------------------------------------------------------------------
1400
+ // 走 ctx.inject、不写进 inject 数组(理由同上)。**指令列表不属于这个插件**:
1401
+ // 它是 DSH 的账本,别的插件往里注册。手机只是多了一个「能发起指令的界面」——
1402
+ // 所以这里不认识任何一条具体指令,也就不会有「手机上少了一条」的问题。
1403
+ ctx.inject(['commands'], (cmdCtx) => {
1404
+ commandService = cmdCtx.commands
1405
+ console.log('[dsh-mini-remote] 指令服务已就绪:手机上可以执行斜杠指令')
1406
+ })
1407
+
1408
+ /**
1409
+ * 手机要的那份指令名单。
1410
+ *
1411
+ * 三种「拿不到」要分开说,因为下一步动作完全不同:没绑会话 / 会话没在跑 /
1412
+ * 这台电脑没有指令服务。合成一句「取不到指令」,用户不知道该去做什么。
1413
+ */
1414
+ function onCommands() {
1415
+ const sessionId = targetSessionId()
1416
+ if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
1417
+ const agent = ctx.agents.get(sessionId)
1418
+ if (!agent) {
1419
+ return { ok: false, error: '这个会话现在没在跑,指令要先让它跑起来(发一句话)。' }
1420
+ }
1421
+ if (agent.session?.header?.origin === 'subagent') {
1422
+ return { ok: false, error: '这是个子 Agent,手机上用不了它的指令。' }
1423
+ }
1424
+ if (!commandService) return { ok: false, error: '这台电脑上的 DSH 没提供指令。' }
1425
+ try {
1426
+ return { ok: true, commands: describeCommands(commandService.list(agent)) }
1427
+ } catch (err) {
1428
+ return { ok: false, error: `取指令列表失败:${err?.message ?? err}` }
1429
+ }
1430
+ }
1431
+
1432
+ /**
1433
+ * 执行一条斜杠指令。
1434
+ *
1435
+ * **照抄 DSH 自己的语义,不自己发明**:指令行以 `/` 开头、名字是小写字母打头,
1436
+ * 名字后面原样的一段归指令自己解释(我们连那个分隔空格都不动)。满足形状、
1437
+ * 又确实注册过的,才交给 `execute`;其余一律**拒绝并说明**,绝不退回去当普通消息
1438
+ * 发给模型——那正是「打错的指令白跑一轮模型」的来源。
1439
+ *
1440
+ * 这里**不等它跑完**(`execute` 要到处理器结束才 resolve):指令的进度和结果是通过
1441
+ * 会话事件(command/run、command/done)推到手机上的,和电脑端同一份账。
1442
+ * 等它跑完会让这条 HTTP 请求一直挂着——像 /compact 这种要跑十几秒的指令,
1443
+ * 手机上就成了「点了没反应」。
1444
+ *
1445
+ * 附件**这一版递不进去**:`execute` 要的是 DSH 自己那套上传小票,而手机上传走的是
1446
+ * 另一套存储,两者不通(2026-09-25 查证)。所以这里一个附件都不传——输入框里那些
1447
+ * 附件会原样留着,跟下一条普通消息一起发出去,不会丢。
1448
+ */
1449
+ async function onCommand(line) {
1450
+ const sessionId = targetSessionId()
1451
+ if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
1452
+ const agent = ctx.agents.get(sessionId)
1453
+ if (!agent) {
1454
+ return { ok: false, error: '这个会话现在没在跑,指令要先让它跑起来(发一句话)。' }
1455
+ }
1456
+ if (agent.session?.header?.origin === 'subagent') {
1457
+ return { ok: false, error: '这是个子 Agent,手机上用不了它的指令。' }
1458
+ }
1459
+ if (!commandService) return { ok: false, error: '这台电脑上的 DSH 没提供指令。' }
1460
+
1461
+ const parsed = parseSlashLine(line)
1462
+ if (!parsed) return { ok: false, error: invalidCommandMessage() }
1463
+ if (!commandService.find(agent, parsed.name)) {
1464
+ return { ok: false, error: unknownCommandMessage(parsed.name) }
1465
+ }
1466
+
1467
+ // 不等:结果走事件流。但仍要接住异常,否则会变成没人处理的 Promise 拒绝,
1468
+ // 在这台机器上表现为进程级的告警,而手机上什么也看不到。
1469
+ const signal = new AbortController().signal
1470
+ Promise.resolve(commandService.execute(agent, line, [], signal)).catch((err) => {
1471
+ log.warn(`指令 /${parsed.name} 执行异常:${err?.message ?? err}`)
1472
+ })
1473
+ return { ok: true }
1474
+ }
1475
+
914
1476
  /**
915
1477
  * 把手机传来的字节交给 DSH 存,换一张小票回来。
916
1478
  *
@@ -957,9 +1519,36 @@ export function apply(ctx, config = {}) {
957
1519
  return map
958
1520
  }
959
1521
 
1522
+ // 权限档位。跟建会话一样**单独一条 inject**:这个服务依赖 ctx.shell 和
1523
+ // ctx.approval,headless 组合里根本不存在。要是跟别的服务挤在同一条 inject 里,
1524
+ // 它不到场会把那条 inject 上的其它服务一起拖住——本项目在 tree 那三件套上
1525
+ // 踩过这个坑,上面有注释。
1526
+ const presets = { service: null }
1527
+ ctx.inject(['permissionPresets'], (permCtx) => {
1528
+ presets.service = permCtx.permissionPresets
1529
+ console.log('[dsh-mini-remote] 权限档位已就绪:'
1530
+ + (presetsAvailable(presets.service) ? presets.service.names.join(' / ') : '拿不到'))
1531
+ })
1532
+
1533
+ /**
1534
+ * 按 id 取会话对象。
1535
+ *
1536
+ * `PermissionPresetService` 的读和写都收**会话对象**而不是 id
1537
+ * (`current(session)` / `set(session, name)`),所以得先拿 agent 再取它的 session。
1538
+ * 拿不到就回 null——手机那边会显示「暂时读不到」,而不是整页崩掉。
1539
+ */
1540
+ function sessionFor(sessionId) {
1541
+ if (!sessionId) return null
1542
+ try {
1543
+ return ctx.agents?.get(sessionId)?.session ?? null
1544
+ } catch (err) {
1545
+ return null
1546
+ }
1547
+ }
1548
+
960
1549
  const nav = {
961
- listWorkspaces: (runningIds, force) =>
962
- listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force }),
1550
+ listWorkspaces: (runningIds, force, includeEmpty) =>
1551
+ listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
963
1552
  listSessionsOf: (workspaceId, limit, force) =>
964
1553
  listSessionsOf({
965
1554
  registry: treeServices.registry,
@@ -970,6 +1559,118 @@ export function apply(ctx, config = {}) {
970
1559
  limit,
971
1560
  force,
972
1561
  }),
1562
+ // 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
1563
+ // 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
1564
+ createSession: (workspaceId) =>
1565
+ createSessionIn({
1566
+ controller: canCreate.controller ?? ctx.get('sessionController'),
1567
+ registry: treeServices.registry,
1568
+ workspaceId,
1569
+ }),
1570
+ // 「这一刻到底能不能建会话」。给 /mini/api/version 用:那是个不用动手、
1571
+ // 一条命令就能问的运行时事实,比翻日志快。和 createSession 同一条判据,
1572
+ // 免得出现「探测说能、真按了却不行」。
1573
+ canCreateSession: () =>
1574
+ typeof (canCreate.controller ?? ctx.get('sessionController'))?.create === 'function',
1575
+ // 登记工作区。`workspaceRegistry` 就是上面那三个服务里的 registry,
1576
+ // 已经注入到位了,不用再开一条依赖。
1577
+ createWorkspace: (path) =>
1578
+ createWorkspaceAt({ registry: treeServices.registry, path }),
1579
+ // 权限档位。作用域就是传进来的那个会话——手机只绑一个会话,所以
1580
+ // 「当前会话」和「绑定会话」是同一件事,不用另外记状态。
1581
+ permissions: (sessionId) =>
1582
+ listPresets({ service: presets.service, session: sessionFor(sessionId) }),
1583
+ setPermission: (sessionId, name) =>
1584
+ setPreset({ service: presets.service, session: sessionFor(sessionId), name }),
1585
+
1586
+ // 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
1587
+ // `canCreate.controller` 就是 `ctx.sessionController`——和上面 createSession 同一个东西。
1588
+ //
1589
+ // 有个区别要记牢:**目录是全局的,选中的那个是会话级的**。所以列清单只要问一次,
1590
+ // 「现在用哪个」得按会话问。`ModelSelection` 就是 `{ provider, model, reasoningEffort? }`,
1591
+ // 强度不是单独一项设置,它是模型选择的一部分——这也是为什么两个按钮要一起改。
1592
+ //
1593
+ // 拿不到能力时如实说 `unavailable`,让手机整块不显示。**不假装只有一种模型可选**:
1594
+ // 那会让用户以为自己的模型被换掉了。
1595
+ modelCatalog: async () => {
1596
+ const controller = canCreate.controller ?? ctx.get('sessionController')
1597
+ if (typeof controller?.modelCatalog !== 'function') return { ok: false, reason: 'unavailable' }
1598
+ return { ok: true, catalog: await controller.modelCatalog() }
1599
+ },
1600
+ selectModel: async (sessionId, selection) => {
1601
+ const controller = canCreate.controller ?? ctx.get('sessionController')
1602
+ if (typeof controller?.selectModel !== 'function') return { ok: false, reason: 'unavailable' }
1603
+ const value = await controller.selectModel({ sessionId, ...selection })
1604
+ return { ok: true, selected: value?.selected ?? null }
1605
+ },
1606
+ // 某个会话**现在到底用哪个模型**。
1607
+ //
1608
+ // 为什么非得问 DSH、不能自己记:插件只在会话跑起来时才从事件里看到模型
1609
+ // (request/header),没在插件眼皮底下跑过的会话——比如重启之前用过的那些——
1610
+ // 手里就是空的。上一版给空值兜了一个「目录默认值」,结果把一个不知道的会话
1611
+ // 显示成上次选过的那个模型:看着像对的,其实是编的。**宁可说不知道,也不编。**
1612
+ //
1613
+ // 两条路,**第二条是必须的**:
1614
+ // ① 会话正活着 → 它的 `requestHeader()` 直接给结果,走内存,最快。
1615
+ // ② 会话睡在硬盘上 → 去它的日志里翻最后那条 request/header 事件。
1616
+ //
1617
+ // ② 不是备胎,是主路。手机上能挑的是**全部**会话(这个工作区里就有上百个),
1618
+ // 而「活着的」通常只有开着的两三个——只走 ① 的话,用户切到绝大多数会话都只能
1619
+ // 看到「还没问到」。查证过:147 个会话里只有 3 个是活的。
1620
+ //
1621
+ // 用 `sessionQuery.listEvents`:它的说明是「列出原始日志事件」,并且注明
1622
+ // **优先取活着的、没有就读硬盘**——两件事一个方法全包,不用我自己分支。
1623
+ sessionModel: async (sessionId) => {
1624
+ if (!sessionId) return { ok: false, reason: 'no-id' }
1625
+ const shape = (cfg) => ({
1626
+ provider: typeof cfg.provider === 'string' ? cfg.provider : null,
1627
+ model: cfg.model,
1628
+ reasoningEffort: typeof cfg.reasoningEffort === 'string' ? cfg.reasoningEffort : null,
1629
+ })
1630
+
1631
+ // ① 活着的会话:内存里就有
1632
+ const sessions = ctx.get('sessions')
1633
+ const live = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined
1634
+ const liveCfg = live && typeof live.requestHeader === 'function' ? live.requestHeader()?.config : null
1635
+ if (liveCfg && typeof liveCfg.model === 'string') return { ok: true, model: shape(liveCfg) }
1636
+
1637
+ // ② 睡着的会话:读硬盘上的日志,从后往前找最后那条 request/header。
1638
+ //
1639
+ // **必须用 `readSession`,不能用 `listEvents`。** 后者给的是**元数据**
1640
+ // (sessionId / seq / type / time / surface),**没有 `data`**——而配置恰恰在
1641
+ // `data` 里。这个已经试过一次,读出来是空的,白跑一轮。`readSession` 给的是
1642
+ // 完整的原始事件,`data.header.config` 就在里面。
1643
+ //
1644
+ // 读整个日志是贵的,而手机每 15 秒会来问一次,所以缓存 60 秒:桌面那边改了模型,
1645
+ // 最迟一分钟内在手机上出现。手机自己改的那次走另一条路(那条接口会把新值直接
1646
+ // 写进会话记录),不受这个缓存影响。
1647
+ const cache = treeServices.modelCache || (treeServices.modelCache = new Map())
1648
+ const hit = cache.get(sessionId)
1649
+ if (hit && Date.now() - hit.at < 60000) return hit.value
1650
+
1651
+ const q = treeServices.query
1652
+ let value
1653
+ if (typeof q?.readSession !== 'function') {
1654
+ value = { ok: false, reason: 'unavailable' }
1655
+ } else {
1656
+ try {
1657
+ const snap = await q.readSession(sessionId)
1658
+ const events = Array.isArray(snap?.events) ? snap.events : []
1659
+ value = { ok: false, reason: 'no-header' }
1660
+ for (let i = events.length - 1; i >= 0; i--) {
1661
+ const e = events[i]
1662
+ if (!e || e.type !== 'request/header') continue
1663
+ const cfg = e.data?.header?.config
1664
+ if (cfg && typeof cfg.model === 'string') { value = { ok: true, model: shape(cfg) }; break }
1665
+ }
1666
+ } catch (err) {
1667
+ value = { ok: false, reason: 'read-failed', error: String(err && err.message ? err.message : err) }
1668
+ }
1669
+ }
1670
+ if (cache.size > 50) cache.clear()
1671
+ cache.set(sessionId, { at: Date.now(), value })
1672
+ return value
1673
+ },
973
1674
  }
974
1675
 
975
1676
  // ------------------------------------------------------------------
@@ -985,10 +1686,18 @@ export function apply(ctx, config = {}) {
985
1686
  tunnelHost,
986
1687
  log,
987
1688
  onInstruction,
1689
+ onCommands,
1690
+ onCommand,
988
1691
  onUpload,
989
1692
  onStop,
990
1693
  onUnqueue,
1694
+ // 手机点开会话(或刷新页面)时,把那条会话的完整历史读出来。
1695
+ onSessionOpened: (sessionId) => ensureHistory(sessionId),
991
1696
  tree: nav,
1697
+ // 目录浏览自己读盘(理由见 lib/browse.js 开头:DSH 的目录选择器在
1698
+ // 「Windows + 只绑回环」时会判成 native,那套动词是直接拒绝的,
1699
+ // 会在电脑屏幕上弹框——而用户正在外面用手机)。
1700
+ browse: { listRoots, listDirectory, makeDirectory },
992
1701
  build: BUILD,
993
1702
  })
994
1703
  .then((instance) => {
@@ -1014,6 +1723,9 @@ export function apply(ctx, config = {}) {
1014
1723
  }
1015
1724
  // 服务起来了才谈得上开隧道:cloudflared 要指向这个端口
1016
1725
  if (settings.tunnel?.enabled) bringTunnelUp()
1726
+ // 重启前手机在看哪个会话,就先把它的完整历史补上——用户打开手机时
1727
+ // 该看到的是那个会话的全貌,而不是「重启之后才发生的事」。
1728
+ ensureHistory(store.snapshot().boundSessionId)
1017
1729
  })
1018
1730
  .catch((err) => {
1019
1731
  // 记下来。设置页那边要拿它把「还在启动」和「起不来了」分开说——
@@ -1084,6 +1796,18 @@ export function apply(ctx, config = {}) {
1084
1796
  starting: tunnelStarting,
1085
1797
  error: tunnelError,
1086
1798
  },
1799
+ /**
1800
+ * Tailscale 那条路上的 HTTPS 地址。
1801
+ *
1802
+ * **每次刷新都现问一次**,不缓存:这个状态会被别的东西改掉——用户可能在
1803
+ * 命令行里 `tailscale serve --https=443 off`,也可能 Tailscale 自己升级后
1804
+ * 配置没了。缓存一份就会漂移,而漂移的表现是「面板上写着已开启、手机却打不开」,
1805
+ * 正是 2026-09-22 隧道那次踩过的坑。
1806
+ *
1807
+ * 问的是 `server.port`(**实际**在听的那个),不是配置里的 port——
1808
+ * 端口被占时服务会自己往后让,用配置值会指错。
1809
+ */
1810
+ serve: await serveState(server.port),
1087
1811
  })
1088
1812
  return { status: payload.ok ? 200 : 500, body: payload }
1089
1813
  }
@@ -1132,6 +1856,60 @@ export function apply(ctx, config = {}) {
1132
1856
  },
1133
1857
  })
1134
1858
 
1859
+ /**
1860
+ * 开/关 Tailscale 的 HTTPS 地址。
1861
+ *
1862
+ * 和公网那个开关一样**只允许本机调用**(`localOnly`):它会改这台电脑上
1863
+ * Tailscale 的配置,不该让手机或同网段的别人碰到。
1864
+ *
1865
+ * 失败时把 `enableLink` 一起带回去——那是 tailnet 没开 Serve 时 Tailscale
1866
+ * 官方印出来的开启链接。整个功能里门槛最高的就是这一步,把链接原样递给用户,
1867
+ * 他点一下就完事;自己写一句「请到后台开启」等于把门槛加回去。
1868
+ */
1869
+ const offServe = webServer.register({
1870
+ kind: 'exact',
1871
+ path: '/mini-remote/serve',
1872
+ handler: async (req, res) => {
1873
+ if (!localOnly(req, res)) return
1874
+ if (req.method !== 'POST') {
1875
+ sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
1876
+ return
1877
+ }
1878
+ let want
1879
+ try {
1880
+ want = await readBody(req)
1881
+ } catch (err) {
1882
+ sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
1883
+ return
1884
+ }
1885
+ if (!server) {
1886
+ sendJson(res, 503, { ok: false, error: '手机服务还在启动,过几秒再试。' })
1887
+ return
1888
+ }
1889
+ try {
1890
+ const r = want?.enabled ? await serveEnable(server.port) : await serveDisable()
1891
+ if (!r.ok) {
1892
+ sendJson(res, 400, {
1893
+ ok: false,
1894
+ error: r.error,
1895
+ enableLink: r.enableLink ?? null,
1896
+ reason: r.reason ?? 'other',
1897
+ })
1898
+ return
1899
+ }
1900
+ } catch (err) {
1901
+ sendJson(res, 500, { ok: false, error: `切换 HTTPS 地址失败:${err?.message ?? err}` })
1902
+ return
1903
+ }
1904
+ try {
1905
+ const { status, body } = await pairingPayload()
1906
+ sendJson(res, status, body)
1907
+ } catch (err) {
1908
+ sendJson(res, 500, { ok: false, error: `切换之后读不到状态:${err?.message ?? err}` })
1909
+ }
1910
+ },
1911
+ })
1912
+
1135
1913
  /**
1136
1914
  * 密码最短多少位。
1137
1915
  *
@@ -1198,9 +1976,20 @@ export function apply(ctx, config = {}) {
1198
1976
  },
1199
1977
  })
1200
1978
 
1979
+ /**
1980
+ * 卸载时把注册过的路由全部注销。
1981
+ *
1982
+ * **每加一条 `webServer.register` 都要往这里补一笔**,否则插件卸载后那条路由
1983
+ * 还挂在服务器上,指向一个已经死掉的闭包——再有人打过来就是莫名其妙的报错。
1984
+ *
1985
+ * 2026-09-24 补:`offToken` 一直是漏的(改密码那条路由注册了却没注销),
1986
+ * 加 HTTPS 这条时对照着数了一遍才发现。四条注册,四条注销,现在对得上。
1987
+ */
1201
1988
  return () => {
1202
1989
  offGet?.()
1203
1990
  offTunnel?.()
1991
+ offServe?.()
1992
+ offToken?.()
1204
1993
  }
1205
1994
  })
1206
1995
  }