dsh-mini-remote 0.1.8 → 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,
@@ -45,6 +58,8 @@ import { ensureCloudflared } from './cloudflared.js'
45
58
  import { startTunnel, TUNNEL_HINT } from './tunnel.js'
46
59
  import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt } from './tree.js'
47
60
  import { createTitleCache } from './title-cache.js'
61
+ import { createSessionMetaCache } from './session-meta-cache.js'
62
+ import { createSubagentCache } from './subagent-cache.js'
48
63
  import { listRoots, listDirectory, makeDirectory } from './browse.js'
49
64
  import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
50
65
  import { buildId } from './build.js'
@@ -334,7 +349,18 @@ export function apply(ctx, config = {}) {
334
349
  * 而这件事**每重启一次就得重付一遍**——标题却是定下来基本不变的东西。
335
350
  */
336
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') })
337
354
  const tracker = createTurnTracker()
355
+ /**
356
+ * 执行轨迹的累计器(「完整」模式要的过程区)。
357
+ *
358
+ * **和上面的 tracker 是两个人**:上面那个只认「用户说的话」和「最终回答」,轨迹那个
359
+ * 认的是思考、工具调用、工具结果。分开的理由是它们服务的是**两种模式**——聊天模式的
360
+ * 回答区里一个字的过程都不该有(2026-09-22 用户亲自否掉的),而完整模式要的就是过程。
361
+ * 两条路共用的是同一批原始事件,不是同一份产出。
362
+ */
363
+ const trajectory = createTrajectoryTracker()
338
364
  // 队列的真相在 agent 的收件箱里,store 拿不到 ctx,所以给它一个回调去问。
339
365
  // 不缓存、每次取快照现读:没有第二份状态,也就不会和电脑上不一致。
340
366
  store.setQueueSource(queuedOf)
@@ -1185,11 +1211,100 @@ export function apply(ctx, config = {}) {
1185
1211
  return win
1186
1212
  }
1187
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
+
1188
1299
  ctx.on('session/event', (session, event) => {
1189
1300
  // 子 agent 的会话不进手机——用户遥控的是自己的主会话,不是它派出去的小弟。
1190
1301
  if (session.header?.origin === 'subagent') return
1191
1302
  const sessionId = session.id
1192
1303
 
1304
+ // 执行轨迹:**在所有早退之前**喂一口。下面 `tool/call` 那条分支取完命令原文就 return
1305
+ // 了(审批卡要用),而工具调用正是轨迹的主干——放在这一句之前,它才不会漏掉。
1306
+ feedTrajectory(sessionId, event)
1307
+
1193
1308
  // 手机正在看的这个会话:顺手让它的完整历史保持新鲜(一分钟最多重读一次)。
1194
1309
  // 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
1195
1310
  if (sessionId === targetSessionId()) ensureHistory(sessionId)
@@ -1336,6 +1451,7 @@ export function apply(ctx, config = {}) {
1336
1451
  summary: action.summary,
1337
1452
  senderSessionId: action.senderSessionId,
1338
1453
  sessionId,
1454
+ turn: action.turn,
1339
1455
  timestamp: event.time,
1340
1456
  })
1341
1457
  server?.broadcast('state', store.snapshot())
@@ -1358,7 +1474,7 @@ export function apply(ctx, config = {}) {
1358
1474
  // 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
1359
1475
  // 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
1360
1476
  // 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
1361
- 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 })
1362
1478
  server?.broadcast('state', store.snapshot())
1363
1479
  return
1364
1480
  }
