dsh-mini-remote 0.1.7 → 0.1.9

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
@@ -30,6 +30,19 @@ import z from '@deepseek-ai/schemastery'
30
30
  import { createStore } from './store.js'
31
31
  import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
32
32
  import { replayHistory, lastReply } from './history.js'
33
+ import { createTrajectoryTracker, replayTrajectory, capTrajectory } from './trajectory.js'
34
+
35
+ /**
36
+ * Agent 模式(内置四档)的中文词条。**照抄 PC 的词条表**(dsh-client-ui-agent-preset
37
+ * 里的 presetStandardName / presetStandardDescription 等八个键),一个字不改。
38
+ * 自建模式不进这张表——PC 的政策原话是「不翻译用户自造的名字」。
39
+ */
40
+ const AGENT_MODE_COPY = {
41
+ standard: ['标准模式', '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。'],
42
+ ptc: ['PTC 模式', '包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。'],
43
+ minimal: ['极简模式', 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。'],
44
+ cordis: ['创造模式', '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。'],
45
+ }
33
46
  import { readTailEvents, findSessionLog } from './log-tail.js'
34
47
  import {
35
48
  parseSlashLine, describeCommands, commandRow, commandPatch,
@@ -37,6 +50,7 @@ import {
37
50
  } from './commands.js'
38
51
  import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
39
52
  import { sendExternalNotification } from './notify.js'
53
+ import { createFeishuChannel, feishuQuestionText, approvalText, FEISHU_TEXT_LIMIT } from './lark.js'
40
54
  import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
41
55
  import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
42
56
  import { serveState, serveEnable, serveDisable } from './serve.js'
@@ -44,6 +58,8 @@ import { ensureCloudflared } from './cloudflared.js'
44
58
  import { startTunnel, TUNNEL_HINT } from './tunnel.js'
45
59
  import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt } from './tree.js'
46
60
  import { createTitleCache } from './title-cache.js'
61
+ import { createSessionMetaCache } from './session-meta-cache.js'
62
+ import { createSubagentCache } from './subagent-cache.js'
47
63
  import { listRoots, listDirectory, makeDirectory } from './browse.js'
48
64
  import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
49
65
  import { buildId } from './build.js'
@@ -68,6 +84,15 @@ const TUNNEL_ATTEMPTS = 3
68
84
  */
69
85
  const MAX_PENDING_UPLOADS = 20
70
86
 
87
+ /**
88
+ * 飞书里发 `/会话` 最多列几个会话。
89
+ *
90
+ * 10 是「一屏能看完」定的:手机页是把工作区一层层展开看的,飞书里只有一条消息,
91
+ * 列太长要划半天,而这条命令要的只是「切到那一个」。超出的部分如实说还剩多少条
92
+ * (见 lib/lark.js 的 sessionPickText)。
93
+ */
94
+ const FEISHU_PICK_MAX = 10
95
+
71
96
  /**
72
97
  * 这份代码的构建指纹,加载时算一次。
73
98
  * 用来回答「现在应答我的进程,跑的是不是我改的这份代码」——重启期间旧进程还在
@@ -112,6 +137,22 @@ const DEFAULTS = {
112
137
  maxLength: 200,
113
138
  sound: 'default',
114
139
  },
140
+ /**
141
+ * 飞书通路(长连接)。默认关。
142
+ *
143
+ * 为什么默认关:开了之后这台电脑就多了一扇「从飞书进来的门」,该由用户自己决定。
144
+ * 而且**两个名单都空 = 谁都不认**(判据在 lib/lark.js 的 admitSource)——门开着但
145
+ * 名单不填,等于开着没钥匙,这是刻意的默认值。
146
+ */
147
+ feishu: {
148
+ enabled: false,
149
+ appId: '',
150
+ appSecret: '',
151
+ /** 允许的 open_id,一行一个(逗号、分号也行)。空 = 这份名单不设限。 */
152
+ openIds: '',
153
+ /** 允许的 chat_id,同上。两个都空 = 任何来源都不处理。 */
154
+ chatIds: '',
155
+ },
115
156
  }
116
157
 
117
158
  /**
@@ -166,6 +207,23 @@ export const Config = z.object({
166
207
  maxLength: z.number(),
167
208
  sound: z.string(),
168
209
  })).description('外部推送(Bark / ntfy / Telegram)。改了立即生效,不用重载。'),
210
+ /**
211
+ * 飞书这一块**没有**标 volatile(口径见上面那段注释)。
212
+ *
213
+ * 这不是省事:飞书的长连接是用 appId/appSecret 建起来的,改了凭据就得把那条连接
214
+ * 整个换掉——那一步不是「就地改一个值」能兑现的。标了 volatile 等于向 DSH 承诺
215
+ * 「改它不用重载」,而承诺得在代码里兑现。**宁可少标,不能乱标。**
216
+ *
217
+ * 用户实际不会因此多做什么:设置页那一页有自己的保存按钮,存完插件自己就会把连接
218
+ * 换掉(见 restartFeishu)。只有手改 settings.json 的人需要重载一次插件。
219
+ */
220
+ feishu: z.object({
221
+ enabled: z.boolean(),
222
+ appId: z.string(),
223
+ appSecret: z.string().role('secret'),
224
+ openIds: z.string(),
225
+ chatIds: z.string(),
226
+ }).description('飞书(长连接):机器人的两个凭据与两个允许名单。改了要重载插件(凭据变了得重建连接)。'),
169
227
  })
170
228
 
171
229
  function deepMerge(base, ...overrides) {
@@ -291,7 +349,18 @@ export function apply(ctx, config = {}) {
291
349
  * 而这件事**每重启一次就得重付一遍**——标题却是定下来基本不变的东西。
292
350
  */
293
351
  const titles = createTitleCache({ file: join(dir, 'titles.json') })
352
+ const sessionMeta = createSessionMetaCache({ file: join(dir, 'session-meta.json') })
353
+ const subagentCache = createSubagentCache({ file: join(dir, 'subagents.json') })
294
354
  const tracker = createTurnTracker()
355
+ /**
356
+ * 执行轨迹的累计器(「完整」模式要的过程区)。
357
+ *
358
+ * **和上面的 tracker 是两个人**:上面那个只认「用户说的话」和「最终回答」,轨迹那个
359
+ * 认的是思考、工具调用、工具结果。分开的理由是它们服务的是**两种模式**——聊天模式的
360
+ * 回答区里一个字的过程都不该有(2026-09-22 用户亲自否掉的),而完整模式要的就是过程。
361
+ * 两条路共用的是同一批原始事件,不是同一份产出。
362
+ */
363
+ const trajectory = createTrajectoryTracker()
295
364
  // 队列的真相在 agent 的收件箱里,store 拿不到 ctx,所以给它一个回调去问。
296
365
  // 不缓存、每次取快照现读:没有第二份状态,也就不会和电脑上不一致。
297
366
  store.setQueueSource(queuedOf)
@@ -660,6 +729,185 @@ export function apply(ctx, config = {}) {
660
729
  })
661
730
  }
662
731
 
732
+ // ------------------------------------------------------------------
733
+ // 飞书通路(长连接)
734
+ // ------------------------------------------------------------------
735
+ /**
736
+ * 飞书那条路:入站走官方 SDK 的 WebSocket 长连接(不需要公网地址、不需要域名),
737
+ * 出站用「回复某条消息」接口挂回原消息下。实现全在 lib/lark.js,这里只管它的生死。
738
+ */
739
+ /** @type {Awaited<ReturnType<typeof createFeishuChannel>> | null} */
740
+ let feishu = null
741
+ /** 正在建连接;用来挡住「连点两次开关起出两条」。 */
742
+ let feishuStarting = false
743
+ /** 上一次没起来的原因;null = 没出错。和 serverError 一样,用来把「还没连上」和「起不来」分开说。 */
744
+ let feishuError = null
745
+
746
+ async function bringFeishuUp() {
747
+ if (feishu || feishuStarting) return
748
+ const cfg = settings.feishu ?? {}
749
+ if (!cfg.enabled) return
750
+ feishuStarting = true
751
+ feishuError = null
752
+ try {
753
+ feishu = await createFeishuChannel({
754
+ config: cfg,
755
+ log,
756
+ onInstruction,
757
+ // 飞书里回了「同意/1」这类话,交回电脑。走的是手机页同一份清单、同一个 settle
758
+ // (见 lib/server.js 的 answerQuestion / decideApproval),谁先答谁生效。
759
+ onAnswer: answerFromFeishu,
760
+ // `/会话` 要的两件事:列可选会话(标题是手机页同一份缓存)、切到某一个
761
+ // (切的是手机页同一个全局绑定,切完手机页跟着变)。文案全在 lib/lark.js。
762
+ listSessions: feishuSessionList,
763
+ bindSession: bindSessionForFeishu,
764
+ })
765
+ log.info('飞书:通路挂上了,等消息。')
766
+ } catch (err) {
767
+ feishu = null
768
+ feishuError = err?.message ?? String(err)
769
+ log.warn(`飞书没起来:${feishuError}`)
770
+ } finally {
771
+ feishuStarting = false
772
+ }
773
+ }
774
+
775
+ function bringFeishuDown() {
776
+ feishu?.close()
777
+ feishu = null
778
+ feishuError = null
779
+ }
780
+
781
+ /**
782
+ * 换配置就地重建。
783
+ *
784
+ * 长连接是绑在凭据上的,改一处就得整条换掉——所以不是「改个值」而是「关了重开」。
785
+ * 关这一步必须真的断干净(`close()` 里连定时器一起收),否则会留下一条看不见的
786
+ * 旧连接,用旧凭据继续收消息。
787
+ */
788
+ async function restartFeishu() {
789
+ bringFeishuDown()
790
+ await bringFeishuUp()
791
+ }
792
+
793
+ /**
794
+ * 这条会话叫什么。给飞书那行「会话:…」标记用(见 lib/lark.js 的 sessionTagLine)。
795
+ *
796
+ * 来源就是**手机页显示的那个名字**:lib/title-cache.js 那份落盘缓存。取不到就回空串,
797
+ * 标记那边会写成「未命名会话」——**不把会话 id 露出去**,手机上从不显示 id,
798
+ * 用户拿着它没有地方可以对。
799
+ */
800
+ function titleOfSession(sessionId) {
801
+ if (!sessionId) return ''
802
+ const known = titles.get(sessionId)
803
+ return typeof known === 'string' ? known : ''
804
+ }
805
+
806
+ /**
807
+ * 这一轮落定的回答,回给在飞书里发指令的那个人。
808
+ *
809
+ * **归属靠 sessionId**(见 lib/lark.js 的 takeTarget / markRoundStarted):哪条会话
810
+ * 这一轮是从飞书起跑的,回答就回那条飞书消息。`meta.userText` 是这一轮**用户的原话**,
811
+ * 只做兜底比对——它会被同一轮里后来的一句人话顶掉,所以不能当主凭据。
812
+ * 手机发起的轮次在这里天然落空——对不上号就不发,不会把手机上的回答倒进飞书。
813
+ *
814
+ * 顺带把这条会话的名字一起交出去:飞书那边要在回答末尾补一行「会话:…」。
815
+ * 只有飞书这条路拿得到它——手机页那份文本一个字都不变。
816
+ */
817
+ function onReply(text, meta) {
818
+ if (!feishu) return
819
+ // reply() 自己把失败写进日志并返回 { ok:false },不会抛——这里不用再兜一层。
820
+ feishu.reply(text, { ...meta, sessionTitle: titleOfSession(meta?.sessionId) })
821
+ }
822
+
823
+ /**
824
+ * 飞书那边拍完板,把结果交回电脑。
825
+ *
826
+ * **这里不判「这道题还在不在」**:那份清单和 settle 都在 lib/server.js,交回去的时候
827
+ * 它还在不在,由它如实回答(`expired` = 手机先拍了板)。这边只是把飞书的说法翻译成
828
+ * 那个入口收的形状。
829
+ *
830
+ * 末尾那个 `'feishu'` 是**说出来源**,只用来让手机页那张还挂着的卡片知道该收起来、
831
+ * 该写「已在飞书答过」(见 lib/server.js 的 notifySettled)。不是身份判据。
832
+ *
833
+ * @param {{kind: string, id: string, cancel?: boolean, answers?: Array, decision?: string}} reply
834
+ * @returns {{ok: boolean, error?: string}}
835
+ */
836
+ function answerFromFeishu(reply) {
837
+ const take = reply?.kind === 'approval' ? miniControl.decideApproval : miniControl.answerQuestion
838
+ if (typeof take !== 'function') return { ok: false, error: 'no-channel' }
839
+ const input = reply?.cancel === true
840
+ ? { cancel: true }
841
+ : (reply?.kind === 'approval' ? { decision: reply?.decision } : { answers: reply?.answers })
842
+ return take(reply?.id, input, 'feishu')
843
+ }
844
+
845
+ /**
846
+ * `/会话` 要列的那些:这台电脑上的会话,新的在前。
847
+ *
848
+ * 标题走的是**手机页同一份东西**:`nav.listSessionsOf` 拿的正是手机页展开工作区时那份
849
+ * (knownTitles + 落盘缓存 lib/title-cache.js),取不到就是空串,飞书那边写「未命名会话」。
850
+ *
851
+ * 上限是给「一屏能看完」定的,不是给性能定的:工作区一多,全列出来在手机上要划半天。
852
+ * `total` 一起交出去,好在超出时如实说「只列了最近 N 条」。
853
+ */
854
+ async function feishuSessionList() {
855
+ const workspaces = await nav.listWorkspaces(new Set(), false, false)
856
+ const rows = []
857
+ let total = 0
858
+ for (const ws of workspaces) {
859
+ const got = await nav.listSessionsOf(ws.id, FEISHU_PICK_MAX, false)
860
+ if (!got) continue
861
+ total += Number(got.total) || 0
862
+ for (const one of got.sessions ?? []) {
863
+ rows.push({ id: one.id, title: one.title, at: Number(one.createdAt) || 0 })
864
+ }
865
+ }
866
+ // 新的在前——和手机页展开一个工作区时的顺序同一条口径(那边也是按 createdAt 倒序)。
867
+ rows.sort((a, b) => b.at - a.at)
868
+ return {
869
+ rows: rows.slice(0, FEISHU_PICK_MAX).map((one) => ({ id: one.id, title: one.title })),
870
+ total,
871
+ bound: store.snapshot().boundSessionId ?? '',
872
+ }
873
+ }
874
+
875
+ /**
876
+ * 切到那一个会话。
877
+ *
878
+ * **切的是手机页同一个全局绑定**(和 `/mini/api/bind` 那条路逐条一样:bind → 读历史 →
879
+ * 广播一份快照),所以飞书里切完,手机页跟着变。这不是顺手:两边各绑各的会让用户
880
+ * 以为在跟同一个会话说话,而实际上没有。
881
+ */
882
+ function bindSessionForFeishu(sessionId) {
883
+ store.bind(sessionId)
884
+ // 和 /mini/api/bind 那条逐句一样:读这个会话的历史(异步补,读完自己会广播一份快照),
885
+ // 再广播一次当前快照——手机页正开着的话,它当场就跟着切过去了。
886
+ ensureHistory(sessionId)
887
+ server?.broadcast('state', store.snapshot())
888
+ return titleOfSession(sessionId)
889
+ }
890
+
891
+ /** 飞书那一块给设置页看的现状。**绝不包含 appSecret**,只回答「有没有凭据」。 */
892
+ function feishuStatus() {
893
+ const cfg = settings.feishu ?? {}
894
+ const live = feishu?.status?.() ?? null
895
+ return {
896
+ enabled: Boolean(cfg.enabled),
897
+ appId: String(cfg.appId ?? ''),
898
+ openIds: String(cfg.openIds ?? ''),
899
+ chatIds: String(cfg.chatIds ?? ''),
900
+ hasSecret: String(cfg.appSecret ?? '').trim() !== '',
901
+ starting: feishuStarting,
902
+ running: Boolean(feishu),
903
+ connected: Boolean(live?.connected),
904
+ // 上一次被白名单挡住的来源(含 open_id / chat_id 原值)。设置页要把这一行印出来:
905
+ // 名单默认是空的,用户不可能凭空知道自己的 id 长什么样,全靠这一行抄。
906
+ rejected: live?.rejected ?? null,
907
+ error: feishuError,
908
+ }
909
+ }
910
+
663
911
  // ------------------------------------------------------------------
