dsh-mobile-plugin 0.1.92 → 0.1.94

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.
Files changed (38) hide show
  1. package/README.md +3 -1
  2. package/config/agent-presets/standard/agent.cordis.yml +7 -4
  3. package/lib/desktop-view.cjs +20 -0
  4. package/lib/impl.js +7 -2
  5. package/lib/mirror/pusher.cjs +40 -0
  6. package/lib/mobile/archive-clean.js +17 -18
  7. package/lib/mobile/attachments.js +23 -6
  8. package/lib/mobile/events.js +28 -3
  9. package/lib/mobile/history-derive.js +53 -7
  10. package/lib/mobile/host-bridge/endpoints.js +0 -1
  11. package/lib/mobile/host-bridge/event-synthesis.js +33 -3
  12. package/lib/mobile/host-bridge/shims.js +28 -4
  13. package/lib/mobile/monitor.js +135 -0
  14. package/lib/mobile/session-flags.js +63 -0
  15. package/lib/mobile/session-models.js +6 -0
  16. package/lib/mobile/session-preview.js +70 -0
  17. package/lib/mobile/session-send.js +8 -2
  18. package/lib/mobile/session-snapshot.js +13 -19
  19. package/lib/mobile/sessions.js +48 -91
  20. package/lib/mobile/stream.js +113 -16
  21. package/lib/mobile/uploads.js +14 -7
  22. package/lib/mobile/workspaces.js +17 -3
  23. package/lib/panel/index.html +7 -7
  24. package/lib/reconnect-scheduler.cjs +31 -4
  25. package/lib/relay/lan-network.cjs +156 -12
  26. package/lib/relay/lan-proxy.js +85 -7
  27. package/lib/relay/protocol.js +3 -3
  28. package/lib/relay/tunnel.js +17 -4
  29. package/lib/routes/control-plane.js +11 -5
  30. package/lib/routes/mobile-api.js +22 -1
  31. package/lib/session-cleanup.cjs +160 -27
  32. package/lib/shared/desktop-view-signal.js +66 -0
  33. package/lib/shared/http-util.js +50 -4
  34. package/lib/shared/list-revision.js +20 -1
  35. package/lib/shared/sse-write.js +52 -2
  36. package/lib/sidecar-facade-worker.cjs +16 -2
  37. package/lib/update-install.cjs +71 -13
  38. package/package.json +2 -2
package/README.md CHANGED
@@ -7,6 +7,7 @@ AI-HUB 手机端插件,为 DSH 提供移动 API、远程连接和局域网代
7
7
  ## 下载 AI-HUB 手机 App
8
8
 