@@ -1366,6 +1482,7 @@ export function apply(ctx, config = {}) {
1366
1482
  // action.kind === 'reply'
1367
1483
  store.pushReply({
1368
1484
  text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
1485
+ turn: action.turn,
1369
1486
  // 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
1370
1487
  timestamp: event.time,
1371
1488
  })
@@ -1387,8 +1504,19 @@ export function apply(ctx, config = {}) {
1387
1504
  ctx.on('agent/status', ({ agent, status }) => {
1388
1505
  const sessionId = agent?.session?.id
1389
1506
  if (!sessionId) return
1390
- 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
+ }
1391
1517
  store.setRunning(sessionId, status === 'running')
1518
+ server?.invalidateNavigation?.()
1519
+ server?.broadcast('navigation-refresh', { reason: 'session-status', sessionId })
1392
1520
  broadcastStatus()
1393
1521
  })
1394
1522
 
@@ -1405,15 +1533,20 @@ export function apply(ctx, config = {}) {
1405
1533
  * 子 Agent 的流不往手机上推。
1406
1534
  */
1407
1535
  ctx.on('agent/assistant-stream', ({ agent, frame }) => {
1408
- // 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
1409
- // 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
1410
- if (!STREAMING_ENABLED) return
1411
-
1412
1536
  const session = agent?.session
1413
1537
  const sessionId = session?.id
1414
1538
  if (!sessionId) return
1415
1539
  if (session.header?.origin === 'subagent') return
1416
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
+
1417
1550
  // 只报一次:这条日志回答的是「事件到底有没有送到插件」。一条都没有,就说明
1418
1551
  // 问题在订阅那一层,不用再往下查。
1419
1552
  if (!sawStreamEvent) {
@@ -1483,17 +1616,45 @@ export function apply(ctx, config = {}) {
1483
1616
  // 会话一建出来就登记,这样手机上的会话选择器不用等到有活动才出现。
1484
1617
  ctx.on('agent/created', ({ agent }) => {
1485
1618
  const sessionId = agent?.session?.id
1486
- 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 })
1487
1631
  store.touchSession(sessionId, {
1488
1632
  running: agent.status === 'running',
1489
1633
  })
1634
+ server?.invalidateNavigation?.()
1635
+ server?.broadcast('navigation-refresh', { reason: 'session-created' })
1490
1636
  server?.broadcast('state', store.snapshot())
1491
1637
  })
1492
1638
 
1493
1639
  ctx.on('agent/disposed', ({ agent }) => {
1494
1640
  const sessionId = agent?.session?.id
1495
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 })
1496
1653
  store.forgetSession(sessionId)
1654
+ // 轨迹那个累计器也清掉(它有自己的一份 per-session 状态)。
1655
+ trajectory.forget(sessionId)
1656
+ server?.invalidateNavigation?.()
1657
+ server?.broadcast('navigation-refresh', { reason: 'session-disposed' })
1497
1658
  server?.broadcast('state', store.snapshot())
1498
1659
  })
1499
1660
 
@@ -1576,6 +1737,8 @@ export function apply(ctx, config = {}) {
1576
1737
  const events = Array.isArray(snap?.events) ? snap.events : []
1577
1738
  const entries = replayHistory(events)
1578
1739
  const capped = capEntries(entries)
1740
+ // 轨迹和聊天记录是**同一批事件的两面**,顺手一起重放出来;不这样就得为它再读一遍盘。
1741
+ const traj = capTrajectory(replayTrajectory(events))
1579
1742
  return {
1580
1743
  entries: capped.entries,
1581
1744
  latest: lastReply(capped.entries, sessionId),
@@ -1585,6 +1748,10 @@ export function apply(ctx, config = {}) {
1585
1748
  // 于是手机上真看到的条数可能比这里多一两条。数字对不上就是假话,
1586
1749
  // 所以这里只声称**读取的上限**,不声称列表长度。
1587
1750
  note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
1751
+ // 轨迹在**插件这一层就按自己的上限截好**再进缓存:不截的话,20 个会话的缓存会
1752
+ // 各压着几兆(见 lib/store.js 的 setReplaySource 那段说明)。
1753
+ trajectory: traj.turns,
1754
+ trajectoryDropped: traj.dropped,
1588
1755
  }
1589
1756
  } catch (err) {
1590
1757
  // 老日志格式对不上、文件坏了,都会走到这儿。退回「手里那份」,**不编**。
@@ -1623,6 +1790,7 @@ export function apply(ctx, config = {}) {
1623
1790
  const entries = replayHistory(tail.events)
1624
1791
  const capped = capEntries(entries)
1625
1792
  if (!capped.entries.length) return null
1793
+ const traj = capTrajectory(replayTrajectory(tail.events))
1626
1794
  const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
1627
1795
  // 「不是全部」有两处会砍:readTailEvents 那层(窗口 / 事件上限)和这里 capEntries 那层
1628
1796
  // (聊天记录条数上限)。**两层都要算进去**——小会话的窗口盖得住整份日志,唯一会砍它的
@@ -1639,6 +1807,10 @@ export function apply(ctx, config = {}) {
1639
1807
  ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)`
1640
1808
  : `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`)
1641
1809
  : null,
1810
+ // 轨迹那一份的「不是全部」由 store 自己说(它知道轨迹的上限),这里只把
1811
+ // 「被砍了多少条」交出去——读取窗口那一层的截断在上面 note 里已经说过一次了。
1812
+ trajectory: traj.turns,
1813
+ trajectoryDropped: traj.dropped,
1642
1814
  }
1643
1815
  }
1644
1816
 
@@ -1687,6 +1859,8 @@ export function apply(ctx, config = {}) {
1687
1859
  latest: got?.latest ?? null,
1688
1860
  truncated: got?.truncated === true,
1689
1861
  note: got?.note ?? null,
1862
+ trajectory: got?.trajectory ?? null,
1863
+ trajectoryDropped: got?.trajectoryDropped ?? 0,
1690
1864
  })
1691
1865
  historyPending.delete(sessionId)
1692
1866
  server?.broadcast('state', store.snapshot())
@@ -1705,6 +1879,10 @@ export function apply(ctx, config = {}) {
1705
1879
  // 这个会话只有这么点内容(读历史有上限,见 HISTORY_MAX_* 那组常量)。
1706
1880
  truncated: hit.truncated === true,
1707
1881
  note: hit.note ?? null,
1882
+ // 同一份记录里重放出来的执行轨迹(「完整」模式要的过程区)。
1883
+ // store 会把它和内存里那份合成一张表,见 store.trajectoryOf。
1884
+ trajectory: hit.trajectory ?? null,
1885
+ trajectoryDropped: hit.trajectoryDropped ?? 0,
1708
1886
  }
1709
1887
  }
1710
1888
  if (historyPending.has(sessionId)) return { entries: null, latest: null, pending: true }
@@ -1816,7 +1994,25 @@ export function apply(ctx, config = {}) {
1816
1994
  * `listDescendants` 读的是父会话自己的目录**事实**,不加载也不唤醒任何子代理——
1817
1995
  * 所以列一下很便宜,也不会把睡着的子代理弄醒。
1818
1996
  */
1819
- async function listSubagents(parentSessionId) {
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) {
1820
2016
  const runtime = subagentServices.runtime
1821
2017
  if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
1822
2018
  if (typeof runtime?.listDescendants !== 'function') {
@@ -1837,7 +2033,7 @@ export function apply(ctx, config = {}) {
1837
2033
  out.push({ id: String(row.id), diagnostic: String(row.reason ?? 'unavailable') })
1838
2034
  continue
1839
2035
  }
1840
- const metrics = subagentMetrics(row.id)
2036
+ const metrics = { tokens: null, durationMs: null, lastTurnCompleted: null }
1841
2037
  out.push({
1842
2038
  id: String(row.id),
1843
2039
  parentId: String(row.parentId ?? ''),
@@ -1854,6 +2050,25 @@ export function apply(ctx, config = {}) {
1854
2050
  return { ok: true, subagents: out, boundSessionId: parentSessionId }
1855
2051
  }
1856
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
+
1857
2072
  /**
1858
2073
  * 某个子智能体**自己的运行过程**。
1859
2074
  *
@@ -1865,7 +2080,7 @@ export function apply(ctx, config = {}) {
1865
2080
  */
1866
2081
  async function subagentTranscript(childId, parentSessionId) {
1867
2082
  if (!childId) return { ok: false, error: '没说要读哪一道子智能体。' }
1868
- const listed = await listSubagents(parentSessionId)
2083
+ const listed = await listSubagents(parentSessionId, { includeMetrics: false, scheduleMetrics: false })
1869
2084
  if (!listed.ok) return listed
1870
2085
  const row = listed.subagents.find((s) => s.id === childId)
1871
2086
  if (!row) return { ok: false, error: '这道子智能体不在当前会话名下。' }
@@ -2162,6 +2377,84 @@ export function apply(ctx, config = {}) {
2162
2377
  + Boolean(canCreate.controller) + ' create=' + typeof canCreate.controller?.create)
2163
2378
  })
2164
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
+
2165
2458
  // ------------------------------------------------------------------
2166
2459
  // 手机上传文件要用的附件服务
2167
2460
  // ------------------------------------------------------------------
@@ -2333,9 +2626,54 @@ export function apply(ctx, config = {}) {
2333
2626
  }
2334
2627
  }
2335
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
+
2336
2674
  const nav = {
2337
2675
  listWorkspaces: (runningIds, force, includeEmpty) =>
2338
- listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
2676
+ listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty, meta: sessionMeta }),
2339
2677
  listSessionsOf: (workspaceId, limit, force, onTitle) =>
2340
2678
  listSessionsOf({
2341
2679
  registry: treeServices.registry,
@@ -2347,15 +2685,38 @@ export function apply(ctx, config = {}) {
2347
2685
  limit,
2348
2686
  force,
2349
2687
  onTitle,
2688
+ meta: sessionMeta,
2350
2689
  }),
2351
2690
  // 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
2352
2691
  // 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
2353
- createSession: (workspaceId) =>
2692
+ createSession: (workspaceId, agentPreset) =>
2354
2693
  createSessionIn({
2355
2694
  controller: canCreate.controller ?? ctx.get('sessionController'),
2356
2695
  registry: treeServices.registry,
2357
2696
  workspaceId,
2697
+ agentPreset,
2358
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
+ },
2359
2720
  // 「这一刻到底能不能建会话」。给 /mini/api/version 用:那是个不用动手、
2360
2721
  // 一条命令就能问的运行时事实,比翻日志快。和 createSession 同一条判据,
2361
2722
  // 免得出现「探测说能、真按了却不行」。
@@ -2374,7 +2735,302 @@ export function apply(ctx, config = {}) {
2374
2735
 
2375
2736
  // 子智能体(2026-10-03)。四条都取**当前绑定会话**那一份,和上面 send / stop
2376
2737
  // 同一套口径:手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
2377
- subagents: (sessionId) => listSubagents(sessionId),
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
+
2378
3034
  subagentTranscript: (childId, sessionId) => subagentTranscript(childId, sessionId),
2379
3035
  stopSubagent: (childId, sessionId, mode) => stopSubagent(childId, sessionId, mode),
2380
3036
  askSubagent: (childId, sessionId, mode, text) => askSubagent(childId, sessionId, mode, text),