664
912
  // 手机发来的指令 → 注入 DSH
665
913
  // ------------------------------------------------------------------
@@ -963,11 +1211,100 @@ export function apply(ctx, config = {}) {
963
1211
  return win
964
1212
  }
965
1213
 
1214
+ /**
1215
+ * 喂一口执行轨迹,并把这一批的变化推给**正在看这个会话、而且要「完整」模式**的手机。
1216
+ *
1217
+ * 三个决定,都有理由:
1218
+ * ① **落在 store 的内存缓冲里**,不落盘(体积是回答正文的 90.7 倍,落盘那份是每
1219
+ * 500 毫秒重写整份 JSON;理由见 lib/store.js 的 trajectoryBySession)。
1220
+ * ② **只推当前绑定的那个会话**(和 reply 那条同一个口径):手机遥控的是这一个,
1221
+ * 别的会话在想什么跟它无关;那些会话照旧攒在缓冲里,切过去时用接口拉一份。
1222
+ * ③ **推的是增量帧**,不是整份快照(见 lib/server.js 的 broadcastTrajectory)。
1223
+ */
1224
+ function feedTrajectory(sessionId, event) {
1225
+ const change = trajectory.feed(sessionId, event)
1226
+ if (!change) return
1227
+ const frame = store.applyTrajectory(sessionId, change)
1228
+ if (!frame) return
1229
+ // 真条目落盘了:活片段的使命结束(它只是「还没落定」的影子,见 feedTrajectoryLive)。
1230
+ if (change.entries.length || change.state) resetLiveThink(sessionId)
1231
+ if (sessionId !== targetSessionId()) return
1232
+ server?.broadcastTrajectory({ sessionId, ...frame })
1233
+ }
1234
+
1235
+ /**
1236
+ * 轨迹的活片段(2026-10-04 用户解禁「流式不做」,仅完整模式消费,别的模式零影响):
1237
+ * 模型正在想的那一句,从 `agent/assistant-stream` 的 reasoning-delta 里顺出来。
1238
+ *
1239
+ * 边界三条:
1240
+ * ① **只吃思考**(reasoning-delta);回答正文(text-delta)一概不用——回答照旧等
1241
+ * 落定才显示,「流式不做」那条裁决只对轨迹的思考片段解禁;
1242
+ * ② **只推当前绑定的那个会话**,子 Agent 的流不过来(和轨迹增量帧同一个口径);
1243
+ * ③ **落盘即清、打断即清**:真条目一到就让位。它是进程本地的,不重放——手机刷新
1244
+ * 后没了就没了,等下一步的片段接着来,不补假的。
1245
+ *
1246
+ * 节流:模型一秒能吐几十个字,250 毫秒一发、尾随必发;第一段立刻发——
1247
+ * 「有没有动静」这件事最要紧的就是第一眼。
1248
+ */
1249
+ const liveThink = new Map()
1250
+ const LIVE_THINK_MS = 250
1251
+
1252
+ /** 「当前这一段」的尾句:压成一行、限 160 字——给人瞄一眼的,不是给人读的。 */
1253
+ function liveThinkDetail(text) {
1254
+ const paragraphs = String(text).split(/\r?\n[\t ]*\r?\n/)
1255
+ const last = paragraphs[paragraphs.length - 1].replace(/\s+/g, ' ').trim()
1256
+ return last.length > 160 ? `${last.slice(0, 159)}…` : last
1257
+ }
1258
+
1259
+ function resetLiveThink(sessionId) {
1260
+ const slot = liveThink.get(sessionId)
1261
+ if (!slot) return
1262
+ if (slot.timer) clearTimeout(slot.timer)
1263
+ liveThink.delete(sessionId)
1264
+ // 只在真露过字之后才喊「清空」——不然每来一次工具调用都白发一帧。
1265
+ if (slot.sent) server?.broadcastTrajectoryLive({ sessionId, detail: '' })
1266
+ }
1267
+
1268
+ function feedTrajectoryLive(sessionId, frame) {
1269
+ if (sessionId !== targetSessionId()) return
1270
+ if (!frame || frame.type !== 'chunk') { resetLiveThink(sessionId); return }
1271
+ const chunk = frame.chunk ?? {}
1272
+ if (chunk.type === 'reasoning-delta' && chunk.text) {
1273
+ let slot = liveThink.get(sessionId)
1274
+ if (!slot) {
1275
+ slot = { text: '', sent: false, at: 0, timer: null }
1276
+ liveThink.set(sessionId, slot)
1277
+ }
1278
+ slot.text += chunk.text
1279
+ const detail = liveThinkDetail(slot.text)
1280
+ const flush = () => {
1281
+ slot.timer = null
1282
+ slot.at = Date.now()
1283
+ slot.sent = true
1284
+ server?.broadcastTrajectoryLive({ sessionId, detail })
1285
+ }
1286
+ if (!slot.sent || Date.now() - slot.at >= LIVE_THINK_MS) {
1287
+ if (slot.timer) { clearTimeout(slot.timer); slot.timer = null }
1288
+ flush()
1289
+ } else if (!slot.timer) {
1290
+ slot.timer = setTimeout(flush, LIVE_THINK_MS - (Date.now() - slot.at))
1291
+ if (slot.timer.unref) slot.timer.unref()
1292
+ }
1293
+ return
1294
+ }
1295
+ // 出现工具调用 = 这一步走到工具了,思考片段到此为止;text-delta 是回答正文,一概不用。
1296
+ if (chunk.type === 'tool-call-delta') resetLiveThink(sessionId)
1297
+ }
1298
+
966
1299
  ctx.on('session/event', (session, event) => {
967
1300
  // 子 agent 的会话不进手机——用户遥控的是自己的主会话,不是它派出去的小弟。
968
1301
  if (session.header?.origin === 'subagent') return
969
1302
  const sessionId = session.id
970
1303
 
1304
+ // 执行轨迹:**在所有早退之前**喂一口。下面 `tool/call` 那条分支取完命令原文就 return
1305
+ // 了(审批卡要用),而工具调用正是轨迹的主干——放在这一句之前,它才不会漏掉。
1306
+ feedTrajectory(sessionId, event)
1307
+
971
1308
  // 手机正在看的这个会话:顺手让它的完整历史保持新鲜(一分钟最多重读一次)。
972
1309
  // 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
973
1310
  if (sessionId === targetSessionId()) ensureHistory(sessionId)
@@ -1105,6 +1442,22 @@ export function apply(ctx, config = {}) {
1105
1442
  // 状态必须是同一套口径,否则刷新一下就看到别人的结果了。
1106
1443
  if (sessionId !== targetSessionId()) return
1107
1444
 
1445
+ if (action.kind === 'notice') {
1446
+ // 子智能体完工的通知。记进聊天记录,**但不震动、不弹通知**——它是「你派出去的
1447
+ // 那件事有结果了」,和「模型答了你一句」不是同一件事。可它确实是这个会话里
1448
+ // 发生的一件事,聊天记录里不能缺(原来就是被 user/message 那道过滤一起挡掉的)。
1449
+ store.pushNotice({
1450
+ text: action.text,
1451
+ summary: action.summary,
1452
+ senderSessionId: action.senderSessionId,
1453
+ sessionId,
1454
+ turn: action.turn,
1455
+ timestamp: event.time,
1456
+ })
1457
+ server?.broadcast('state', store.snapshot())
1458
+ return
1459
+ }
1460
+
1108
1461
  if (action.kind === 'user') {
1109
1462
  // 手机自己发的那条:onInstruction 里已经记过一笔了,这里跳过。
1110
1463
  // 不跳的话聊天模式里同一条指令会显示两次(用户实机报过)。
@@ -1121,7 +1474,7 @@ export function apply(ctx, config = {}) {
1121
1474
  // 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
1122
1475
  // 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
1123
1476
  // 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
1124
- store.pushUser({ text: action.text, sessionId, id: action.id, timestamp: event.time })
1477
+ store.pushUser({ text: action.text, sessionId, id: action.id, turn: action.turn, timestamp: event.time })
1125
1478
  server?.broadcast('state', store.snapshot())
1126
1479
  return
1127
1480
  }
@@ -1129,6 +1482,7 @@ export function apply(ctx, config = {}) {
1129
1482
  // action.kind === 'reply'
1130
1483
  store.pushReply({
1131
1484
  text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
1485
+ turn: action.turn,
1132
1486
  // 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
1133
1487
  timestamp: event.time,
1134
1488
  })
@@ -1141,13 +1495,28 @@ export function apply(ctx, config = {}) {
1141
1495
  timestamp: Date.now(),
1142
1496
  })
1143
1497
  maybeNotify(action.text)
1498
+ // 飞书那条路:这一轮落定的回答,回给在飞书里发指令的那个人(见 onReply)。
1499
+ // `sessionId` 既是**归属**(飞书那边靠它认出发起这一轮的那条消息),也要拿去查会话名;
1500
+ // `userText` 是 events.js 取好的「这一轮用户的原话」,只做兜底比对。
1501
+ onReply(action.text, { userText: action.userText, sessionId })
1144
1502
  })
1145
1503
 
1146
1504
  ctx.on('agent/status', ({ agent, status }) => {
1147
1505
  const sessionId = agent?.session?.id
1148
1506
  if (!sessionId) return
1149
- if (agent.session.header?.origin === 'subagent') return
1507
+ if (agent.session.header?.origin === 'subagent') {
1508
+ const parent = agent.session.header?.parentSession
1509
+ if (parent) {
1510
+ subagentCache.delete(parent)
1511
+ store.setSubagentRunning(parent, sessionId, status === 'running')
1512
+ server?.invalidateSubagents?.(parent)
1513
+ server?.broadcast('state', store.snapshot())
1514
+ }
1515
+ return
1516
+ }
1150
1517
  store.setRunning(sessionId, status === 'running')
1518
+ server?.invalidateNavigation?.()
1519
+ server?.broadcast('navigation-refresh', { reason: 'session-status', sessionId })
1151
1520
  broadcastStatus()
1152
1521
  })
1153
1522
 
@@ -1164,15 +1533,20 @@ export function apply(ctx, config = {}) {
1164
1533
  * 子 Agent 的流不往手机上推。
1165
1534
  */
1166
1535
  ctx.on('agent/assistant-stream', ({ agent, frame }) => {
1167
- // 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
1168
- // 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
1169
- if (!STREAMING_ENABLED) return
1170
-
1171
1536
  const session = agent?.session
1172
1537
  const sessionId = session?.id
1173
1538
  if (!sessionId) return
1174
1539
  if (session.header?.origin === 'subagent') return
1175
1540
 
1541
+ // 轨迹的活片段:完整模式要「正在想的片段一点点顺出来」(2026-10-04 用户解禁,
1542
+ // 只解禁这一处)。它跑在下面那道闸门**之前**——回答的流式照旧关着,两者互不相干:
1543
+ // 这里吃的是 reasoning-delta,text-delta 一个字都不碰。
1544
+ feedTrajectoryLive(sessionId, frame)
1545
+
1546
+ // 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
1547
+ // 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
1548
+ if (!STREAMING_ENABLED) return
1549
+
1176
1550
  // 只报一次:这条日志回答的是「事件到底有没有送到插件」。一条都没有,就说明
1177
1551
  // 问题在订阅那一层,不用再往下查。
1178
1552
  if (!sawStreamEvent) {
@@ -1242,17 +1616,45 @@ export function apply(ctx, config = {}) {
1242
1616
  // 会话一建出来就登记,这样手机上的会话选择器不用等到有活动才出现。
1243
1617
  ctx.on('agent/created', ({ agent }) => {
1244
1618
  const sessionId = agent?.session?.id
1245
- if (!sessionId || agent.session.header?.origin === 'subagent') return
1619
+ if (!sessionId) return
1620
+ if (agent.session.header?.origin === 'subagent') {
1621
+ const parent = agent.session.header?.parentSession
1622
+ if (parent) {
1623
+ subagentCache.delete(parent)
1624
+ store.setSubagentRunning(parent, sessionId, agent.status === 'running')
1625
+ server?.invalidateSubagents?.(parent)
1626
+ server?.broadcast('state', store.snapshot())
1627
+ }
1628
+ return
1629
+ }
1630
+ sessionMeta.upsert({ header: agent.session.header, live: true, persisted: false })
1246
1631
  store.touchSession(sessionId, {
1247
1632
  running: agent.status === 'running',
1248
1633
  })
1634
+ server?.invalidateNavigation?.()
1635
+ server?.broadcast('navigation-refresh', { reason: 'session-created' })
1249
1636
  server?.broadcast('state', store.snapshot())
1250
1637
  })
1251
1638
 
1252
1639
  ctx.on('agent/disposed', ({ agent }) => {
1253
1640
  const sessionId = agent?.session?.id
1254
1641
  if (!sessionId) return
1642
+ if (agent.session.header?.origin === 'subagent') {
1643
+ const parent = agent.session.header?.parentSession
1644
+ if (parent) {
1645
+ subagentCache.delete(parent)
1646
+ store.setSubagentRunning(parent, sessionId, false)
1647
+ server?.invalidateSubagents?.(parent)
1648
+ server?.broadcast('state', store.snapshot())
1649
+ }
1650
+ return
1651
+ }
1652
+ sessionMeta.upsert({ header: agent.session.header, live: false, persisted: true })
1255
1653
  store.forgetSession(sessionId)
1654
+ // 轨迹那个累计器也清掉(它有自己的一份 per-session 状态)。
1655
+ trajectory.forget(sessionId)
1656
+ server?.invalidateNavigation?.()
1657
+ server?.broadcast('navigation-refresh', { reason: 'session-disposed' })
1256
1658
  server?.broadcast('state', store.snapshot())
1257
1659
  })
1258
1660
 
@@ -1335,6 +1737,8 @@ export function apply(ctx, config = {}) {
1335
1737
  const events = Array.isArray(snap?.events) ? snap.events : []
1336
1738
  const entries = replayHistory(events)
1337
1739
  const capped = capEntries(entries)
1740
+ // 轨迹和聊天记录是**同一批事件的两面**,顺手一起重放出来;不这样就得为它再读一遍盘。
1741
+ const traj = capTrajectory(replayTrajectory(events))
1338
1742
  return {
1339
1743
  entries: capped.entries,
1340
1744
  latest: lastReply(capped.entries, sessionId),
@@ -1344,6 +1748,10 @@ export function apply(ctx, config = {}) {
1344
1748
  // 于是手机上真看到的条数可能比这里多一两条。数字对不上就是假话,
1345
1749
  // 所以这里只声称**读取的上限**,不声称列表长度。
1346
1750
  note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
1751
+ // 轨迹在**插件这一层就按自己的上限截好**再进缓存:不截的话,20 个会话的缓存会
1752
+ // 各压着几兆(见 lib/store.js 的 setReplaySource 那段说明)。
1753
+ trajectory: traj.turns,
1754
+ trajectoryDropped: traj.dropped,
1347
1755
  }
1348
1756
  } catch (err) {
1349
1757
  // 老日志格式对不上、文件坏了,都会走到这儿。退回「手里那份」,**不编**。
@@ -1382,6 +1790,7 @@ export function apply(ctx, config = {}) {
1382
1790
  const entries = replayHistory(tail.events)
1383
1791
  const capped = capEntries(entries)
1384
1792
  if (!capped.entries.length) return null
1793
+ const traj = capTrajectory(replayTrajectory(tail.events))
1385
1794
  const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
1386
1795
  // 「不是全部」有两处会砍:readTailEvents 那层(窗口 / 事件上限)和这里 capEntries 那层
1387
1796
  // (聊天记录条数上限)。**两层都要算进去**——小会话的窗口盖得住整份日志,唯一会砍它的
@@ -1398,6 +1807,10 @@ export function apply(ctx, config = {}) {
1398
1807
  ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)`
1399
1808
  : `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`)
1400
1809
  : null,
1810
+ // 轨迹那一份的「不是全部」由 store 自己说(它知道轨迹的上限),这里只把
1811
+ // 「被砍了多少条」交出去——读取窗口那一层的截断在上面 note 里已经说过一次了。
1812
+ trajectory: traj.turns,
1813
+ trajectoryDropped: traj.dropped,
1401
1814
  }
1402
1815
  }
1403
1816
 
@@ -1446,6 +1859,8 @@ export function apply(ctx, config = {}) {
1446
1859
  latest: got?.latest ?? null,
1447
1860
  truncated: got?.truncated === true,
1448
1861
  note: got?.note ?? null,
1862
+ trajectory: got?.trajectory ?? null,
1863
+ trajectoryDropped: got?.trajectoryDropped ?? 0,
1449
1864
  })
1450
1865
  historyPending.delete(sessionId)
1451
1866
  server?.broadcast('state', store.snapshot())
@@ -1464,12 +1879,310 @@ export function apply(ctx, config = {}) {
1464
1879
  // 这个会话只有这么点内容(读历史有上限,见 HISTORY_MAX_* 那组常量)。
1465
1880
  truncated: hit.truncated === true,
1466
1881
  note: hit.note ?? null,
1882
+ // 同一份记录里重放出来的执行轨迹(「完整」模式要的过程区)。
1883
+ // store 会把它和内存里那份合成一张表,见 store.trajectoryOf。
1884
+ trajectory: hit.trajectory ?? null,
1885
+ trajectoryDropped: hit.trajectoryDropped ?? 0,
1467
1886
  }
1468
1887
  }
1469
1888
  if (historyPending.has(sessionId)) return { entries: null, latest: null, pending: true }
1470
1889
  return null
1471
1890
  })
1472
1891
 
1892
+ // ------------------------------------------------------------------
1893
+ // 子智能体(2026-10-03 用户要求:把电脑端「子智能体」搬到手机)
1894
+ //
1895
+ // 和会话树同一套拿法:走 ctx.inject,服务不在就退回「没这个能力」。**不写进
1896
+ // `inject` 数组**——写进去,headless 组合下这个服务永远不来,插件就整个不加载了
1897
+ // (这个坑上面刚踩过,见 inject 那行的注释)。
1898
+ //
1899
+ // 三条路都**在进程内直接调 DSH 的服务**,不经过任何 HTTP / WebSocket:
1900
+ // `subagents` 子代理编排服务(列表、停止、继续)
1901
+ // `sessionProjections` 投影注册表(耗时、用量)
1902
+ // `sessions` 会话存储(不读日志就能拿到 Session 对象)
1903
+ //
1904
+ // 插件和它们同在一个进程里,这是它比桌面前端占便宜的地方:桌面前端只能隔着
1905
+ // 一条 WebSocket 问,而那条线上只开了 `prompt` / `interruptByParent` 两个口子;
1906
+ // 列表和运行记录它反而拿不到,得靠投影另推一份。
1907
+ // ------------------------------------------------------------------
1908
+ const subagentServices = { runtime: null, projections: null, sessions: null }
1909
+ ctx.inject(['subagents'], (subCtx) => {
1910
+ subagentServices.runtime = subCtx.subagents ?? null
1911
+ // 和会话树那句同一个道理:配置里有不等于运行时真的提供了,这一句是唯一的事实来源。
1912
+ console.log('[dsh-mini-remote] 子智能体服务已就绪:' + Boolean(subagentServices.runtime))
1913
+ })
1914
+ ctx.inject(['sessionProjections'], (projCtx) => {
1915
+ subagentServices.projections = projCtx.sessionProjections ?? null
1916
+ })
1917
+ ctx.inject(['sessions'], (sessCtx) => {
1918
+ subagentServices.sessions = sessCtx.sessions ?? null
1919
+ })
1920
+
1921
+ /**
1922
+ * DSH 那几个失败代号 → 一句人话。说不上来的照原样带出去,**不吞也不编**。
1923
+ *
1924
+ * 代号取自 dsh-subagent 的 `SubagentError.code`。头三句是手机上真点得到的三种:
1925
+ * 父会话不在线、同时跑的数量满了、这道子智能体接不了话。
1926
+ */
1927
+ const SUBAGENT_FAILURES = {
1928
+ PARENT_UNAVAILABLE: '它在的那个会话不在线了,这条发不过去。',
1929
+ ACTIVATION_LIMIT_REACHED: '同时能跑的子智能体已经到上限了,等一个跑完再试。',
1930
+ NOT_RESUMABLE: '这道子智能体没有对话可以接,只能看它跑过什么。',
1931
+ UNAUTHORIZED: '这道子智能体不在当前会话名下。',
1932
+ DRAINING: '它正在收尾,这会儿不接受新消息。',
1933
+ ACTIVATION_CLOSING: '它正在停下,等停稳了再试。',
1934
+ CONTINUATION_UNAVAILABLE: '这台电脑上的 DSH 没开「继续子智能体对话」这条路。',
1935
+ PERSISTENCE_UNAVAILABLE: '这台电脑上的 DSH 没开会话记录,接不了话。',
1936
+ CANCELLED: '这条请求已经取消了。',
1937
+ }
1938
+ function subagentFailure(err, what) {
1939
+ const code = err?.code
1940
+ if (typeof code === 'string' && SUBAGENT_FAILURES[code]) return SUBAGENT_FAILURES[code]
1941
+ return `${what}:${err?.message ?? err}`
1942
+ }
1943
+
1944
+ /**
1945
+ * 每一行要的那两个数:耗时、用量。
1946
+ *
1947
+ * 都从 DSH 自己的投影里读(`subagentTiming` / `tokenUsage`),**不自己拿事件累加**——
1948
+ * 自己算出来的数和电脑上那一栏对不上,用户一眼就看得出来。读不到就给 null,
1949
+ * 页面上显示「—」:给 0 会被读成「真的一次都没跑过」,那是另一句话。
1950
+ *
1951
+ * 会话对象从 `sessions.get` 拿:内存里现成的,**不读日志**。老早跑完、进程里
1952
+ * 已经不在的那些拿不到——那就如实空着,不为数字好看去读它整份日志。
1953
+ */
1954
+ function subagentMetrics(childId) {
1955
+ const session = subagentServices.sessions?.get?.(childId)
1956
+ const projections = subagentServices.projections
1957
+ if (!session || typeof projections?.snapshot !== 'function') {
1958
+ return { tokens: null, durationMs: null, lastTurnCompleted: null }
1959
+ }
1960
+ let values
1961
+ try {
1962
+ values = projections.snapshot(session, ['subagentTiming', 'tokenUsage'])?.values
1963
+ } catch (err) {
1964
+ return { tokens: null, durationMs: null, lastTurnCompleted: null }
1965
+ }
1966
+ let durationMs = null
1967
+ // 「最近跑完的那一轮是正常收尾的」——页面上拿它区分绿点(真的干完了)和灰点(
1968
+ // 没在跑,但不知道最后是怎么结束的)。读不到就给 null,别猜成「成功」。
1969
+ let lastTurnCompleted = null
1970
+ const timing = values?.subagentTiming
1971
+ if (timing) {
1972
+ // 跑完的轮次累加在 settledMs;还开着的那一轮按投影记的切面算,**不用 Date.now()**
1973
+ // ——那会读出一个投影没盖到的时间,和电脑上对不上。
1974
+ const settled = Number(timing.settledMs) || 0
1975
+ const active = timing.active
1976
+ ? Math.max(0, (Number(timing.active.through) || 0) - (Number(timing.active.since) || 0))
1977
+ : 0
1978
+ durationMs = settled + active
1979
+ if (typeof timing.lastTurnCompleted === 'boolean') lastTurnCompleted = timing.lastTurnCompleted
1980
+ }
1981
+ let tokens = null
1982
+ const usage = values?.tokenUsage
1983
+ if (usage) {
1984
+ // 四个桶互不重叠(dsh-token-meter 的注释:思考 token 已算在 output 里,不再加一次)。
1985
+ tokens = (Number(usage.uncachedInputTokens) || 0) + (Number(usage.outputTokens) || 0)
1986
+ + (Number(usage.cacheReadTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
1987
+ }
1988
+ return { tokens, durationMs, lastTurnCompleted }
1989
+ }
1990
+
1991
+ /**
1992
+ * 当前绑定会话派出的子智能体清单(含后代,按目录顺序)。
1993
+ *
1994
+ * `listDescendants` 读的是父会话自己的目录**事实**,不加载也不唤醒任何子代理——
1995
+ * 所以列一下很便宜,也不会把睡着的子代理弄醒。
1996
+ */
1997
+ const subagentMetricInflight = new Map()
1998
+
1999
+ async function fillSubagentMetrics(parentSessionId, rows) {
2000
+ if (subagentMetricInflight.has(parentSessionId)) return
2001
+ const task = (async () => {
2002
+ for (const row of rows) {
2003
+ if (!row || row.diagnostic) continue
2004
+ const metrics = subagentMetrics(row.id)
2005
+ server?.broadcast('subagent-metrics', {
2006
+ parentSessionId,
2007
+ childId: row.id,
2008
+ ...metrics,
2009
+ })
2010
+ }
2011
+ })().finally(() => subagentMetricInflight.delete(parentSessionId))
2012
+ subagentMetricInflight.set(parentSessionId, task)
2013
+ }
2014
+
2015
+ async function readSubagentRows(parentSessionId) {
2016
+ const runtime = subagentServices.runtime
2017
+ if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
2018
+ if (typeof runtime?.listDescendants !== 'function') {
2019
+ return { ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }
2020
+ }
2021
+ let rows
2022
+ try {
2023
+ rows = await runtime.listDescendants(parentSessionId)
2024
+ } catch (err) {
2025
+ return { ok: false, error: subagentFailure(err, '读不到子智能体') }
2026
+ }
2027
+ const out = []
2028
+ for (const row of rows ?? []) {
2029
+ if (!row) continue
2030
+ // 有一支目录读不动时,DSH 拿一行「诊断」顶上来(corrupt / unsupported / unavailable)。
2031
+ // 如实转达,别当空的吞掉——吞了的表现是「明明有子智能体,却一个都不显示」。
2032
+ if (row.kind === 'diagnostic') {
2033
+ out.push({ id: String(row.id), diagnostic: String(row.reason ?? 'unavailable') })
2034
+ continue
2035
+ }
2036
+ const metrics = { tokens: null, durationMs: null, lastTurnCompleted: null }
2037
+ out.push({
2038
+ id: String(row.id),
2039
+ parentId: String(row.parentId ?? ''),
2040
+ depth: Number(row.depth) || 1,
2041
+ // 只有「可续接」谈得上继续聊;两个值之外的都按一次性处理——未知模式不许接话。
2042
+ mode: row.mode === 'continuable' ? 'continuable' : 'one-shot',
2043
+ label: typeof row.label === 'string' ? row.label : '',
2044
+ running: row.activity === 'running',
2045
+ tokens: metrics.tokens,
2046
+ durationMs: metrics.durationMs,
2047
+ lastTurnCompleted: metrics.lastTurnCompleted,
2048
+ })
2049
+ }
2050
+ return { ok: true, subagents: out, boundSessionId: parentSessionId }
2051
+ }
2052
+
2053
+ async function listSubagents(parentSessionId, { includeMetrics = true, scheduleMetrics = false } = {}) {
2054
+ if (!parentSessionId) return readSubagentRows(parentSessionId)
2055
+ const cached = !includeMetrics ? subagentCache.get(parentSessionId) : null
2056
+ const rows = cached
2057
+ ? { ok: true, subagents: cached, boundSessionId: parentSessionId }
2058
+ : await readSubagentRows(parentSessionId)
2059
+ if (!rows.ok) return rows
2060
+ if (includeMetrics) {
2061
+ rows.subagents = rows.subagents.map((row) => row.diagnostic
2062
+ ? row
2063
+ : { ...row, ...subagentMetrics(row.id) })
2064
+ }
2065
+ if (!cached) subagentCache.set(parentSessionId, rows.subagents)
2066
+ if (scheduleMetrics && !includeMetrics) {
2067
+ setTimeout(() => { fillSubagentMetrics(parentSessionId, rows.subagents) }, 0).unref?.()
2068
+ }
2069
+ return rows
2070
+ }
2071
+
2072
+ /**
2073
+ * 某个子智能体**自己的运行过程**。
2074
+ *
2075
+ * 数据来源和主会话**完全同一条**:`sessionQuery.readSession(childId)` 拿原始事件,
2076
+ * `replayHistory` 重放成聊天记录(和实时那条路共用同一个提取器)。电脑端「点进去
2077
+ * 看它跑了什么」看的也是它自己的会话记录,两边因此长得一样——各写一套迟早会走散。
2078
+ *
2079
+ * 地址是手机传上来的,**不能拿它当权限**:先确认它真的挂在当前绑定会话名下。
2080
+ */
2081
+ async function subagentTranscript(childId, parentSessionId) {
2082
+ if (!childId) return { ok: false, error: '没说要读哪一道子智能体。' }
2083
+ const listed = await listSubagents(parentSessionId, { includeMetrics: false, scheduleMetrics: false })
2084
+ if (!listed.ok) return listed
2085
+ const row = listed.subagents.find((s) => s.id === childId)
2086
+ if (!row) return { ok: false, error: '这道子智能体不在当前会话名下。' }
2087
+ const q = treeServices.query
2088
+ if (typeof q?.readSession !== 'function') {
2089
+ return { ok: false, error: '这台电脑上的 DSH 没提供读取会话记录的能力。' }
2090
+ }
2091
+ let snap
2092
+ try {
2093
+ snap = await q.readSession(childId)
2094
+ } catch (err) {
2095
+ return { ok: false, error: subagentFailure(err, '读不到它的记录') }
2096
+ }
2097
+ const events = Array.isArray(snap?.events) ? snap.events : []
2098
+ if (!events.length) {
2099
+ // 一次性子智能体跑完、记录已被回收时就是这里。**如实说没有记录**,不拿
2100
+ // 「读不到」冒充「它没干过活」——这两件事对用户含义不同。
2101
+ return {
2102
+ ok: true, mode: row.mode, running: row.running,
2103
+ entries: [], reclaimed: true, truncated: false, note: null,
2104
+ }
2105
+ }
2106
+ const capped = capEntries(replayHistory(events))
2107
+ return {
2108
+ ok: true,
2109
+ mode: row.mode,
2110
+ running: row.running,
2111
+ entries: capped.entries,
2112
+ reclaimed: false,
2113
+ truncated: capped.dropped > 0,
2114
+ note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
2115
+ }
2116
+ }
2117
+
2118
+ /**
2119
+ * 停:只停**当前这一轮**,不销毁这道子智能体(排队里的消息、它的后代都留着)。
2120
+ *
2121
+ * 权限走「人」这条:`interruptByParent(childId, parentSessionId, mode)` 拿这三个当凭据,
2122
+ * 它**不做父 Agent 在线检查**——这正是「父会话不在线,也能停下正在跑的它」的原因。
2123
+ * 一次性的没有可停的轮次,这里就挡掉,不往 DSH 递。
2124
+ */
2125
+ async function stopSubagent(childId, parentSessionId, mode) {
2126
+ const runtime = subagentServices.runtime
2127
+ if (!childId) return { ok: false, error: '没说要停哪一道子智能体。' }
2128
+ if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
2129
+ if (mode !== 'continuable') {
2130
+ return { ok: false, error: '这是一次性子智能体,跑完就结束,没有可停的轮次。' }
2131
+ }
2132
+ if (typeof runtime?.interruptByParent !== 'function') {
2133
+ return { ok: false, error: '这台电脑上的 DSH 没提供停止子智能体的能力。' }
2134
+ }
2135
+ try {
2136
+ await runtime.interruptByParent(childId, parentSessionId, 'continuable')
2137
+ } catch (err) {
2138
+ return { ok: false, error: subagentFailure(err, '停不下来') }
2139
+ }
2140
+ // 和主会话那条同一个口径:递进去不等于停稳,界面上得等它的状态翻过来。
2141
+ return { ok: true }
2142
+ }
2143
+
2144
+ /**
2145
+ * 继续:往这道子智能体里递一句**人**的话。
2146
+ *
2147
+ * `prompt` 把这条记成 `source.kind === 'user'`(带 rpcId),也就是「你在它旁边说了
2148
+ * 一句」,而不是「别的 agent 转达的」。delivery 选 queue(排到它当前这轮之后)——
2149
+ * 和手机自己对主会话的做法一致:主会话正在跑时,手机上发的指令也是排队,不打断。
2150
+ *
2151
+ * requestId 是这条消息的身份,**每次都得新铸一个**:复用同一个会被判成重复投递。
2152
+ */
2153
+ async function askSubagent(childId, parentSessionId, mode, text) {
2154
+ const runtime = subagentServices.runtime
2155
+ if (!childId) return { ok: false, error: '没说要跟哪一道子智能体说话。' }
2156
+ if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
2157
+ if (!text) return { ok: false, error: '话是空的。' }
2158
+ if (mode !== 'continuable') {
2159
+ return { ok: false, error: '这是一次性子智能体,跑完就结束了,接不了话。' }
2160
+ }
2161
+ if (typeof runtime?.prompt !== 'function') {
2162
+ return { ok: false, error: '这台电脑上的 DSH 没提供继续子智能体对话的能力。' }
2163
+ }
2164
+ let requestId
2165
+ try {
2166
+ requestId = randomUUID()
2167
+ } catch (err) {
2168
+ // 兜底:uuid 拿不到也不该让这句话发不出去。
2169
+ requestId = 'mini-' + Date.now() + '-' + randomBytes(8).toString('hex')
2170
+ }
2171
+ try {
2172
+ const receipt = await runtime.prompt({
2173
+ requestId,
2174
+ parentSessionId,
2175
+ childSessionId: childId,
2176
+ mode: 'continuable',
2177
+ delivery: 'queue',
2178
+ content: [{ type: 'text', text }],
2179
+ })
2180
+ return { ok: true, messageId: receipt?.messageId ? String(receipt.messageId) : null }
2181
+ } catch (err) {
2182
+ return { ok: false, error: subagentFailure(err, '这句话没送出去') }
2183
+ }
2184
+ }
2185
+
1473
2186
  // ------------------------------------------------------------------
1474
2187
  // 手机上答题:把电脑这边的提问推到手机,等它答,再把答案交还给电脑。
1475
2188
  //
@@ -1507,6 +2220,23 @@ export function apply(ctx, config = {}) {
1507
2220
  // 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
1508
2221
  // **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
1509
2222
  const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
2223
+ /**
2224
+ * **飞书那条路此刻能不能接这道题。**
2225
+ *
2226
+ * 三个条件,缺一不接:
2227
+ * ① 通路在、而且**有一轮从飞书发起的指令还没落定**(见 lib/lark.js 的 serving)——
2228
+ * 手机上发起的轮次不该因此抢掉电脑上的弹窗;
2229
+ * ② 这道题在飞书里写得成一段纯文本(`feishuQuestionText` 写不成时给空串:
2230
+ * 两道以上的题里混着一道没有选项的题,数字协议表达不了);
2231
+ * ③ 那段话不超过一条消息的上限。
2232
+ * 接不了就当飞书那边不存在,电脑和手机照旧——**一个字都不变**。
2233
+ */
2234
+ const askText = feishuQuestionText(questions)
2235
+ const feishuReady = Boolean(feishu)
2236
+ && askText !== ''
2237
+ && askText.length <= FEISHU_TEXT_LIMIT
2238
+ && typeof feishu.serving === 'function'
2239
+ && feishu.serving()
1510
2240
  /**
1511
2241
  * **让路的那三种情况,每一种都留一行。**
1512
2242
  *
@@ -1520,8 +2250,8 @@ export function apply(ctx, config = {}) {
1520
2250
  console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
1521
2251
  return next()
1522
2252
  }
1523
- if (!phoneReady) {
1524
- console.log('[dsh-mini-remote] 提问让路:手机上此刻没有连接(页面没开、锁屏或断网),交给电脑')
2253
+ if (!phoneReady && !feishuReady) {
2254
+ console.log('[dsh-mini-remote] 提问让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
1525
2255
  return next()
1526
2256
  }
1527
2257
  if (typeof miniControl.askPhone !== 'function') {
@@ -1529,11 +2259,28 @@ export function apply(ctx, config = {}) {
1529
2259
  return next()
1530
2260
  }
1531
2261
  // ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
1532
- console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`)
1533
- return miniControl.askPhone(questions, agentId).then((answer) => {
2262
+ console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`
2263
+ + (feishuReady ? '(飞书那边一起问)' : ''))
2264
+ /**
2265
+ * `askId` 是这道题在**手机页那份清单**里的编号,由 `onPending` 当场交出来。
2266
+ * 它只用来问一件事:「飞书那边还挂着这道题吗」(见 keepAlive)——
2267
+ * 题目的胜负、谁先答谁生效,全在 lib/server.js 那份清单里,这里不另判。
2268
+ */
2269
+ let askId = null
2270
+ return miniControl.askPhone(questions, agentId, {
2271
+ onPending: (id) => {
2272
+ askId = id
2273
+ // 飞书那边写成一段普通文字问出来。它没接(写不成、或发不出去就当场撤回)时,
2274
+ // keepAlive 下一次为 false,题照旧还给电脑。
2275
+ feishu?.openAsk?.({ id, kind: 'question', payload: questions })
2276
+ },
2277
+ // 「手机全断了要不要收摊」的第二个判据:飞书那边还挂着这道题就别收。
2278
+ keepAlive: () => Boolean(feishu && askId && feishu.isWaiting?.(askId)),
2279
+ }).then((answer) => {
1534
2280
  // 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
1535
2281
  // 这一句日志是「手机上答了、电脑上没动静」那类现象唯一能落下来的痕迹:
1536
2282
  // 有它才知道手机那份答案没接上(断线、或者答晚了一步),而不是插件没在跑。
2283
+ if (askId) feishu?.expireAsk?.(askId)
1537
2284
  if (!answer) {
1538
2285
  console.log('[dsh-mini-remote] 手机上没答成(连接断了、或者答晚了),这题还给电脑')
1539
2286
  return next()
@@ -1542,6 +2289,7 @@ export function apply(ctx, config = {}) {
1542
2289
  return answer
1543
2290
  }).catch((err) => {
1544
2291
  // 我这边出任何问题都不能把提问吞掉,一律让回电脑。
2292
+ if (askId) feishu?.expireAsk?.(askId)
1545
2293
  console.log('[dsh-mini-remote] 接管提问出错,让回电脑:' + String(err))
1546
2294
  return next()
1547
2295
  })
@@ -1562,20 +2310,40 @@ export function apply(ctx, config = {}) {
1562
2310
  // 这次到底要跑什么:审批请求只带调用编号,参数在会话事件里记着的那份。
1563
2311
  const command = request?.callId ? (toolCalls.get(String(request.callId)) ?? '') : ''
1564
2312
  const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
2313
+ // 飞书那条路此刻能不能接:判据和上面那条提问链**一模一样**(可能接、写得成、不超长)。
2314
+ const view = {
2315
+ toolName: typeof toolName === 'string' && toolName ? toolName : '某个工具',
2316
+ reason: typeof request?.reason === 'string' ? request.reason : '',
2317
+ command: typeof command === 'string' ? command : '',
2318
+ }
2319
+ const approvalAskText = approvalText(view)
2320
+ const feishuReady = Boolean(feishu)
2321
+ && approvalAskText.length <= FEISHU_TEXT_LIMIT
2322
+ && typeof feishu.serving === 'function'
2323
+ && feishu.serving()
1565
2324
  if (!agentId || agentId !== bound) {
1566
2325
  console.log(`[dsh-mini-remote] 审批让路:批的=${agentId ?? '无'},手机绑的=${bound ?? '无'}`)
1567
2326
  return next()
1568
2327
  }
1569
- if (!phoneReady) {
1570
- console.log('[dsh-mini-remote] 审批让路:手机上此刻没有连接(页面没开、锁屏或断网),交给电脑')
2328
+ if (!phoneReady && !feishuReady) {
2329
+ console.log('[dsh-mini-remote] 审批让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
1571
2330
  return next()
1572
2331
  }
1573
2332
  if (typeof miniControl.askApproval !== 'function') {
1574
2333
  console.log('[dsh-mini-remote] 审批让路:拿不到 askApproval(手机服务还没起来),交给电脑')
1575
2334
  return next()
1576
2335
  }
1577
- console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`)
1578
- return miniControl.askApproval(request, agentId, command).then((outcome) => {
2336
+ console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`
2337
+ + (feishuReady ? '(飞书那边一起问)' : ''))
2338
+ let askId = null
2339
+ return miniControl.askApproval(request, agentId, command, {
2340
+ onPending: (id) => {
2341
+ askId = id
2342
+ feishu?.openAsk?.({ id, kind: 'approval', payload: view })
2343
+ },
2344
+ keepAlive: () => Boolean(feishu && askId && feishu.isWaiting?.(askId)),
2345
+ }).then((outcome) => {
2346
+ if (askId) feishu?.expireAsk?.(askId)
1579
2347
  if (!outcome) {
1580
2348
  console.log('[dsh-mini-remote] 手机上没拍成(连接断了、或者拍晚了),这条还给电脑')
1581
2349
  return next()
@@ -1583,6 +2351,7 @@ export function apply(ctx, config = {}) {
1583
2351
  console.log('[dsh-mini-remote] 手机上拍板了:' + outcome)
1584
2352
  return outcome
1585
2353
  }).catch((err) => {
2354
+ if (askId) feishu?.expireAsk?.(askId)
1586
2355
  console.log('[dsh-mini-remote] 接管审批出错,让回电脑:' + String(err))
1587
2356
  return next()
1588
2357
  })
@@ -1608,6 +2377,84 @@ export function apply(ctx, config = {}) {
1608
2377
  + Boolean(canCreate.controller) + ' create=' + typeof canCreate.controller?.create)
1609
2378
  })
1610
2379
 
2380
+ // Agent 模式注册表(2026-10-04 用户要求:新建会话时手机上也要能选模式,PC 一直
2381
+ // 可以)。**单独 inject**(理由同上:缺一个服务不该连别的都没了)。没有它的时候
2382
+ // 手机直接建 DSH 的默认模式,如实说「没有可选的」——不摆一个没得选的选择题。
2383
+ const canPresets = { registry: null }
2384
+ ctx.inject(['agentPresets'], (presetsCtx) => {
2385
+ canPresets.registry = presetsCtx.agentPresets
2386
+ console.log('[dsh-mini-remote] Agent 模式清单已就绪:agentPresets=' + Boolean(canPresets.registry))
2387
+ })
2388
+
2389
+ // 插件管理服务(手机端「插件」页面用)。
2390
+ const pluginServices = { manager: null }
2391
+ // DSH 版本号问到一次就记着(版本不会中途变),省得每 30 秒问一趟。
2392
+ let cachedDshVersion = ''
2393
+ ctx.inject(['pluginManager'], (pCtx) => {
2394
+ pluginServices.manager = pCtx.pluginManager
2395
+ console.log('[dsh-mini-remote] 插件管理服务已就绪:pluginManager=' + Boolean(pluginServices.manager))
2396
+ })
2397
+
2398
+ /**
2399
+ * 「内置插件」要用的两样东西(2026-10-04 用户指出:内置插件只显示 6 个是不对的)。
2400
+ *
2401
+ * DSH 的理念是「一切皆插件、无特权核心」——一整套系统是几百个插件模块拼起来的,
2402
+ * 所以「内置插件」的真相不在 npm 包清单里(那是「插件市场」的粒度),
2403
+ * 而在 **Cordis Loader 的实时条目表**里。PC 的「设置 → 内置插件」页读的正是它,
2404
+ * 分成两块:全局插件(系统与所有会话共用)+ 会话插件(按 Agent 预设组合而成)。
2405
+ *
2406
+ * 两样都走 ctx.inject(软注入):拿不到就只少这一块,不整个倒下。
2407
+ * `pluginPackages` 只提供显示用的本地化标题/说明,没有它照样能列出来(退成模块名)。
2408
+ */
2409
+ const invServices = { loader: null, packages: null, settings: null }
2410
+ ctx.inject(['loader'], (lCtx) => {
2411
+ invServices.loader = lCtx.loader
2412
+ console.log('[dsh-mini-remote] Cordis Loader 已就绪(内置插件清单要用):loader='
2413
+ + Boolean(invServices.loader))
2414
+ })
2415
+ ctx.inject(['pluginPackages'], (pkCtx) => {
2416
+ invServices.packages = pkCtx.pluginPackages
2417
+ console.log('[dsh-mini-remote] 插件包元数据已就绪:pluginPackages='
2418
+ + Boolean(invServices.packages))
2419
+ })
2420
+ // 设置文档:问「官方插件里注册了设置卡片的那几个,这台部署是不是提供了」。
2421
+ ctx.inject(['settings'], (sCtx) => {
2422
+ invServices.settings = sCtx.settings
2423
+ console.log('[dsh-mini-remote] 设置文档已就绪(官方插件卡片要用):settings='
2424
+ + Boolean(invServices.settings))
2425
+ })
2426
+
2427
+ /**
2428
+ * 官方插件里「注册了一张设置卡片」的那几个。
2429
+ *
2430
+ * **为什么只能列在这里**:这几张卡片是**浏览器端**的插件在运行时注册进 Plugins 页的
2431
+ * `plugins.item` 插槽的(dsh-client-ui-plugin-manager 读的是 `ctx.slots`),
2432
+ * 宿主进程看不见浏览器里的插槽注册表——没有哪条宿主接口能问出来。
2433
+ * 所以包 identity、卡片 id、开关注册所用的命名空间、中文文案,
2434
+ * 只能照 DSH 0.2.0-rc.2 的客户端源码抄一份(4 个包各注册一张卡):
2435
+ *
2436
+ * dsh-client-ui-settings-shell id=shell 等 bash-sandbox / pwsh-sandbox
2437
+ * dsh-client-ui-settings-agent-loop id=agent-loop 等 agent-loop
2438
+ * dsh-client-ui-settings-subagent id=subagent 等 subagent / subagent-model-selection-settings
2439
+ * dsh-client-ui-settings-web-search id=web-search 等 web-search-deepseek
2440
+ *
2441
+ * 文案逐字取自那几个包 `lib/client.js` 里自己的 zh 词典(不自己编)。
2442
+ *
2443
+ * **出现条件照 DSH 同一条规矩**:宿主的设置文档里 served 了它那几个命名空间才出现
2444
+ * (DSH 那边是 `configForms.whileServed(namespaces, …)`)——所以「这台部署没有终端设置」
2445
+ * 时,手机上也不会凭空多出一张终端卡。DSH 以后再加官方卡片,这张表要跟着补。
2446
+ */
2447
+ const OFFICIAL_CARDS = [
2448
+ { id: 'shell', namespaces: ['bash-sandbox', 'pwsh-sandbox'], title: '终端',
2449
+ description: '限制每条命令最多能跑多久、最多输出多少内容。' },
2450
+ { id: 'agent-loop', namespaces: ['agent-loop'], title: 'Agent 循环',
2451
+ description: '控制 Agent 派发工具调用的方式。' },
2452
+ { id: 'subagent', namespaces: ['subagent', 'subagent-model-selection-settings'], title: '子智能体',
2453
+ description: '设置子智能体的递归层级、数量和模型。' },
2454
+ { id: 'web-search', namespaces: ['web-search-deepseek'], title: '网页搜索',
2455
+ description: '设置 DeepSeek 的搜索提供方。' },
2456
+ ]
2457
+
1611
2458
  // ------------------------------------------------------------------
1612
2459
  // 手机上传文件要用的附件服务
1613
2460
  // ------------------------------------------------------------------
@@ -1779,10 +2626,55 @@ export function apply(ctx, config = {}) {
1779
2626
  }
1780
2627
  }
1781
2628
 
2629
+ /**
2630
+ * 取一份「本地化文案」里的中文。
2631
+ *
2632
+ * 形状是 LocalizedText:一个字符串,或 `{ en, 'zh-CN', … }` 这种按语言分份的对象。
2633
+ * 插件的标题和说明都是这个形状——不挑语言直接读,官方插件在手机上就全是英文。
2634
+ * 没有中文退英文,再没有就取第一个非空串;都没有返回空串(调用方自己决定怎么兜)。
2635
+ */
2636
+ function localizedText(v) {
2637
+ if (typeof v === 'string') return v
2638
+ if (!v || typeof v !== 'object') return ''
2639
+ for (const k of ['zh-CN', 'zh-Hans', 'zh', 'en']) {
2640
+ if (typeof v[k] === 'string' && v[k]) return v[k]
2641
+ }
2642
+ for (const val of Object.values(v)) if (typeof val === 'string' && val) return val
2643
+ return ''
2644
+ }
2645
+
2646
+ /**
2647
+ * Loader 条目的 Fiber 状态码 → 词。照 dsh-host-plugin-inventory 里那张表:
2648
+ * 0 待定 / 1 加载中 / 2 运行中 / 3 失败 / 4 已销毁(报 null)/ 5 卸载中。
2649
+ */
2650
+ const FIBER_PHASE = ['pending', 'loading', 'active', 'failed', null, 'unloading']
2651
+
2652
+ /**
2653
+ * 一个 Agent 模式(预设)拿给用户看的名字与说明。
2654
+ *
2655
+ * 政策照 PC:**声明里自带名字的(自建模式)用它自己的**,没名字的(内置那几档)
2656
+ * 才落 AGENT_MODE_COPY;认不出又没名字的落 null——如实,不瞎起名。
2657
+ *
2658
+ * 抽出来是因为两处要用:手机上「选模式」的清单,以及插件页「会话插件」按预设分组。
2659
+ * (2026-10-05 实测抓到:分组那儿漏了这一步,页面上露出 standard / ptc / cordis
2660
+ * 这种英文代号——组合清单里的 name 是预设声明的原名,不是给用户看的名字。)
2661
+ */
2662
+ function presetCopyOf(p) {
2663
+ const copy = AGENT_MODE_COPY[p?.id]
2664
+ const hasName = typeof p?.name === 'string' && p.name
2665
+ const hasDesc = typeof p?.description === 'string' && p.description
2666
+ return {
2667
+ id: p?.id,
2668
+ name: hasName ? p.name : (copy ? copy[0] : null),
2669
+ description: hasDesc ? p.description : (copy ? copy[1] : null),
2670
+ broken: typeof p?.broken === 'string' ? p.broken : null,
2671
+ }
2672
+ }
2673
+
1782
2674
  const nav = {
1783
2675
  listWorkspaces: (runningIds, force, includeEmpty) =>
1784
- listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
1785
- listSessionsOf: (workspaceId, limit, force) =>
2676
+ listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty, meta: sessionMeta }),
2677
+ listSessionsOf: (workspaceId, limit, force, onTitle) =>
1786
2678
  listSessionsOf({
1787
2679
  registry: treeServices.registry,
1788
2680
  query: treeServices.query,
@@ -1792,15 +2684,39 @@ export function apply(ctx, config = {}) {
1792
2684
  workspaceId,
1793
2685
  limit,
1794
2686
  force,
2687
+ onTitle,
2688
+ meta: sessionMeta,
1795
2689
  }),
1796
2690
  // 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
1797
2691
  // 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
1798
- createSession: (workspaceId) =>
2692
+ createSession: (workspaceId, agentPreset) =>
1799
2693
  createSessionIn({
1800
2694
  controller: canCreate.controller ?? ctx.get('sessionController'),
1801
2695
  registry: treeServices.registry,
1802
2696
  workspaceId,
2697
+ agentPreset,
1803
2698
  }),
2699
+ // Agent 模式清单(2026-10-04):手机新建会话时挑模式用。坏档也如实给(broken),
2700
+ // 拿不到能力就说 unavailable——不摆一个没得选的选择题。默认哪一档由
2701
+ // registry 的 defaultId 说(那是 DSH 自己的默认,不猜)。
2702
+ //
2703
+ // 名字照抄 PC 的词条表(见 AGENT_MODE_COPY,用户 2026-10-04 点名「模式选择应该
2704
+ // 为中文」)。政策也照 PC:**声明里自带名字的(自建模式)用它自己的**,没名字的
2705
+ // (内置档)才落词条表;认不出又没名字的才落 id——如实,不瞎起名。
2706
+ agentPresets: async () => {
2707
+ const svc = canPresets.registry ?? ctx.get('agentPresets')
2708
+ if (!svc || typeof svc.list !== 'function') return { ok: false, reason: 'unavailable' }
2709
+ try {
2710
+ const list = await svc.list()
2711
+ return {
2712
+ ok: true,
2713
+ defaultId: typeof svc.defaultId === 'string' ? svc.defaultId : null,
2714
+ presets: (Array.isArray(list) ? list : []).map(presetCopyOf),
2715
+ }
2716
+ } catch (err) {
2717
+ return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
2718
+ }
2719
+ },
1804
2720
  // 「这一刻到底能不能建会话」。给 /mini/api/version 用:那是个不用动手、
1805
2721
  // 一条命令就能问的运行时事实,比翻日志快。和 createSession 同一条判据,
1806
2722
  // 免得出现「探测说能、真按了却不行」。
@@ -1817,6 +2733,308 @@ export function apply(ctx, config = {}) {
1817
2733
  setPermission: (sessionId, name) =>
1818
2734
  setPreset({ service: presets.service, session: sessionFor(sessionId), name }),
1819
2735
 
2736
+ // 子智能体(2026-10-03)。四条都取**当前绑定会话**那一份,和上面 send / stop
2737
+ // 同一套口径:手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
2738
+ subagents: (sessionId, options) => listSubagents(sessionId, options),
2739
+
2740
+ /**
2741
+ * 插件清单(手机端「插件」页面用,2026-10-04)。
2742
+ *
2743
+ * 字段照 DSH 的 `BundleInfo`(pluginManager 的契约,逐字段对过):
2744
+ * name / version / installed / optional / enabled / description
2745
+ * meta: { title?, description? } —— **本地化文案**,中文说明在这一份里;
2746
+ * error: { code, diagnostic? } —— 出问题的那一笔。
2747
+ *
2748
+ * 两处不照抄会难看:① 只取 `description`(包的英文说明),
2749
+ * PC 上那句中文「启用团队协作、团队工具……」来自 `meta.description`,得优先用它;
2750
+ * ② `error` 是个对象,直接递出去页面上会显示 `[object Object]`。
2751
+ */
2752
+ /**
2753
+ * 正在跑的这只 DSH 是哪个版本(设置页底部那行用,2026-10-04)。
2754
+ *
2755
+ * **为什么不读文件**:早先这里去翻 `node_modules/@deepseek-ai/dsh/package.json`,
2756
+ * 两个毛病都踩了——① 那是「命令行那份 dsh」的版本,未必等于正在跑的这只 Host
2757
+ * (实测桌面端跑的是 0.2.0-rc.2,而命令行那份写着 0.2.0-rc.1);
2758
+ * ② 桌面端的工作目录里压根没有那个文件,于是手机上一直显示 `unknown`。
2759
+ *
2760
+ * 改问正在跑的这只 Host:它自己报的运行时版本才作数。问到一次就记住。
2761
+ */
2762
+ dshVersion: async () => {
2763
+ if (cachedDshVersion) return cachedDshVersion
2764
+ const mgr = pluginServices.manager ?? ctx.get('pluginManager')
2765
+ try {
2766
+ // 这个接口本意是列「版本豁免」,但它捎带把运行时版本报出来了
2767
+ // ——宿主唯一一处直接说「我是哪个版本」的地方。
2768
+ const got = await mgr?.listVersionExemptions?.()
2769
+ if (typeof got?.runtimeVersion === 'string' && got.runtimeVersion) {
2770
+ cachedDshVersion = got.runtimeVersion
2771
+ return cachedDshVersion
2772
+ }
2773
+ } catch (err) {
2774
+ // 问不到就往下一步退,不把设置页那行弄崩。
2775
+ }
2776
+ try {
2777
+ // 退一步:核心包自己的版本就是 DSH 的版本(整个仓库一起发版)。
2778
+ const bundles = await mgr?.listBundles?.()
2779
+ const base = (bundles || []).find((b) => b?.name === '@deepseek-ai/dsh-base')
2780
+ if (typeof base?.version === 'string' && base.version) {
2781
+ cachedDshVersion = base.version
2782
+ return cachedDshVersion
2783
+ }
2784
+ } catch (err) {
2785
+ // 都没有就如实说不知道(页面据此整行不显示,不拿 unknown 糊一行)。
2786
+ }
2787
+ return 'unknown'
2788
+ },
2789
+
2790
+ pluginList: async () => {
2791
+ const mgr = pluginServices.manager ?? ctx.get('pluginManager')
2792
+ if (!mgr) return { ok: false, reason: 'unavailable' }
2793
+ try {
2794
+ const bundles = await mgr.listBundles()
2795
+ return {
2796
+ ok: true,
2797
+ bundles: (bundles || []).map((b) => ({
2798
+ name: b?.name ?? '',
2799
+ // 显示名优先用本地化标题(官方插件在 PC 上显示的是「智能体团队」而不是包名),
2800
+ // 没有就退回包名——第三方插件多半没有标题。
2801
+ title: localizedText(b?.meta?.title) || b?.name || '',
2802
+ description: localizedText(b?.meta?.description) || b?.description || '',
2803
+ version: typeof b?.version === 'string' ? b.version : '',
2804
+ installed: b?.installed === true,
2805
+ optional: b?.optional === true,
2806
+ enabled: b?.enabled !== false,
2807
+ // 出问题的那一笔:有原话就说原话,没有就报错误码。没它就别硬编。
2808
+ problem: b?.error ? (b.error.diagnostic || b.error.code || '有问题') : '',
2809
+ })),
2810
+ }
2811
+ } catch (err) {
2812
+ return { ok: false, reason: String(err?.message || err) }
2813
+ }
2814
+ },
2815
+
2816
+ /**
2817
+ * 内置插件清单(手机端「插件 → 已安装 → 内置」用,2026-10-04 补)。
2818
+ *
2819
+ * **为什么不是 listBundles**:那是 npm 包的粒度(十几个),答不了「内置插件有多少」。
2820
+ * DSH 是「一切皆插件、无特权核心」,一整套系统由几百个插件模块拼成,真相在
2821
+ * Cordis Loader 的实时条目表里。PC 的「设置 → 内置插件」页就是读这里,分两块:
2822
+ * 全局插件 = Loader 自己的条目(系统与所有会话共用);
2823
+ * 会话插件 = 各 Agent 预设的组合行(预设挂载后才在会话里跑)。
2824
+ *
2825
+ * 读取逻辑照 `dsh-host-plugin-inventory`(那是只读投影,我们直接读同几个源:
2826
+ * loader.entries() / agentPresets.compositionInventory() / pluginPackages.metaOf()),
2827
+ * 因为它在宿主服务目录里查不到,只能按它的实现来。
2828
+ */
2829
+ pluginInventory: async () => {
2830
+ const loader = invServices.loader ?? ctx.get('loader')
2831
+ if (!loader || typeof loader.entries !== 'function') {
2832
+ return { ok: false, reason: 'unavailable' }
2833
+ }
2834
+ const packages = invServices.packages ?? ctx.get('pluginPackages')
2835
+ // 显示用的本地化元数据。取不到就是没有——退成模块名,不影响列出来。
2836
+ const metaOf = (moduleName, base) => {
2837
+ if (!packages || typeof packages.metaOf !== 'function' || base === undefined) return null
2838
+ try {
2839
+ return packages.metaOf(moduleName, base) ?? null
2840
+ } catch (err) {
2841
+ return null
2842
+ }
2843
+ }
2844
+ /**
2845
+ * 模块名压成短名。照 PC 的「内置插件」面板(dsh-client-ui-settings-plugin-inventory
2846
+ * 里那个 moduleShortName):`@deepseek-ai/dsh-tool-fs` → `tool-fs`。
2847
+ * 手机上不压的话每一行都是 `@deepseek-ai/dsh-xxx`,一屏放不下几个。
2848
+ */
2849
+ const shortName = (name) => String(name ?? '')
2850
+ .replace(/^@[^/]+\//, '')
2851
+ .replace(/^cordis:/, '')
2852
+ .replace(/^cordis-plugin-/, '')
2853
+ .replace(/^dsh-(?:host-|client-)?/, '')
2854
+ /**
2855
+ * 显示名。PC 的规矩:`meta.title` 是本地化对象就取文案,否则把那一份
2856
+ * (字符串标题,或干脆是模块名)压成短名。没有标题就退到模块名的短名。
2857
+ */
2858
+ const titleOf = (meta, moduleName) => {
2859
+ const t = meta?.title
2860
+ if (t && typeof t === 'object') return localizedText(t) || shortName(moduleName)
2861
+ return shortName(t || moduleName)
2862
+ }
2863
+ const rowOf = (moduleName, base, extra) => {
2864
+ const meta = metaOf(moduleName, base)
2865
+ return {
2866
+ entryId: extra.entryId,
2867
+ moduleName,
2868
+ title: titleOf(meta, moduleName),
2869
+ description: localizedText(meta?.description),
2870
+ enabled: extra.enabled,
2871
+ // 预设组合行可能是 'conditional'(带 !!js 条件、要挂载后才定得下来)。
2872
+ conditional: extra.conditional === true,
2873
+ condition: extra.condition,
2874
+ phase: extra.phase,
2875
+ }
2876
+ }
2877
+
2878
+ const entries = []
2879
+ try {
2880
+ for (const entry of loader.entries()) {
2881
+ // 分组只是树上的容器,不是插件(PC 的内置插件页同样跳过它们)。
2882
+ if (entry?.options?.group) continue
2883
+ const moduleName = String(entry?.options?.name ?? '')
2884
+ entries.push(rowOf(moduleName, entry?.parent?.tree?.ctx?.baseUrl, {
2885
+ entryId: String(entry?.id ?? ''),
2886
+ enabled: entry?.disabled !== true,
2887
+ condition: '',
2888
+ phase: entry?.fiber === undefined ? null : (FIBER_PHASE[entry.fiber.state] ?? null),
2889
+ }))
2890
+ }
2891
+ } catch (err) {
2892
+ return { ok: false, reason: '读 Loader 条目失败:' + String(err?.message || err) }
2893
+ }
2894
+
2895
+ // 会话插件:预设的组合行。拿不到就空着——那说明这个部署没挂预设名册,
2896
+ // 不是「一个都没有」,所以页面那一块整块不画。
2897
+ const presets = []
2898
+ try {
2899
+ const registry = canPresets.registry ?? ctx.get('agentPresets')
2900
+ // 组合清单里的 `name` 是预设自己声明的**原名**(内置那几档就是 standard / ptc
2901
+ // 这种代号),不是给用户看的。给用户看的名字在名册那边——走和「选模式」同一套
2902
+ // 政策取(见 presetCopyOf)。名册读不到就退到原名,不硬编一张对照表。
2903
+ const shownName = new Map()
2904
+ try {
2905
+ const roster = await registry?.list?.()
2906
+ for (const p of Array.isArray(roster) ? roster : []) {
2907
+ const copy = presetCopyOf(p)
2908
+ if (copy.id !== undefined && copy.name) shownName.set(String(copy.id), copy.name)
2909
+ }
2910
+ } catch (err) {
2911
+ // 名册问不到不影响列出组合(只是名字会退回代号)。
2912
+ }
2913
+ const list = await registry?.compositionInventory?.()
2914
+ for (const one of list || []) {
2915
+ const id = String(one?.id ?? '')
2916
+ presets.push({
2917
+ id,
2918
+ name: shownName.get(id) || localizedText(one?.name) || id,
2919
+ isDefault: one?.isDefault === true,
2920
+ broken: one?.broken ? String(one.broken) : '',
2921
+ rows: (one?.rows || []).map((r) => rowOf(String(r?.moduleName ?? ''), ctx.baseUrl, {
2922
+ entryId: r?.entryId === null || r?.entryId === undefined ? '' : String(r.entryId),
2923
+ enabled: r?.enabled === true,
2924
+ conditional: r?.enabled === 'conditional',
2925
+ condition: r?.condition ? String(r.condition) : '',
2926
+ phase: r?.fiberState === undefined ? null : (FIBER_PHASE[r.fiberState] ?? null),
2927
+ })),
2928
+ })
2929
+ }
2930
+ } catch (err) {
2931
+ // 预设读不动不该把全局那一半也拖没:两块各报各的。
2932
+ console.log('[dsh-mini-remote] 预设组合读不到(内置插件的会话那一块会空着):'
2933
+ + String(err?.message || err))
2934
+ }
2935
+
2936
+ return { ok: true, entries, presets, hasRoster: canPresets.registry !== null }
2937
+ },
2938
+
2939
+ /**
2940
+ * 官方插件里那几张「设置卡片」(2026-10-04 用户确认:官方就是这 8 个,手机上也要看得到)。
2941
+ *
2942
+ * 官方那一栏其实是两样东西拼的,PC 上也是:
2943
+ * ① 可开关的官方插件**包**(optional && 未装)——走 pluginList,4 个;
2944
+ * ② 注册了设置卡片的官方插件——就是这里,4 个(终端 / Agent 循环 / 子智能体 / 网页搜索)。
2945
+ * 两张合起来正好是 PC 上那 8 个。
2946
+ *
2947
+ * 判据照 DSH:宿主的设置文档 served 了卡片那几个命名空间才出现。问不到设置文档时
2948
+ * **整块不出现**(宁可少画,不凭空多画)——这和 DSH 自己「不 served 就不注册」一致。
2949
+ */
2950
+ officialPlugins: async () => {
2951
+ const settings = invServices.settings ?? ctx.get('settings')
2952
+ if (!settings || typeof settings.describe !== 'function') return { ok: true, cards: [] }
2953
+ let served = null
2954
+ try {
2955
+ served = new Set((settings.describe() || []).map((d) => String(d?.ns ?? '')))
2956
+ } catch (err) {
2957
+ return { ok: false, reason: String(err?.message || err) }
2958
+ }
2959
+ return {
2960
+ ok: true,
2961
+ cards: OFFICIAL_CARDS
2962
+ .filter((c) => c.namespaces.some((n) => served.has(n)))
2963
+ .map((c) => ({ kind: 'card', id: c.id, title: c.title, description: c.description })),
2964
+ }
2965
+ },
2966
+ /**
2967
+ * 「繁忙时的发送行为」(2026-10-05 用户要求搬进手机设置)。
2968
+ *
2969
+ * **这是什么**:智能体正在跑的时候,你在手机上进的那句话到底怎么交给它——
2970
+ * 排队发送(queue):排到队尾,等当前这一轮跑完再跑;
2971
+ * 插话发送(steer):直接插进去,它当场看到并调整方向。
2972
+ *
2973
+ * **PC 上是同一个东西**:设置 → 通用里的「繁忙时的发送行为」。
2974
+ * 真相在 `dsh-client-ui-conversation` 里:
2975
+ * 命名空间 ui-conversation、字段 busyEnter、取值 queue|steer、默认 queue。
2976
+ * 它的注释原话是「Busy-Enter preference shared by the composer bar inject face
2977
+ * and its Settings row: one live store … backed by the Host user-settings document」。
2978
+ *
2979
+ * **两端共用一个值**(用户 2026-10-05 拍板「都 ok」):宿主只有一份用户设置文档,
2980
+ * 手机上改它,电脑上也是这个值。这样才不会出现「手机上选了插话、电脑上还是排队」。
2981
+ *
2982
+ * **拿不到就整条不显示**,不摆一个假的开关——和插件页那套「宁可少画」同一口径。
2983
+ */
2984
+ busyEnterSetting: async () => {
2985
+ const settings = invServices.settings ?? ctx.get('settings')
2986
+ if (!settings || typeof settings.describe !== 'function') return { ok: false, reason: 'unavailable' }
2987
+ let doc = null
2988
+ try {
2989
+ doc = (settings.describe() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation') ?? null
2990
+ } catch (err) {
2991
+ return { ok: false, reason: String(err?.message || err) }
2992
+ }
2993
+ // 这个命名空间得真的被 served 才有得改——没有它就没有这一条。
2994
+ if (!doc) return { ok: false, reason: 'unavailable' }
2995
+ const value = doc?.value && typeof doc.value === 'object' ? doc.value : {}
2996
+ const current = value.busyEnter === 'steer' ? 'steer' : 'queue'
2997
+ return {
2998
+ ok: true,
2999
+ current,
3000
+ // 带上 revision:写的时候要它做乐观锁,免得和电脑上同时改打架。
3001
+ revision: typeof doc.revision === 'number' ? doc.revision : undefined,
3002
+ }
3003
+ },
3004
+
3005
+ /**
3006
+ * 改「繁忙时的发送行为」。值只有两个,别的一律拒绝——**不替用户猜一个**。
3007
+ *
3008
+ * 走 `settings.update(ns, patch, revision)`(官方契约里的「把一个 entry 的可编辑
3009
+ * 字段合并进去」)。带 revision 是有意的:万一电脑上刚改过,这次写入会被挡下来
3010
+ * 而不是把对方的改动盖掉——那种覆盖用户看不见,最难查。
3011
+ */
3012
+ setBusyEnter: async (next) => {
3013
+ if (next !== 'queue' && next !== 'steer') {
3014
+ return { ok: false, reason: 'invalid' }
3015
+ }
3016
+ const settings = invServices.settings ?? ctx.get('settings')
3017
+ if (!settings || typeof settings.update !== 'function') return { ok: false, reason: 'unavailable' }
3018
+ let revision
3019
+ try {
3020
+ const doc = (settings.describe() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation')
3021
+ if (!doc) return { ok: false, reason: 'unavailable' }
3022
+ revision = typeof doc.revision === 'number' ? doc.revision : undefined
3023
+ } catch (err) {
3024
+ return { ok: false, reason: String(err?.message || err) }
3025
+ }
3026
+ try {
3027
+ await settings.update('ui-conversation', { busyEnter: next }, revision)
3028
+ } catch (err) {
3029
+ return { ok: false, reason: String(err?.message || err) }
3030
+ }
3031
+ return { ok: true, current: next }
3032
+ },
3033
+
3034
+ subagentTranscript: (childId, sessionId) => subagentTranscript(childId, sessionId),
3035
+ stopSubagent: (childId, sessionId, mode) => stopSubagent(childId, sessionId, mode),
3036
+ askSubagent: (childId, sessionId, mode, text) => askSubagent(childId, sessionId, mode, text),
3037
+
1820
3038
  // 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
1821
3039
  // `canCreate.controller` 就是 `ctx.sessionController`——和上面 createSession 同一个东西。
1822
3040
  //
@@ -1982,6 +3200,12 @@ export function apply(ctx, config = {}) {
1982
3200
  // 手机点开会话(或刷新页面)时,把那条会话的完整历史读出来。
1983
3201
  onSessionOpened: (sessionId) => ensureHistory(sessionId),
1984
3202
  tree: nav,
3203
+ // 正文里写相对路径的图(`![](scratch/a.png)`)相对的是**那段对话的工作目录**,
3204
+ // 而不一定是插件根。所以把当前已注册工作区的目录一并递过去当额外候选根。
3205
+ // `treeServices.registry` 就是 DSH 的 workspaceRegistry(注入见上面那段),
3206
+ // 服务这边拿不到 ctx,只能由插件给——传函数不传快照,理由和 treeServices 一样:
3207
+ // 服务每次请求现读,工作区是随时会变的。
3208
+ imageRoots: () => (treeServices.registry?.list?.() ?? []).map((w) => w?.path),
1985
3209
  // 目录浏览自己读盘(理由见 lib/browse.js 开头:DSH 的目录选择器在
1986
3210
  // 「Windows + 只绑回环」时会判成 native,那套动词是直接拒绝的,
1987
3211
  // 会在电脑屏幕上弹框——而用户正在外面用手机)。
@@ -2030,6 +3254,29 @@ export function apply(ctx, config = {}) {
2030
3254
  }
2031
3255
  })
2032
3256
 
3257
+ // ------------------------------------------------------------------
3258
+ // 飞书那条路自己的生死
3259
+ // ------------------------------------------------------------------
3260
+ /**
3261
+ * 单独一个 effect,不和手机服务那一个搅在一起:这两样各有各的失败方式,
3262
+ * 一个起不来不该把另一个拖下水。
3263
+ *
3264
+ * `closed` 那一道和手机服务同一个道理——建连接是异步的,而插件随时可能被卸载。
3265
+ * 建好时如果已经卸了,当场把它关掉,不能留下一条没人管的连接和它自己的定时器。
3266
+ */
3267
+ ctx.effect(() => {
3268
+ let closed = false
3269
+ const start = async () => {
3270
+ await bringFeishuUp()
3271
+ if (closed) bringFeishuDown()
3272
+ }
3273
+ start()
3274
+ return () => {
3275
+ closed = true
3276
+ bringFeishuDown()
3277
+ }
3278
+ })
3279
+
2033
3280
  // ------------------------------------------------------------------
2034
3281
  // DSH 设置页的「手机遥控」标签从这里取数据
2035
3282
  // ------------------------------------------------------------------
@@ -2069,6 +3316,9 @@ export function apply(ctx, config = {}) {
2069
3316
  error: serverError
2070
3317
  ? `手机服务起不来:${serverError}\n端口可以在设置文件里改(settings.json 里的 port)。`
2071
3318
  : '手机服务还在启动,过几秒刷新一下。',
3319
+ // 手机服务没起来,不影响飞书那一块——它和手机服务没有关系,谁用谁的。
3320
+ // 所以这一条早退路径上也带着 feishu,用户至少改得动它。
3321
+ feishu: feishuStatus(),
2072
3322
  },
2073
3323
  }
2074
3324
  }
@@ -2096,6 +3346,8 @@ export function apply(ctx, config = {}) {
2096
3346
  * 端口被占时服务会自己往后让,用配置值会指错。
2097
3347
  */
2098
3348
  serve: await serveState(server.port),
3349
+ // 飞书那一块的现状(**永远不含 appSecret**,只回答「有没有凭据」)。
3350
+ feishu: feishuStatus(),
2099
3351
  })
2100
3352
  return { status: payload.ok ? 200 : 500, body: payload }
2101
3353
  }
@@ -2264,6 +3516,57 @@ export function apply(ctx, config = {}) {
2264
3516
  },
2265
3517
  })
2266
3518
 
3519
+ /**
3520
+ * 飞书那一块的配置:开关、两个凭据、两个允许名单。
3521
+ *
3522
+ * 和公网开关、改密码一样**只允许本机调用**:那是一对凭据,改它等于改这个插件
3523
+ * 在飞书那边的身份。同一个 Wi-Fi 下的别人不该替你做主。
3524
+ *
3525
+ * 两条不成文但必须守的规矩:
3526
+ * 1. **appSecret 从不回显**(见 feishuStatus)。所以界面上那个框天生是空的,
3527
+ * 于是「留空」只能解释成「不改动原来那份」——不然用户每点一次保存都得重新
3528
+ * 贴一遍密码,贴错一次就是一段查不出原因的鉴权失败。
3529
+ * 2. 存完**就地重建连接**。飞书的长连接是绑在凭据上的,不重建的话用户会看到
3530
+ * 「填好了、开关也开着,就是不回消息」,而屏幕上没有任何东西提示他原因。
3531
+ */
3532
+ const offFeishu = webServer.register({
3533
+ kind: 'exact',
3534
+ path: '/mini-remote/feishu',
3535
+ handler: async (req, res) => {
3536
+ if (!localOnly(req, res)) return
3537
+ if (req.method !== 'POST') {
3538
+ sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
3539
+ return
3540
+ }
3541
+ let want
3542
+ try {
3543
+ want = await readBody(req)
3544
+ } catch (err) {
3545
+ sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
3546
+ return
3547
+ }
3548
+ const next = {
3549
+ enabled: Boolean(want?.enabled),
3550
+ appId: typeof want?.appId === 'string' ? want.appId.trim() : '',
3551
+ appSecret: typeof want?.appSecret === 'string' ? want.appSecret.trim() : '',
3552
+ openIds: typeof want?.openIds === 'string' ? want.openIds.trim() : '',
3553
+ chatIds: typeof want?.chatIds === 'string' ? want.chatIds.trim() : '',
3554
+ }
3555
+ // 留空 = 保留原来那份(理由见上面第 1 条)。
3556
+ if (!next.appSecret) next.appSecret = String(settings.feishu?.appSecret ?? '')
3557
+ settings.feishu = next
3558
+ persistSettings()
3559
+ if (next.enabled) await restartFeishu()
3560
+ else bringFeishuDown()
3561
+ try {
3562
+ const { status, body } = await pairingPayload()
3563
+ sendJson(res, status, body)
3564
+ } catch (err) {
3565
+ sendJson(res, 500, { ok: false, error: `改完之后读不到状态:${err?.message ?? err}` })
3566
+ }
3567
+ },
3568
+ })
3569
+
2267
3570
  /**
2268
3571
  * 卸载时把注册过的路由全部注销。
2269
3572
  *
@@ -2271,13 +3574,14 @@ export function apply(ctx, config = {}) {
2271
3574
  * 还挂在服务器上,指向一个已经死掉的闭包——再有人打过来就是莫名其妙的报错。
2272
3575
  *
2273
3576
  * 2026-09-24 补:`offToken` 一直是漏的(改密码那条路由注册了却没注销),
2274
- * 加 HTTPS 这条时对照着数了一遍才发现。四条注册,四条注销,现在对得上。
3577
+ * 加 HTTPS 这条时对照着数了一遍才发现。五条注册,五条注销,现在对得上。
2275
3578
  */
2276
3579
  return () => {
2277
3580
  offGet?.()
2278
3581
  offTunnel?.()
2279
3582
  offServe?.()
2280
3583
  offToken?.()
3584
+ offFeishu?.()
2281
3585
  }
2282
3586
  })
2283
3587
  }