9
9
  - [Android APK](https://app.wnluo.com/public/apps/14)
10
+ - [Android APK(GitHub 渠道)](https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk);[历史版本](https://github.com/WNLUO/dsh-mobile-plugin/releases)
10
11
  - [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(若暂时无法打开,可能是 App 仍在等待苹果审核通过。)
11
12
  - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)
12
13
 
@@ -55,7 +56,7 @@ curl http://127.0.0.1:3080/api/relay/pairing
55
56
 
56
57
  移动 API 需要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 使用 SSE 转发宿主事件。创建会话时可传 `workspaceId` 或 `cwd`;前者优先,并将会话归入指定工作区。移动端排序也会同步到宿主工作区。
57
58
 
58
- 配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。
59
+ 配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。删除是「排进待删队列」:会话立刻从列表里消失,日志与投影缓存等宿主下次启动时才从磁盘上删掉 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件会让它读到不存在的日志,那是未捕获异常,会带走整个宿主进程。
59
60
 
60
61
  ## 无头部署
61
62
 
@@ -104,6 +105,7 @@ npm test
104
105
  - npm 包的 `repository` 字段用于关联插件列表条目;修改后需再次发布,注册表信息才会更新。
105
106
  - 插件列表收录通过向 awesome-dsh-plugin 提交 `data/plugins/<owner>__<repo>.yml` 完成。描述中的数字和 API 名称应能由代码核实;列表要求 `description.en`、`category: remote`,包需声明 `dsh.bundle`,仓库需有 `dsh-plugin` topic。
106
107
  - 仓库创建满一天的检查会自动重跑;截图可通过 `screenshots.json` 提供,也可由市场从 README 查找。
108
+ - GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,并且发成正式版 —— 草稿与预发布都不会成为「最新」,README 里那条直连会随之断掉。插件自身走 npm 发布,不在本仓库发 release。
107
109
  - 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
108
110
 
109
111
  ### 发布新版本
@@ -162,9 +162,12 @@
162
162
  searchTimeoutMs: 60000
163
163
  # Without this row the mobile host's tool catalog has no `ask_user_question`,
164
164
  # so the App's question dock could never appear: the model simply reports that
165
- # the tool is unavailable. `@deepseek-ai/dsh-host-apiproxy` already registers
166
- # the `ctx.userQuestions` provider that bridges `ask()` to the mux
167
- # `question/requested` frame, and the plugin's `lib/mobile/questions.js`
168
- # answers it — only the tool row was missing.
165
+ # the tool is unavailable. The plugin's `lib/mobile/questions.js` listens on
166
+ # the `user-questions/request` hook (0.2.x shape) and answers it — only the
167
+ # tool row was missing.
168
+ #
169
+ # The package must be pinned to the runtime's own generation (0.2.0-rc.2):
170
+ # its peerDependencies track the runtime release exactly, and the loader
171
+ # disables the whole row — rather than degrade — when one does not match.
169
172
  - id: tool-ask-user
170
173
  name: '@deepseek-ai/dsh-tool-ask-user'
@@ -19,6 +19,7 @@
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const paths = require('./shared/paths');
22
+ const { desktopViewSignal } = require('./shared/desktop-view-signal.js');
22
23
 
23
24
  /** 单个键(workspaceId)长度上限;超长的一律丢弃(真实 id 只有十几字节)。 */
24
25
  const MAX_KEY_LENGTH = 200;
@@ -36,6 +37,17 @@ function stateFile() {
36
37
  let cached = null;
37
38
  let loaded = false;
38
39
 
40
+ /**
41
+ * 折叠态内容指纹(键排序后序列化)。
42
+ *
43
+ * 只用来回答「这次上报真的改了折叠态吗」:两次 JSON.stringify 的键序不保证一致,
44
+ * 直接比会让同一份状态被认成两次变更。内容没变就既不重写文件、也不给手机推帧。
45
+ */
46
+ function expansionFingerprint(groupExpansion) {
47
+ const keys = Object.keys(groupExpansion).sort();
48
+ return JSON.stringify(keys.map((key) => [key, groupExpansion[key]]));
49
+ }
50
+
39
51
  /** 归一化上报的 groupExpansion:只保留布尔值、限长限量;形状不对返回 null。 */
40
52
  function normalizeGroupExpansion(input) {
41
53
  if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
@@ -91,11 +103,16 @@ function update(input, now = Date.now()) {
91
103
  const groupExpansion = normalizeGroupExpansion(input && input.groupExpansion);
92
104
  if (!groupExpansion) throw new Error('groupExpansion 必须是对象');
93
105
  const rawSource = input && (typeof input.source === 'string' ? input.source : input.storageKey);
106
+ const previous = load();
94
107
  const next = {
95
108
  groupExpansion,
96
109
  receivedAt: now,
97
110
  source: typeof rawSource === 'string' ? rawSource.slice(0, MAX_SOURCE_LENGTH) : '',
98
111
  };
112
+ // 折叠态没变就不落盘、不广播:客户端半边在页面重载 / 切窗口时会重发同一份。
113
+ // 什么都没变却写一次文件、给每台手机推一帧,等于把白问的轮询换成白推的帧。
114
+ const changed = !previous || expansionFingerprint(previous.groupExpansion) !== expansionFingerprint(groupExpansion);
115
+ if (!changed) return snapshot();
99
116
  const file = stateFile();
100
117
  fs.mkdirSync(path.dirname(file), { recursive: true });
101
118
  const tmp = `${file}.tmp`;
@@ -103,6 +120,9 @@ function update(input, now = Date.now()) {
103
120
  fs.renameSync(tmp, file);
104
121
  cached = next;
105
122
  loaded = true;
123
+ // 落盘之后再广播:订阅方收到帧会立刻回来读这条路(GET /desktop-view 读的是这份
124
+ // cached),先通知后落盘会把它引到上一份内容上。
125
+ desktopViewSignal.bump(next.receivedAt);
106
126
  return snapshot();
107
127
  }
108
128
 
package/lib/impl.js CHANGED
@@ -24,7 +24,7 @@ const { authorized } = require('./mobile/auth.js')
24
24
  const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
25
25
  const { createMirrorPusher } = require('./mirror/pusher.cjs')
26
26
  const { randomBytes } = require('node:crypto')
27
- const { lanUrls, advertisedCandidates, orderCandidates } = require('./relay/lan-network.cjs')
27
+ const { lanUrls, advertisedCandidates, orderCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
28
28
  const { resolvePort } = require('./relay/port-allocator.cjs')
29
29
  const { createPreviewService } = require('./relay/preview.js')
30
30
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
@@ -177,6 +177,9 @@ function pairingPayload(state, lanPort) {
177
177
  // 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
178
178
  // 依次重试而不用先回到公网中转去问。
179
179
  lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
180
+ // IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
181
+ // 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
182
+ ipv6Candidates: state.lanEnabled ? ipv6Candidates(port).map(candidate => candidate.url).filter(Boolean) : [],
180
183
  }
181
184
  return {
182
185
  payload,
@@ -292,7 +295,7 @@ function apply(ctx) {
292
295
  const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
293
296
 
294
297
  // 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话。
295
- const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar })
298
+ const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar, lastMobileAt: activity.snapshot })
296
299
  // 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
297
300
  sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
298
301
 
@@ -413,6 +416,8 @@ function apply(ctx) {
413
416
  getSidecarSnapshot: sidecarLifecycle.getSnapshot,
414
417
  // 移动端路由表那边叫它「局域网候选」,取的是同一份私网优先的清单。
415
418
  safeLanCandidates: advertisedCandidates,
419
+ // IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
420
+ safeIpv6Candidates: ipv6Candidates,
416
421
  lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
417
422
  // 配对那条路由自己要签凭据:core 是签 token 的实现,pairingApproval 决定这台机器
418
423
  // 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
@@ -39,6 +39,25 @@ const { observeCompletionEvent } = require('../mobile/completion.js');
39
39
  /** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
40
40
  const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
41
41
  const BG_TICK_INTERVAL_MS = 500;
42
+ /**
43
+ * 没人看的时候,后台 tick 降到这个节拍。
44
+ *
45
+ * 500ms 那条节奏是给「手机正在用」准备的:清单或工作区一变,手机半秒内就该看到。
46
+ * 但宿主一天里大部分时间没人看 —— 那时每 500ms 整轮重建一次清单/工作区,走中转的
47
+ * 还要把快照推过一条隧道,纯属空转。
48
+ *
49
+ * 上限由 sidecar 的快照 TTL 卡着:镜像快照 15s 过期,降频必须明显小于它,
50
+ * 取 5s(= TTL/3,`test/mirror-tick-budget.test.cjs` 钉着这条比例)。
51
+ */
52
+ const IDLE_TICK_INTERVAL_MS = 5_000;
53
+ /**
54
+ * 多久没有手机请求就算「没人看」。
55
+ *
56
+ * 走中转的手机每个请求都会记一次 `mobile/activity.cjs` 的 `note()`;局域网直连的手机
57
+ * 不走那道守卫所以记不到,那种情况另有 `listSignal.watching()` 兜着(有人订阅清单变化时
58
+ * 它为真)。两个判据都不成立时才降频。
59
+ */
60
+ const AUDIENCE_IDLE_MS = 30_000;
42
61
  /**
43
62
  * 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
44
63
  *
@@ -64,6 +83,19 @@ const MAX_CONSECUTIVE_EVENT_BUILDS = 3;
64
83
 
65
84
  function createMirrorPusher(deps) {
66
85
  const { ctx, tunnelConnected, getSidecar, listSignal = sharedListSignal, sessionSignal = sharedSessionSignal } = deps;
86
+ /**
87
+ * 「手机最近来过没有」的取值器(`mobile/activity.cjs` 的 `snapshot` 形态)。
88
+ *
89
+ * 不传就按「有人在看」处理、维持原来 500ms 的节拍:降频是个优化,不是契约,
90
+ * 别让一处漏接线的装配把镜像悄悄降成 5s 一份。
91
+ */
92
+ const lastMobileAt = typeof deps.lastMobileAt === 'function' ? deps.lastMobileAt : null;
93
+ const hasAudience = () => {
94
+ if (listSignal.watching()) return true;
95
+ if (!lastMobileAt) return true;
96
+ const at = Number(lastMobileAt()?.lastMobileAt) || 0;
97
+ return at > 0 && Date.now() - at <= AUDIENCE_IDLE_MS;
98
+ };
67
99
 
68
100
  // 移动镜像推送状态(供诊断/状态面板观测:最近推送时间 + 结果)
69
101
  let mirrorPushAt = 0
@@ -565,9 +597,17 @@ function createMirrorPusher(deps) {
565
597
  let bgQueueBusy = false;
566
598
  // 上一次真的跑过冷会话轮转的时刻(见 BG_COLD_ROTATE_MIN_INTERVAL_MS)。
567
599
  let lastColdRotateAt = 0;
600
+ // 上一轮后台扫描真正跑起来的时刻(见 IDLE_TICK_INTERVAL_MS 的降频)。
601
+ let lastBgScanAt = 0;
568
602
 
569
603
  const processBgQueue = async () => {
570
604
  if (bgQueueBusy) return;
605
+ // 降频:没人看的时候不必每 500ms 重建一轮清单。判据用「手机最近有没有请求」,
606
+ // 而不是「隧道连没连上」—— 隧道连着但人没在用,一样是空转。
607
+ // 有人在看时不加额外下限,节奏仍由 500ms 的定时器自己决定。
608
+ // 事件驱动那条路径(pushSessionImmediately)不受这里影响,会话真的变了仍是立即推。
609
+ if (!hasAudience() && lastBgScanAt && Date.now() - lastBgScanAt < IDLE_TICK_INTERVAL_MS) return;
610
+ lastBgScanAt = Date.now();
571
611
  const online = tunnelConnected();
572
612
  // 隧道未连接时这个 500ms 的 tick 只在**有人听**的时候才有意义:构建出来的快照没有
573
613
  // 任何去处(pushMirrorPayload 会短路),但「清单变了」这件事仍然要说给手机听 ——
@@ -1,30 +1,33 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,并删掉它们的日志。
4
+ * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,把它们排进待删队列。
5
5
  *
6
6
  * 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
7
7
  * 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
8
8
  *
9
- * 删除动作不重写:读日志 header.id → 删会话目录 + 投影缓存只在 session-cleanup.cjs 里存在
10
- * (见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
9
+ * **这里不删磁盘**:只把 id 排进 session-cleanup.cjs 的待删队列,日志留到宿主下次启动时删
10
+ * (见那个文件的 deleteQueuedArchivedSessions)。宿主没有「删除会话」接口,进程活着的时候
11
+ * 它随时可能去打开那些文件 —— 文件不在就是一次未捕获的 ENOENT,宿主整个退出、桌面端闪退
12
+ * (2026-10-20 真机现场)。本进程只把 id 压进删除集,让手机列表立刻看不到它们。
11
13
  *
12
- * **这里不擦宿主的归档集合**(只把删掉的 id 记进本进程的删除集,挡住手机端列表里可能
13
- * 还没失效的旧快照)。进程里擦集合会把桌面端那份会话列表快照里、其实已经删掉的行放回
14
- * 正常列表——桌面端用户看到的就是「清理完它们又回来了,重启 DSH 才真的消失」。归档集合
15
- * 的收尾放在宿主启动时做,见 session-cleanup.cjs 的 pruneOrphanedArchivedSessionsOnce。
14
+ * 也**不擦宿主的归档集合**:进程里擦会把桌面端快照里那些其实已经没有日志的行放回正常列表。
15
+ * 归档集合的收尾同样在宿主启动时做,见 pruneOrphanedArchivedSessionsOnce。
16
16
  */
17
17
 
18
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
19
19
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
20
- const { canonicalDeviceKey, rememberDeletedSessionIds, deletedSessionIdsOf } = require('./session-cache.js');
20
+ const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
21
21
  const { invalidateSessionList } = require('./sessions.js');
22
22
  const { unwrap } = require('./envelope.js');
23
- const { deleteSessionData, sessionIdsWithoutAnyData } = require('../session-cleanup.cjs');
23
+ const { queueArchivedClearIds } = require('../session-cleanup.cjs');
24
24
 
25
25
  /**
26
26
  * POST /api/mobile/v1/workspace/clear-archived(token)
27
27
  * body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
28
+ *
29
+ * deleted = 这次被排进队列、手机上立刻消失的条数(含盘上早就没有数据的那些);日志要到
30
+ * 宿主下次启动才真正从磁盘上消失。
28
31
  */
29
32
  async function handleClearArchived(ctx, req, res) {
30
33
  const body = await readJsonBody(req, 64 * 1024);
@@ -41,17 +44,13 @@ async function handleClearArchived(ctx, req, res) {
41
44
  const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
42
45
  const selected = archivedIds.filter((id) => typeof id === 'string' && id);
43
46
  if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
44
- const removed = await deleteSessionData(selected);
45
- // 删的是列表里可见的行:手机端正开着列表,宿主那份快照可能还带着它们,先在本进程压住。
46
- // 盘上已经没有数据的归档 id 一并压住:上一次删到一半抛错时,已删的那批没记进删除集,
47
- // 宿主的进程内清单却还挂着它们 —— 不补记的话再点清理只回 0,手机上永远清不掉。
48
- const already = deletedSessionIdsOf(ctx);
49
- const cleared = new Set(removed);
50
- for (const id of sessionIdsWithoutAnyData(selected)) if (!already.has(id)) cleared.add(id);
51
- rememberDeletedSessionIds(ctx, cleared);
47
+ // 排不进队列就整次失败(路由回 500):回「已清理」却没排上,重启之后它们原样回来。
48
+ queueArchivedClearIds(selected);
49
+ // 宿主那份快照可能还带着它们,先在本进程压住:手机列表与归档列表立刻看不到。
50
+ rememberDeletedSessionIds(ctx, selected);
52
51
  // 不立刻失效的话,最长 10 秒还能点到已删的行。
53
52
  invalidateSessionList(ctx);
54
- return { status: 200, body: { ok: true, deleted: cleared.size } };
53
+ return { status: 200, body: { ok: true, deleted: selected.length } };
55
54
  });
56
55
  sendJson(res, outcome.status, outcome.body);
57
56
  }
@@ -1,16 +1,24 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节,
5
- * 手机端拿到后要经 session attachment RPC 把图片取回来。
4
+ * 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节。
6
5
  *
7
6
  * 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、
8
7
  * assistant chunk 的 block,以及更深的嵌套 content。全部在这里归一,
9
- * 这样历史与 SSE 暴露给手机端的内联图片形状是同一个。
8
+ * 这样历史与 SSE 暴露给手机端的图片形状是同一个。
9
+ *
10
+ * **默认(引用模式 `opts.referencesOnly`)不取字节**:图片块原样留着 `attachment`
11
+ * 引用,由 history-derive 投影成 `{mediaType, name?, attachmentId}`,手机端要画的时候
12
+ * 按 attachmentId 单张去取(`/session/attachment`)。此前这里把每张图都换成内联 base64,
13
+ * 一页 200 条带十几张截图的响应接近 10MB,每次进会话都要重传一遍。
14
+ *
15
+ * 只有显式传 `referencesOnly: false` 时才回到「经 session attachment RPC 取回字节」的
16
+ * 老行为;取不回字节的块打 `dataUnavailable` 标(语义不变,见 dataUnavailableBlock)。
10
17
  *
11
18
  * 另外一类图片在正文里:`![](/abs/path.png)` 这种指向**宿主本机文件**的写法
12
19
  * (本机绝对路径、file://、~)在手机上取不到字节,也会被 App 当成非 http 图片降级成字面
13
20
  * markdown。这里顺手按同一形状内联成 data URI(实现与预算见 local-images.js)。
21
+ * 这条与附件引用是两件事:它走的是正文里的路径、没有 attachmentId,预算与行为保持原样。
14
22
  */
15
23
 
16
24
  const { unwrap } = require('./envelope.js');
@@ -19,12 +27,16 @@ const { inlineTextImages, createTextImageBudget } = require('./local-images.js')
19
27
 
20
28
  /**
21
29
  * Resolve durable image references before exposing messages to mobile clients.
22
- * Session history stores `{type:'image', attachment}` rather than inline bytes;
23
- * the mobile client resolves these through the session attachment RPC.
30
+ *
31
+ * Session history stores `{type:'image', attachment}` rather than inline bytes.
32
+ * By default (`referencesOnly`) the block is left as a reference and the mobile
33
+ * client fetches the bytes per attachment (`/session/attachment`); no host RPC
34
+ * happens on this path at all. Pass `referencesOnly: false` for the legacy
35
+ * inline-bytes shape.
24
36
  *
25
37
  * Host events can carry content in several places: message/content, inserted
26
38
  * context items, or an assistant/chunk block. Keep this normalization here so
27
- * history and SSE expose the same inline-image shape to mobile clients.
39
+ * history and SSE expose the same image shape to mobile clients.
28
40
  *
29
41
  * Text blocks additionally get local-file image syntax inlined as data URIs
30
42
  * (see local-images.js); `opts.budget` is a shared per-response budget so one
@@ -47,6 +59,9 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
47
59
  if (!event || !event.data || typeof event.data !== 'object') return event;
48
60
  const data = event.data;
49
61
  const budget = opts.budget || createTextImageBudget();
62
+ // 引用模式(默认):图片块只留 attachment 引用,**一次宿主 RPC 都不发**。
63
+ // 只有显式 referencesOnly:false 才走下面那条把字节取回来的老路。
64
+ const referencesOnly = opts.referencesOnly !== false;
50
65
  let nextData = data;
51
66
  let eventChanged = false;
52
67
 
@@ -59,6 +74,8 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
59
74
  if (block.type === 'image' && !block.data && block.attachment) {
60
75
  const attachmentId = block.attachment.attachmentId;
61
76
  if (typeof attachmentId !== 'string' || !attachmentId) return block;
77
+ // 引用模式到此为止:块原样返回,字节由手机端按 attachmentId 单张去取。
78
+ if (referencesOnly) return block;
62
79
  let result;
63
80
  try {
64
81
  result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
@@ -6,9 +6,10 @@
6
6
  * 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
7
7
  * 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
8
8
  * 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
9
- * device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)
10
- * 与 device/heartbeat(心跳)三类自产帧,App 侧靠它们判断「这条流还活着」「该重拉列表」
11
- * 「该给某个会话补正文」并据此决定要不要把兜底轮询降频。
9
+ * device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)、
10
+ * device/desktop-view-changed(来自 lib/shared/desktop-view-signal.js,桌面侧栏折叠态
11
+ * 变了)与 device/heartbeat(心跳)四类自产帧,App 侧靠它们判断「这条流还活着」
12
+ * 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
12
13
  *
13
14
  * 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
14
15
  * 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
@@ -19,6 +20,7 @@
19
20
 
20
21
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
21
22
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
23
+ const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
22
24
  const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
23
25
 
24
26
  const DEDUP_WINDOW = 2048;
@@ -194,6 +196,22 @@ async function handleDeviceStream(ctx, req, res) {
194
196
  if (ac.signal.aborted) return;
195
197
  if (!writeFrame({ type: 'device/session-activity', sessionId: event.sessionId, seq: event.seq, at: event.at })) ac.abort();
196
198
  });
199
+ /**
200
+ * 自产的「桌面侧栏折叠态变了」帧。
201
+ *
202
+ * 桌面端那份 groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报,
203
+ * 落在 lib/desktop-view.cjs;手机要拿到它只能问 GET /desktop-view。过去 App 用
204
+ * 「跟列表刷新走 + 5 分钟最短间隔」去猜它有没有变,稳态下一天白问几百次 —— 而插件
205
+ * 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
206
+ *
207
+ * 只发 updatedAt、不发内容:手机据此重拉那一发,拿到的是与列表同一口径的完整镜像,
208
+ * 也不必让这条「信号流」承担数据同步(它的帧一律可丢、可重放)。
209
+ * 同样必须直订 + 收尾注销,理由与上面两条一致。
210
+ */
211
+ const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
212
+ if (ac.signal.aborted) return;
213
+ if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: event.updatedAt, at: event.at })) ac.abort();
214
+ });
197
215
  /**
198
216
  * 连上就先补一帧「当前状态」。
199
217
  *
@@ -213,6 +231,12 @@ async function handleDeviceStream(ctx, req, res) {
213
231
  if (list && list.revision) {
214
232
  if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
215
233
  }
234
+ // 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
235
+ // 所以补发它不会改变老客户端的任何行为。
236
+ const view = sharedDesktopViewSignal.snapshot();
237
+ if (view && view.updatedAt > 0) {
238
+ if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
239
+ }
216
240
  for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
217
241
  if (!(seq > 0)) continue;
218
242
  if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
@@ -279,6 +303,7 @@ async function handleDeviceStream(ctx, req, res) {
279
303
  clearInterval(keepAlive);
280
304
  unsubscribeSignal();
281
305
  unsubscribeActivity();
306
+ unsubscribeDesktopView();
282
307
  releaseSignal();
283
308
  try {
284
309
  if (!res.writableEnded) {
@@ -99,6 +99,46 @@ function toolResultParts(msg) {
99
99
  };
100
100
  }
101
101
 
102
+ /**
103
+ * 图片块 → 移动端图片条目(`ChatMessage.images[]` / 工具块的 `images[]` 同一个形状)。
104
+ *
105
+ * 两条互斥的出口:
106
+ * - 块上有 `attachmentId`:下发**引用** `{ mediaType, name?, attachmentId }`,**不带 data**。
107
+ * 字节由手机端要画的时候按 attachmentId 单张去取(`/session/attachment`),
108
+ * 历史响应因此不再背着几十张截图的 base64。
109
+ * - 块里本来就只有内联 `data`、没有 attachmentId:照旧下发 `{ mediaType, data, name? }`。
110
+ * 这条兜底不能删——老日志、工具结果里存在这种直接带字节的图片块。
111
+ *
112
+ * 两条都要求 mediaType 可读(块上没有就退到 attachment 上的),否则 App 认不出来、
113
+ * 也没有可显示的动作,返回 null 让调用方丢掉它。
114
+ */
115
+ function imageEntryOf(block) {
116
+ if (!block || typeof block !== 'object') return null;
117
+ const attachment = block.attachment && typeof block.attachment === 'object' ? block.attachment : {};
118
+ const attachmentId = typeof attachment.attachmentId === 'string' ? attachment.attachmentId : '';
119
+ const mediaType = typeof block.mediaType === 'string' && block.mediaType
120
+ ? block.mediaType
121
+ : (typeof attachment.mediaType === 'string' ? attachment.mediaType : '');
122
+ if (!mediaType) return null;
123
+ const name = typeof block.name === 'string'
124
+ ? block.name
125
+ : (typeof attachment.name === 'string' ? attachment.name : undefined);
126
+ if (attachmentId) return { mediaType, ...(name !== undefined ? { name } : {}), attachmentId };
127
+ if (typeof block.data === 'string' && block.data) return { mediaType, data: block.data, ...(name !== undefined ? { name } : {}) };
128
+ return null;
129
+ }
130
+
131
+ /** 把 tool/result 的图片块收成移动端图片条目(两种出口见 imageEntryOf)。 */
132
+ function toolResultImages(parts) {
133
+ const out = [];
134
+ for (const part of parts) {
135
+ if (!part || typeof part !== 'object' || part.type !== 'image') continue;
136
+ const entry = imageEntryOf(part);
137
+ if (entry) out.push(entry);
138
+ }
139
+ return out;
140
+ }
141
+
102
142
  /** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
103
143
  function buildToolResultMap(events) {
104
144
  const map = new Map();
@@ -115,10 +155,7 @@ function buildToolResultMap(events) {
115
155
  if (part && typeof part === 'object' && part.type === 'image') return '';
116
156
  try { return JSON.stringify(part); } catch { return ''; }
117
157
  }).filter(Boolean).join('');
118
- const images = parts
119
- .filter((part) => part && typeof part === 'object' && part.type === 'image'
120
- && typeof part.data === 'string' && typeof part.mediaType === 'string')
121
- .map((part) => ({ mediaType: part.mediaType, data: part.data, ...(typeof part.name === 'string' ? { name: part.name } : {}) }));
158
+ const images = toolResultImages(parts);
122
159
  const diffs = normalizeResultDiffs(e.data.meta);
123
160
  map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}), ...(diffs ? { diffs } : {}) });
124
161
  }
@@ -142,8 +179,11 @@ function parseBlocks(blocks, toolResults) {
142
179
  // The image attachment resolver marked this block as unavailable. Keep it out of
143
180
  // visible text; pushMessage projects its retry metadata onto unavailableImages.
144
181
  continue;
145
- } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mediaType === 'string') {
146
- out.push({ type: 'image', mediaType: b.mediaType, data: b.data, name: typeof b.name === 'string' ? b.name : undefined });
182
+ } else if (b.type === 'image') {
183
+ // 有 attachmentId 走引用、只有内联 data 的走兜底(见 imageEntryOf)。两者都认不出
184
+ // 就整块丢掉——与旧行为一致,绝不能把它兜底成一段 JSON 文本。
185
+ const entry = imageEntryOf(b);
186
+ if (entry) out.push({ type: 'image', ...entry });
147
187
  } else if (b.type === 'tool-call') {
148
188
  // tool-call 块字段为 id/name/arguments(与 harness toAssistantBlock 同口径)。
149
189
  const callId = b.id != null ? String(b.id) : undefined;
@@ -336,10 +376,16 @@ function deriveMessages(events) {
336
376
  return rest;
337
377
  });
338
378
  const text = blocksToText(blocks);
379
+ // 图片条目统一投影:引用模式带 attachmentId 不带 data,内联兜底带 data 不带 attachmentId。
339
380
  const images = [
340
381
  ...blocks.filter((b) => b.type === 'image'),
341
382
  ...toolImages,
342
- ].map(({ mediaType, data, name }) => ({ mediaType, data, name }));
383
+ ].map(({ mediaType, data, name, attachmentId }) => ({
384
+ mediaType,
385
+ ...(typeof name === 'string' ? { name } : {}),
386
+ ...(typeof attachmentId === 'string' && attachmentId ? { attachmentId } : {}),
387
+ ...(typeof attachmentId === 'string' && attachmentId ? {} : { data }),
388
+ }));
343
389
  const unavailableImages = (Array.isArray(msg.content) ? msg.content : [])
344
390
  .filter((block) => block && block.type === 'image' && block.dataUnavailable === true)
345
391
  .map((block) => {
@@ -110,7 +110,6 @@ const ENDPOINTS = {
110
110
 
111
111
  // skills / subagents
112
112
  'skills.list': inv('skills', 'list'),
113
- 'subagents.history': inv('subagents', 'history'),
114
113
  'subagents.prompt': inv('subagents', 'prompt', (p) => ({
115
114
  request: {
116
115
  ...pick(p, ['parentSessionId', 'childSessionId', 'mode', 'content', 'clientTimeZone']),
@@ -21,6 +21,28 @@ function sessionHint(request) {
21
21
  return typeof sessionId === 'string' && sessionId ? sessionId : null;
22
22
  }
23
23
 
24
+ /**
25
+ * 这条单会话流该按哪种地址订阅。
26
+ *
27
+ * 子会话必须换成 `{kind:'subagent'}` 的 durable 地址:宿主 `validateAddress` 对
28
+ * `origin === 'subagent'` 的会话拿普通地址一律回 agent-busy,subscribe 直接失败 ——
29
+ * 表现就是「子代理页面打得开,但内容永远不自己更新」。请求体里带上 `parentSessionId`
30
+ * 就是「这是个子会话」的信号(与历史那条同源,见 stream.js 的 handleStream)。
31
+ */
32
+ function sessionAddress(payload) {
33
+ const sessionId = payload && payload.sessionId;
34
+ const parentSessionId = payload && payload.parentSessionId;
35
+ if (typeof parentSessionId === 'string' && parentSessionId) {
36
+ return {
37
+ kind: 'subagent',
38
+ parentSessionId,
39
+ childSessionId: sessionId,
40
+ mode: (payload && payload.mode) || 'continuable',
41
+ };
42
+ }
43
+ return { kind: 'session', sessionId };
44
+ }
45
+
24
46
  /** 一条流跑不通不该拖垮其余流:这里把异常吃掉,只丢这一条源的帧。 */
25
47
  async function* tolerate(frames, what, ctx) {
26
48
  try {
@@ -207,20 +229,28 @@ async function* deviceFrames(ctx, signal) {
207
229
  /** 旧 `events.mux`:设备级帧流,带 sessionId 时收窄成单会话视角。 */
208
230
  function muxFrames(ctx, request, signal) {
209
231
  const sessionId = sessionHint(request);
232
+ const payload = request && request.payload;
210
233
  return (async function* run() {
211
234
  if (!sessionId) {
212
235
  yield* deviceFrames(ctx, signal);
213
236
  return;
214
237
  }
238
+ const address = sessionAddress(payload);
239
+ // 子会话没有 job 面板(任务归属父会话),也就不订阅那一条:拿子会话 id 去问 job.list
240
+ // 只会白打一发,而且它的失败会被 tolerate 吞成一条警告。
215
241
  const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
216
- const jobs = openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx));
242
+ const jobs = address.kind === 'session'
243
+ ? openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx))
244
+ : null;
217
245
  // assistantStream: 逐块增量只走这条流的 assistant-stream 通知(见 followFramePayloads)。
218
246
  const follow = openStream(ctx, 'session', 'follow', {
219
- request: { address: { kind: 'session', sessionId }, maxMessages: 1, assistantStream: true },
247
+ request: { address, maxMessages: 1, assistantStream: true },
220
248
  }, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
221
249
  const projections = projectionState();
222
250
  const followState = { turn: null, step: null, streaming: false };
223
- const sources = await Promise.all([['control', control], ['jobs', jobs], ['follow', follow]].map(([source, promise]) => promise.then(async function* wrap(iterable) {
251
+ const opening = [['control', control], ['follow', follow]];
252
+ if (jobs !== null) opening.push(['jobs', jobs]);
253
+ const sources = await Promise.all(opening.map(([source, promise]) => promise.then(async function* wrap(iterable) {
224
254
  for await (const frame of iterable) {
225
255
  if (source === 'follow') {
226
256
  // follow 一帧可能产出 0..n 个 mux 帧(事件原样 + 增量通知合成 chunk 事件)。
@@ -13,13 +13,36 @@ const { invoke, invokeEnvelope, ok, fail, pick, warn, deadline, openStream, SYNT
13
13
  * follow 拿游标(读完首帧立刻 break,网关会取消这条流),再翻一页历史。
14
14
  * 返回值把 records 拆回旧形状的 events(history-derive.js 只认 events/hasMore)。
15
15
  */
16
+ /**
17
+ * 这次 history 要读的是「谁」的地址。
18
+ *
19
+ * 普通会话是 `{kind:'session'}`;子会话必须换成**带父会话的 durable 地址**
20
+ * `{kind:'subagent'}` —— 宿主对 `origin === 'subagent'` 的会话拿普通地址一律回
21
+ * agent-busy(见 dsh-api-session-controller 的 validateAddress),这跟续话那条
22
+ * 是同一个门。`mode` 也要带:宿主会拿它跟子会话自己的 descriptor 对一遍。
23
+ */
24
+ function historyAddress(payload) {
25
+ const sessionId = payload && payload.sessionId;
26
+ const parentSessionId = payload && payload.parentSessionId;
27
+ if (typeof parentSessionId === 'string' && parentSessionId) {
28
+ return {
29
+ kind: 'subagent',
30
+ parentSessionId,
31
+ childSessionId: sessionId,
32
+ mode: (payload && payload.mode) || 'continuable',
33
+ };
34
+ }
35
+ return { kind: 'session', sessionId };
36
+ }
37
+
16
38
  async function historyViaPage(ctx, payload, signal) {
17
39
  const sessionId = payload && payload.sessionId;
18
40
  if (typeof sessionId !== 'string' || !sessionId) return fail({ code: 'bad-request', message: '缺少 sessionId' }, 'bad-request');
41
+ const address = historyAddress(payload);
19
42
  let throughSeq = -1;
20
43
  try {
21
44
  const frames = await openStream(ctx, 'session', 'follow', {
22
- request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
45
+ request: { address, maxMessages: 1 },
23
46
  }, deadline(signal, SYNTHETIC_DEADLINE_MS));
24
47
  for await (const frame of frames) {
25
48
  if (frame && frame.type === 'snapshot' && Number.isSafeInteger(frame.cursor)) {
@@ -31,7 +54,7 @@ async function historyViaPage(ctx, payload, signal) {
31
54
  return fail(error);
32
55
  }
33
56
  const request = {
34
- address: { kind: 'session', sessionId },
57
+ address,
35
58
  throughSeq,
36
59
  ...pick(payload, ['maxMessages', 'beforeSeq', 'turnWindow']),
37
60
  };
@@ -86,10 +109,11 @@ async function modelCatalog(ctx, payload, signal) {
86
109
  : null)
87
110
  : null;
88
111
  const effective = sessionSelection && sessionSelection.provider && sessionSelection.model ? sessionSelection : current;
89
- const routable = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
112
+ const routableProviders = Array.isArray(catalog.routableProviders) ? catalog.routableProviders : [];
90
113
  return ok({
91
114
  ...(effective ? { current: effective } : {}),
92
- routable: Boolean(effective && routable.includes(effective.provider)),
115
+ routable: Boolean(effective && routableProviders.includes(effective.provider)),
116
+ routableProviders,
93
117
  groups: Array.isArray(catalog.groups) ? catalog.groups : [],
94
118
  });
95
119
  }