dsh-mobile-plugin 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -138,7 +138,7 @@ npm test
138
138
  1. 跑 `node scripts/bump-version.mjs` 更新版本号(演示页的 `DEMO_VERSION` 会一起改,别手改)。
139
139
  2. 改过 `React/` 就跑 `cd React && pnpm run build`。
140
140
  3. 提交这次发版该带的文件,别 `git add -A`。
141
- 4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag 没打过、不比上一版低)、工作区干不干净、面板产物有没有跟上面板源码、整套测试、真打一次包看文件清单。任何一步红就停,并说明该修哪里。**它要求在提交之后跑**,因为查的是「发出去的那一份」。
141
+ 4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag 没打过、不比上一版低)、工作区干不干净、面板产物跟不跟得上源码(拿当前源码真重建一次,与盘上那份逐字节比)、整套测试、真打一次包看文件清单。任何一步红就停,并说明该修哪里。**它要求在提交之后跑**,因为查的是「发出去的那一份」。
142
142
  5. 把提交同步到公开仓库 `WNLUO/dsh-mobile-plugin`。
143
143
  6. 推一个与版本号同名的 tag(版本 0.1.99 就推 `v0.1.99`)。那个仓库的 `.github/workflows/publish.yml` 会跑测试并直接发布,不需要人在浏览器里点二次验证 —— 它走 npm 的 trusted publishing(OIDC),npm 认的是这条流水线而不是某个令牌。
144
144
  7. 等流水线绿。它末尾自己会核 `npm view dsh-mobile-plugin version`,你也可以手动看一眼。
package/lib/impl.js CHANGED
@@ -27,7 +27,7 @@ const { viaChannel } = require('./shared/via-header')
27
27
  const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
28
28
  const { createMirrorPusher } = require('./mirror/pusher.cjs')
29
29
  const { randomBytes } = require('node:crypto')
30
- const { lanUrls, advertisedCandidates, orderCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
30
+ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
31
31
  const { resolvePort } = require('./relay/port-allocator.cjs')
32
32
  const { createPreviewService } = require('./relay/preview.js')
33
33
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
@@ -98,6 +98,8 @@ export const RemoteControlSettingsSchema = z.object({
98
98
  // 手机优先用哪条地址:空 = 不指定(按宿主网卡优先级排)。选择只挪候选顺序、不删候选,
99
99
  // 理由与边界见 relay/lan-network.cjs 的 orderCandidates 与 relay.json 那条地图条目。
100
100
  preferredLanAddress: z.string().max(64).default(''),
101
+ // 用户手填的直连地址(`manualLanUrl`,内网穿透域名)也随二维码一起走,见 lan-network.cjs。
102
+ manualLanUrl: z.string().max(200).default(''),
101
103
  })
102
104
 
103
105
 
@@ -115,6 +117,7 @@ function configOf(state) {
115
117
  // 不会把它回显给手机、二维码或日志。
116
118
  registerSecret: typeof state.registerSecret === 'string' ? state.registerSecret : '',
117
119
  preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
120
+ manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
118
121
  }
119
122
  }
120
123
 
@@ -173,7 +176,9 @@ function createConfigStore(state) {
173
176
  function pairingPayload(state, lanPort) {
174
177
  const port = Number.isInteger(lanPort) ? lanPort : state.lanPort
175
178
  // 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
176
- const candidates = state.lanEnabled ? orderCandidates(advertisedCandidates(port), state.preferredLanAddress) : []
179
+ const candidates = state.lanEnabled
180
+ ? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
181
+ : []
177
182
  const urls = candidates.map(candidate => candidate.url).filter(Boolean)
178
183
  const relayUrl = state.relayUrl || ''
179
184
  const payload = {
@@ -468,8 +473,8 @@ function apply(ctx) {
468
473
  // 一进来就 ReferenceError。同一个 apply() 里给 createStatusView 传的是
469
474
  // `sidecarLifecycle.getSnapshot`,两处不一致才让这个笔误活了下来。
470
475
  getSidecarSnapshot: sidecarLifecycle.getSnapshot,
471
- // 移动端路由表那边叫它「局域网候选」,取的是同一份私网优先的清单。
472
- safeLanCandidates: advertisedCandidates,
476
+ // 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
477
+ safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
473
478
  // IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
474
479
  safeIpv6Candidates: ipv6Candidates,
475
480
  lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
package/lib/index.js CHANGED
@@ -158,7 +158,8 @@ export function apply(ctx) {
158
158
  .then(() => {
159
159
  log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
160
160
  // 每 10 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
161
- // 自动安装仍由开关控制。补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
161
+ // 自动安装仍由开关控制,开着就**不等正在跑的对话**(`auto` 那一档,见 update-install)。
162
+ // 补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
162
163
  const timer = setInterval(() => {
163
164
  deviceOta.reconcileOfficial()
164
165
  .then(() => updater.reconcileInstalledGeneration({ runtime, log }))
@@ -14,10 +14,16 @@
14
14
  * 不这么做就只能让用户重新扫一次码。升级之后所有流量仍由这把钥匙端到端加密。
15
15
  *
16
16
  * 换成什么不在本次判断里:拿到完整一套(凭据 + 密钥 + 标识)才回补丁,缺一样就当没升级。
17
+ *
18
+ * **请求体必须现读**(2026-10-25 修):这条路服务的恰好是协议 v1 的老会话,而老会话发的
19
+ * 是明文 —— 守卫只为「带信封」的那条路预先解好 `req.body`,明文请求的字节还挂在 socket 上
20
+ * 没人读过。原来这里直接取 `req.body`,于是永远拿到空对象、宿主每次都回「没升级」,手机
21
+ * 那边一次都没成功过(真机表现是「App 与插件都更新了,设备行仍旧写链路未加密」)。
22
+ * 现在与其它移动端路由同一条读法(`readJsonBody`:`req.body` 有就用,没有就读流)。
17
23
  */
18
24
 
19
25
  const crypto = require('node:crypto');
20
- const { sendJson } = require('../shared/http-util.js');
26
+ const { sendJson, readJsonBody } = require('../shared/http-util.js');
21
27
  const { DEVICE_KEY_RE, KEY_ID_RE } = require('../shared/e2ee-pair.js');
22
28
 
23
29
  /** 请求出示的设备凭据:独立头优先,`Authorization` 只用于兼容早期客户端。 */
@@ -51,14 +57,16 @@ function deviceSecretOf(body) {
51
57
  * @param {object} res
52
58
  * @param {{ instanceId: string, store: object, log?: Function }} deps
53
59
  */
54
- function handleCredentialUpgrade(req, res, deps) {
60
+ async function handleCredentialUpgrade(req, res, deps) {
55
61
  const instanceId = String((deps && deps.instanceId) || '');
56
62
  const store = deps && deps.store;
57
63
  if (!store) return sendJson(res, 503, { ok: false, error: { code: 'unavailable', message: '凭据库不可用' } });
58
64
 
59
65
  const presented = presentedCredential(req);
60
66
  const record = presented ? store.lookup(presented, instanceId) : null;
61
- const body = req.body && typeof req.body === 'object' ? req.body : {};
67
+ // 见文件头:明文请求的体没人读过,必须在这里现读(读法与其它移动端路由一致)。
68
+ const parsed = await readJsonBody(req, 64 * 1024);
69
+ const body = parsed && typeof parsed === 'object' ? parsed : {};
62
70
  const meta = body.device && typeof body.device === 'object' ? body.device : body;
63
71
  const deviceId = String(meta.deviceId || meta.id || (record && record.deviceId) || '');
64
72
 
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端右栏「文件浏览器」的目录列表:**文件与文件夹都列**,每条带类型、大小、修改时间。
5
+ *
6
+ * 与 `/directory/list` 的分工:那条是「为设备挑一个工作区目录」用的,走宿主 directoryPicker,
7
+ * 只列**能进去的目录**、每条只有 `{name,path,hidden}`(见 lib/mobile/workspaces.js)。右栏
8
+ * 要的是「这个目录里有什么」——文件也是内容,而且要知道类型、大小、修改时间,所以那条路
9
+ * 一个字段都对不上。老那条**一个字都不改**:选目录那条流程还挂在它上面。
10
+ *
11
+ * 这条直接读宿主自己的文件系统,**不经 directoryPicker 网关**。能力面没有因此变大:宿主
12
+ * 那份 browse 后端与本地兜底原语(lib/mobile/directory-browse.js)本来都是全文件系统范围、
13
+ * 没有白名单,`workspace.create` 也接受任意已存在目录;而「桌面 profile 宿主 + 手机远程连」
14
+ * 这一格宿主只服务 native,经网关只会拿到那句 `needs the browse capability`,右栏一个条目
15
+ * 都没有。策略原语(隐藏项标记、上限、面包屑)直接 import 那份副本,不抄第二份。
16
+ *
17
+ * 契约(App 侧 app/src/services/viewmodels/apiPaths.ts 的 directoryListing 同时按这份改):
18
+ * - `path` 可选、绝对路径;缺省 / 空串 / 全空白 = 被控端主目录(os.homedir())。相对路径
19
+ * 直接 400 —— 不拿宿主进程的当前目录去猜,那是另一个语义;
20
+ * - entries 名字升序(**大小写不敏感**),文件与文件夹混在一份里,分组由客户端做;
21
+ * - hidden = 名字以 '.' 开头,照旧标记但返回,过滤交给客户端;
22
+ * - kind 由 stat 得出(**跟随符号链接**):目录是 'folder',其余是 'file',指向目录的
23
+ * 链接算 'folder';
24
+ * - size(字节)/ modified(epoch 毫秒)**只对文件给**;单条 stat 失败(断链、权限)按
25
+ * 'file' 返回且不带这两格 —— 一个坏条目不许把整条列表带走;
26
+ * - 上限复用 directory-browse.js 的 DEFAULT_MAX_ENTRIES,超了截断并置 truncated = true;
27
+ * - crumbs 复用 directory-browse.js 的 ancestryCrumbs(根 → 当前),形状与选目录那条一致。
28
+ * 失败一律 400 + `{ ok:false, error:{ code, message } }`,code 取 bad-request / not-found /
29
+ * not-directory / permission,message 是简短英文,人话由 App 侧自己的字典翻。
30
+ */
31
+
32
+ const { opendir, stat } = require('node:fs/promises');
33
+ const { join, resolve } = require('node:path');
34
+
35
+ const { sendJson } = require('../shared/http-util');
36
+ const { DEFAULT_MAX_ENTRIES, ancestryCrumbs, fullyQualified, homeDirectory } = require('./directory-browse.js');
37
+
38
+ /** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
39
+ function fail(res, code, message) {
40
+ return sendJson(res, 400, { ok: false, error: { code, message } });
41
+ }
42
+
43
+ /** 任意抛出值 → 文案(abort reason 之类可以不是 Error)。 */
44
+ function messageOf(error) {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
47
+
48
+ /** 一次失败的 syscall → 契约里那四个码之一。 */
49
+ function codeOf(error) {
50
+ const raw = error && typeof error === 'object' ? error.code : undefined;
51
+ if (raw === 'EACCES' || raw === 'EPERM') return 'permission';
52
+ if (raw === 'ENOENT' || raw === 'ENOTDIR') return 'not-found';
53
+ return 'bad-request';
54
+ }
55
+
56
+ /**
57
+ * 名字升序、大小写不敏感(契约)。折成同一个主权重后仍视为相同的(`README.md` 与
58
+ * `readme.md`)用码位序兜底:否则比较器回 0,顺序就跟 readdir 的返回顺序走,同一个目录
59
+ * 两次请求可能给出不同顺序。
60
+ */
61
+ function compareNames(a, b) {
62
+ const folded = a.localeCompare(b, undefined, { sensitivity: 'base' });
63
+ if (folded !== 0) return folded;
64
+ return a < b ? -1 : a > b ? 1 : 0;
65
+ }
66
+
67
+ /** 一条列表行:stat 跟到最终目标上,目录为 folder,其余为 file(断了也照样是 file)。 */
68
+ async function listingRow(directory, name) {
69
+ const path = join(directory, name);
70
+ const row = { name, path, hidden: name.startsWith('.') };
71
+ let stats = null;
72
+ try {
73
+ stats = await stat(path);
74
+ } catch {
75
+ // 断链 / 权限:这一条按 file 返回、不带 size 与 modified,整条列表继续。
76
+ }
77
+ if (stats && stats.isDirectory()) {
78
+ row.kind = 'folder';
79
+ return row;
80
+ }
81
+ row.kind = 'file';
82
+ if (stats) {
83
+ row.size = stats.size;
84
+ row.modified = Math.round(stats.mtimeMs);
85
+ }
86
+ return row;
87
+ }
88
+
89
+ /**
90
+ * GET /api/mobile/v1/directory/listing?path=…(token,path 缺省 = 被控端主目录)
91
+ *
92
+ * 200:`{ ok:true, listing:{ path, home, crumbs, entries, truncated } }`。
93
+ * 名字必须整体排序(大小写不敏感)才谈得上「升序」,所以这里读全量名字再截断:上限约束的
94
+ * 是随响应下发的行数与后面逐条 stat 的次数,那两样才是这条路的成本。
95
+ */
96
+ async function handleDirectoryListing(_ctx, req, res) {
97
+ const url = new URL(req.url, 'http://x');
98
+ const asked = (url.searchParams.get('path') || '').trim();
99
+ if (asked && !fullyQualified(asked)) {
100
+ return fail(res, 'bad-request', `cannot list "${asked}": not a fully qualified path`);
101
+ }
102
+ const home = homeDirectory();
103
+ const target = resolve(asked || home);
104
+ let info;
105
+ try {
106
+ // 先 stat 一次:它把「不存在」「不是目录」「权限不够」三件事分开,也跟随符号链接
107
+ //(指向目录的链接按目录列)。opendir 在非目录上的报错各平台不一致,分不出这三种。
108
+ info = await stat(target);
109
+ } catch (error) {
110
+ return fail(res, codeOf(error), `cannot list ${target}: ${messageOf(error)}`);
111
+ }
112
+ if (!info.isDirectory()) {
113
+ return fail(res, 'not-directory', `cannot list ${target}: not a directory`);
114
+ }
115
+
116
+ const names = [];
117
+ try {
118
+ const level = await opendir(target);
119
+ try {
120
+ for (;;) {
121
+ const dirent = await level.read();
122
+ if (dirent === null) break;
123
+ names.push(dirent.name);
124
+ }
125
+ } finally {
126
+ await level.close();
127
+ }
128
+ } catch (error) {
129
+ return fail(res, codeOf(error), `cannot list ${target}: ${messageOf(error)}`);
130
+ }
131
+
132
+ names.sort(compareNames);
133
+ const truncated = names.length > DEFAULT_MAX_ENTRIES;
134
+ const kept = truncated ? names.slice(0, DEFAULT_MAX_ENTRIES) : names;
135
+ const entries = [];
136
+ for (const name of kept) entries.push(await listingRow(target, name));
137
+
138
+ sendJson(res, 200, {
139
+ ok: true,
140
+ listing: { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated },
141
+ });
142
+ }
143
+
144
+ module.exports = { handleDirectoryListing };
@@ -170,8 +170,14 @@ function buildToolResultMap(events) {
170
170
  * `items` 里的 tool-call 块本来就是同一张表构出来的,输出/图片/diff 再传一遍等于同一份
171
171
  * 工具输出在一条响应里走两趟。客户端打补丁时状态照写、正文只在给了的时候才覆盖,所以
172
172
  * 那几格省掉是安全的;消息块不在这份窗口里时才带全。
173
+ *
174
+ * `bodies: false`(增量拉取)连「消息块不在这份窗口里」的那些也只回状态。理由是同一个:
175
+ * 增量拉取的调用方手上已经有一份完整的会话,缺的只是「这几次调用结束了没有」;把整窗的
176
+ * 工具输出再灌一遍,等于每 15 秒把最近两百条消息里的工具输出重发一次(读文件、跑命令的
177
+ * 输出几十到几百 KB 很常见)。要全文的那条路是入口整份拉取(无游标)与展开时重新拉。
173
178
  */
174
- function toolResultPatchesFor(events, items) {
179
+ function toolResultPatchesFor(events, items, options = {}) {
180
+ const bodies = options.bodies !== false;
175
181
  const delivered = new Set();
176
182
  for (const item of items || []) {
177
183
  if (!item || !Array.isArray(item.blocks)) continue;
@@ -183,7 +189,7 @@ function toolResultPatchesFor(events, items) {
183
189
  }
184
190
  return [...buildToolResultMap(events).entries()].map(([callId, result]) => {
185
191
  const state = result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok';
186
- if (delivered.has(callId)) return { callId, state };
192
+ if (!bodies || delivered.has(callId)) return { callId, state };
187
193
  return {
188
194
  callId,
189
195
  state,
@@ -373,6 +379,19 @@ function deriveMessages(events) {
373
379
  if (bucket.length > 0) presentedByTurn.set(data.turn, bucket);
374
380
  }
375
381
 
382
+ /**
383
+ * 这一轮**被 write / edit 动过**的文件,按轮攒。
384
+ *
385
+ * 为什么按轮而不是按单条消息:一份文件是一轮里某一步写的,而收尾答复那条消息自己一个
386
+ * 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的(2026-10-25
387
+ * 现场:手机上那个文件名一直是灰的,数一遍 54 条消息只有 4 条带得动这份清单)。
388
+ * 桌面 `dsh-client-ui-deliverables` 的 `selectProducedFiles(owner)` 也是按**整轮**取。
389
+ *
390
+ * 攒完在循环之后回填:同一轮里先发的消息也要能看到这一轮后几步才写出来的文件。
391
+ */
392
+ const producedByTurn = new Map();
393
+ const producedItems = [];
394
+
376
395
  // 单步计时预扫描:为每条 assistant 消息派生「本轮」的 ttft / decode / 输出 token
377
396
  // (对齐桌面 turn-metrics/assistant-timing:ttftMs = 首个非空 delta − step/start;
378
397
  // decodeMs = 落定 − 首个 delta;outputTokens = assistant/message.usage.outputTokens)。
@@ -490,17 +509,27 @@ function deriveMessages(events) {
490
509
  seq: e.seq,
491
510
  time: e.time,
492
511
  };
493
- const producedFiles = blocks.filter((block) => block.type === 'tool-call' && (block.name === 'write' || block.name === 'edit') && block.state === 'ok').map((block) => {
494
- try {
495
- const args = JSON.parse(block.argsRaw || '{}');
496
- return typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
497
- } catch { return null; }
498
- }).filter((path) => typeof path === 'string');
499
- if (producedFiles.length > 0) item.producedFiles = [...new Set(producedFiles)];
500
- // 这一轮模型显式声明的交付文件:轮尾那张交付卡的唯一依据(与 producedFiles 分开,
501
- // 后者只给收尾正文的行内引用解析用)。事件没有 turn 号的(老日志)不挂。
502
- const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
503
- if (turn !== undefined) {
512
+ // 这一轮的轮号(老日志没有):产出清单与交付声明都按它归并。
513
+ const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
514
+ // 本条消息自己写 / 改过的文件:并进**这一轮**的那一份,不单独挂在它自己头上。
515
+ const ownWrites = blocks
516
+ .filter((block) => block.type === 'tool-call' && (block.name === 'write' || block.name === 'edit') && block.state === 'ok')
517
+ .map((block) => {
518
+ try {
519
+ const args = JSON.parse(block.argsRaw || '{}');
520
+ return typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
521
+ } catch { return null; }
522
+ })
523
+ .filter((path) => typeof path === 'string');
524
+ if (turn !== undefined && ownWrites.length > 0) {
525
+ const bucket = producedByTurn.get(turn) || new Set();
526
+ for (const path of ownWrites) bucket.add(path);
527
+ producedByTurn.set(turn, bucket);
528
+ }
529
+ // 回填要等循环跑完(这一轮后面几步写的也要算进来),先把这条记下来。
530
+ if (turn !== undefined) producedItems.push({ item, turn });
531
+ // 这一轮模型显式声明的交付文件:轮尾那张交付卡的唯一依据(与产出的清单分开)。
532
+ if (turn !== undefined) {
504
533
  const presented = presentedByTurn.get(turn);
505
534
  if (presented && presented.length > 0) item.presentedFiles = presented.map((file) => ({ ...file }));
506
535
  }
@@ -756,6 +785,16 @@ function deriveMessages(events) {
756
785
  }
757
786
  // tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
758
787
  }
788
+ // 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
789
+ // 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
790
+ // 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
791
+ // `viewmodels/file-mentions`)。
792
+ if (producedItems.length > 0) {
793
+ for (const { item, turn } of producedItems) {
794
+ const bucket = producedByTurn.get(turn);
795
+ if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
796
+ }
797
+ }
759
798
  // 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
760
799
  out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
761
800
  return out;
@@ -304,19 +304,31 @@ function projectionState() {
304
304
  * `Array.isArray(items)` 判定失败、整帧被静默丢弃 —— 排队条一次都不显示。
305
305
  *
306
306
  * `next-turn`(等下一轮才开始的消息)→ `placement: 'queued'`,QueueDock 只认这一个值。
307
- * `next-step`(本步末尾注入的插话)暂不合成 `steering`:手机发起的插话已有本地待发气泡
308
- * (`useChatStream` 的 `pending-<clientMessageId>`)与落定后的正文行,多推一份会在同一处
309
- * 多画一条用户气泡。`message.id` 用宿主行自己的 id,App 靠它与正文去重(chatRenderRows)。
307
+ * `next-step`(已收下、等本步末尾注入的插话)→ `placement: 'steering'`:排队条与正文列表都
308
+ * 只认 `queued`,所以它不会多画一行;它的用处是「宿主已经收下了」这条回执 —— 插话从发出去
309
+ * 到真被插进去中间可能隔着好几个工具往返,此前手机上只挂着一颗「发送中…」。页脚那排插话
310
+ * 气泡会把本机自己发的那几条排掉(`steeringForFooter`),不会与待发气泡双画。
311
+ * `message.id` 用宿主行自己的 id,App 靠它与正文去重(chatRenderRows)。
310
312
  */
311
313
  function queueItems(inbox, ctx) {
312
- const rows = inbox && Array.isArray(inbox['next-turn']) ? inbox['next-turn'] : [];
313
314
  const items = [];
314
315
  let dropped = 0;
315
- for (const message of rows) {
316
- if (!message || typeof message !== 'object') continue;
317
- const id = typeof message.id === 'string' && message.id ? message.id : '';
318
- if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
319
- items.push({ id, placement: 'queued', message: { id, content: message.content } });
316
+ /**
317
+ * 摊平一组待发行。`placement` 只标语义:`queued` 进排队条,`steering` 只是回执。
318
+ * 没有 id 的行丢弃并计数(理由见下面的注释)。
319
+ */
320
+ const absorb = (rows, placement) => {
321
+ if (!Array.isArray(rows)) return;
322
+ for (const message of rows) {
323
+ if (!message || typeof message !== 'object') continue;
324
+ const id = typeof message.id === 'string' && message.id ? message.id : '';
325
+ if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
326
+ items.push({ id, placement, message: { id, content: message.content } });
327
+ }
328
+ };
329
+ if (inbox && typeof inbox === 'object') {
330
+ absorb(inbox['next-turn'], 'queued');
331
+ absorb(inbox['next-step'], 'steering');
320
332
  }
321
333
  // 丢弃必须留痕:旧实现是裸 continue,手机上少一条待发消息而宿主侧一行日志都没有,
322
334
  // 只能靠人对着桌面端数气泡。凭空造 id 更糟——App 的 session.updateQueue 按 id 定位,
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * 这条只回监控需要的那几格:在不在跑、刚完成没有、在等谁回话、上一轮是不是跑挂的、标题。
12
12
  * 并支持 `?rev=` 条件刷新:状态位与上次一模一样时回一句 `unchanged`,连 items 都不带
13
- * (稳态下这一发是几十字节)。
13
+ * (稳态下这一发是几十字节)。再带上 `?wait=秒` 时,条件刷新的那一发**先挂住**,等状态位
14
+ * 真的变了才回 —— 安卓后台服务靠它把「定时去问」变成「一有变化立刻送到」。
14
15
  *
15
16
  * 完成态仍以 completion 状态机为准,与 `/sessions` 用同一本账:常态下这里**不读 history**,
16
17
  * 只有「跑过、现在不在跑、结局还没定」的那几条才读一次 —— 那是唯一能把 failedTurn 折出来
@@ -22,6 +23,8 @@
22
23
 
23
24
  const { sendJson } = require('../shared/http-util');
24
25
  const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
26
+ const { listSignal } = require('../shared/list-signal.js');
27
+ const { sessionSignal } = require('../shared/session-signal.js');
25
28
  const { callSessions } = require('./rpc.js');
26
29
  const { unwrap } = require('./envelope.js');
27
30
  const { listTitle } = require('./history-derive.js');
@@ -29,6 +32,7 @@ const {
29
32
  canonicalDeviceKey, deletedSessionIdsOf, completionGenerationOf,
30
33
  } = require('./session-cache.js');
31
34
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
35
+ const { lastTurnOf } = require('./session-last-turn.js');
32
36
  const { sessionPreviewFor } = require('./session-preview.js');
33
37
  const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
34
38
 
@@ -39,16 +43,46 @@ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags
39
43
  * lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
40
44
  * 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
41
45
  */
42
- function monitorItem(summary, state) {
46
+ function monitorItem(summary, state, fact) {
47
+ const running = summary.running === true;
48
+ // 完成/失败优先用**上一轮事实**(`session-last-turn`),不再等 `state.completed`。
49
+ //
50
+ // 为什么:那个按设备记账的完成态要靠「这一次真的读回了 history」才折得出来,而
51
+ // 「刚跑完」那一刻宿主的镜像推送早把同一份预览按新指纹写进缓存了 —— 监控这一侧
52
+ // 拿到的是缓存命中(events 为空)、永远折不出来,`completed` 就一直 false,后台
53
+ // 一条提醒都不会响(2026-10-25 真机:宿主日志里全是「preview cache-hit」)。
54
+ // 事实那本账由 turn/end 事件当场记下,不依赖预览缓存,所以拿它判完成。
55
+ //
56
+ // 已读水位仍然生效:这一轮结束在「这台手机读过」之前就不算提醒。
57
+ const clearedAt = typeof state?.clearedAt === 'number' ? state.clearedAt : null;
58
+ const settledForThisDevice = fact != null && (clearedAt === null || fact.endedAt > clearedAt);
59
+ const completed = fact
60
+ ? (!running && fact.status === 'completed' && settledForThisDevice)
61
+ : (state ? state.completed === true : false);
43
62
  const item = {
44
63
  sessionId: summary.sessionId,
45
64
  title: listTitle(summary),
46
- running: summary.running === true,
47
- completed: state ? state.completed === true : false,
65
+ running,
66
+ completed,
48
67
  };
49
68
  const pending = pendingInteractionOf(summary.sessionId, summary);
50
69
  if (pending) item.pendingInteraction = pending;
51
- if (state && state.failure) item.lastTurnError = state.failure;
70
+ const failure = fact
71
+ ? (fact.status === 'failed' && settledForThisDevice
72
+ ? { turn: fact.turn, code: fact.code ?? null, message: fact.message ?? null }
73
+ : null)
74
+ : (state && state.failure ? state.failure : null);
75
+ if (failure) item.lastTurnError = failure;
76
+ // 上一轮事实整份透给原生:它按「轮次前进」判完成 / 失败,比 completed 翻面可靠
77
+ // (两次轮询之间跑完一整轮时 completed 可能一直是 true)。字段与列表那条同源。
78
+ if (fact) {
79
+ item.lastTurn = {
80
+ turn: fact.turn,
81
+ endedAt: fact.endedAt,
82
+ status: fact.status,
83
+ ...(fact.status === 'failed' ? { code: fact.code ?? null, message: fact.message ?? null } : {}),
84
+ };
85
+ }
52
86
  return item;
53
87
  }
54
88
 
@@ -68,7 +102,10 @@ async function buildMonitorState(ctx, deviceKey) {
68
102
  const generation = completionGenerationOf(ctx, deviceKey);
69
103
  const deleted = deletedSessionIdsOf(ctx);
70
104
  const { archived } = await workspaceSessionFlags(ctx);
105
+ // 子代理会话不参与后台提醒:它们是主轮内部的执行单元,App 侧栏也不列;把它们算进来
106
+ // 会在后台弹一堆「子代理跑完了」(2026-10-26 真机日志里四十多个 UUID 会话都是这种)。
71
107
  const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
108
+ && s.origin !== 'subagent'
72
109
  && !deleted.has(s.sessionId) && !archived.has(s.sessionId));
73
110
 
74
111
  // 状态机跟着这份清单走:归档 / 删除的会话在这里被踢掉,与完整列表同一口径。
@@ -85,51 +122,129 @@ async function buildMonitorState(ctx, deviceKey) {
85
122
  await Promise.all(unsettled.map(async (summary) => {
86
123
  try {
87
124
  const preview = await sessionPreviewFor(ctx, summary);
88
- if (!preview.events.length) return;
125
+ if (!preview.events.length) {
126
+ return;
127
+ }
89
128
  // 读到这些历史的这一刻,别处可能刚发起一次写后刷新(它会 bump 代次);
90
129
  // 用旧快照去确认结局等于把「还在跑的一轮」写成已完成。
91
- if (completionGenerationOf(ctx, deviceKey) !== generation) return;
130
+ if (completionGenerationOf(ctx, deviceKey) !== generation) {
131
+ return;
132
+ }
133
+ const before = states.get(summary.sessionId)?.completed;
92
134
  confirmCompletionFromHistory(states.get(summary.sessionId), preview.events, false, ctx, summary.sessionId);
93
- } catch {
135
+ } catch (error) {
94
136
  // 这一轮没读到就原样留着:判定条件(没完成、没失败)下一轮仍然成立,会再试。
95
137
  }
96
138
  }));
97
139
  }
98
140
 
99
- const items = live.map((summary) => monitorItem(summary, states.get(summary.sessionId)));
141
+ const items = live.map((summary) => monitorItem(
142
+ summary,
143
+ states.get(summary.sessionId),
144
+ lastTurnOf(ctx, summary.sessionId),
145
+ ));
100
146
  // 按 id 排序后再算指纹:宿主是按 updatedAt 排的,聊一句话顺序就换,指纹会跟着每轮都变,
101
147
  // 条件刷新等于白做。监控视图不关心顺序。
102
148
  items.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
103
149
  return { items, revision: stableRevision(items) };
104
150
  }
105
151
 
106
- /** GET /api/mobile/v1/monitor?rev=…(token) */
107
- async function handleMonitor(ctx, req, res) {
108
- const deviceKey = canonicalDeviceKey(req);
109
- const url = new URL(req.url, 'http://x');
110
- const rev = (url.searchParams.get('rev') || '').trim();
152
+ /** 一次挂起的上限。手机侧每次挂 20 秒,再长没有意义:断线重连比占着一条连接便宜。 */
153
+ const WAIT_MAX_MS = 25_000;
154
+
155
+ /**
156
+ * 挂在「有东西变了」的信号上,最多等 timeoutMs。
157
+ *
158
+ * 只有两条总线会唤醒它:清单变了(list-signal)、某个会话的正文水位涨了(session-signal)
159
+ * —— 一轮跑完、跑挂、开始等你回话,都会至少踩中其中一条。两条都是模块级单例,生产端在
160
+ * 镜像推送链上,不需要认识这条请求。
161
+ *
162
+ * 为什么用总线而不是自己轮着算一遍:这里的等待必须是**事件驱动**的,自己定时重算就成了
163
+ * 换了个地方的轮询,而这条接口存在的全部理由就是让手机不必轮询。
164
+ */
165
+ function waitForChange(timeoutMs, res) {
166
+ return new Promise((resolve) => {
167
+ let settled = false;
168
+ let releaseList = () => {};
169
+ let releaseSession = () => {};
170
+ const timer = setTimeout(() => finish(), timeoutMs);
171
+ function finish() {
172
+ if (settled) return;
173
+ settled = true;
174
+ clearTimeout(timer);
175
+ releaseList();
176
+ releaseSession();
177
+ resolve();
178
+ }
179
+ releaseList = listSignal.onBump(finish);
180
+ releaseSession = sessionSignal.onBump(finish);
181
+ // 手机切网 / 进程被杀时连接会断,没必要再替它挂满整个窗口。
182
+ if (res && typeof res.once === 'function') res.once('close', finish);
183
+ });
184
+ }
111
185
 
186
+ /** 算一次监控视图,把「宿主出错 / 形状异常」也收成一个可下发的响应。 */
187
+ async function buildMonitorView(ctx, deviceKey) {
112
188
  let built;
113
189
  try {
114
190
  built = await buildMonitorState(ctx, deviceKey);
115
191
  } catch (error) {
116
- return sendJson(res, 500, {
117
- ok: false,
118
- error: { code: 'monitor-failed', message: (error && error.message) || '监控视图构建失败' },
119
- });
192
+ return {
193
+ status: 500,
194
+ body: { ok: false, error: { code: 'monitor-failed', message: (error && error.message) || '监控视图构建失败' } },
195
+ };
120
196
  }
121
- if (built.error) return sendJson(res, 400, { ok: false, error: built.error });
197
+ if (built.error) return { status: 400, body: { ok: false, error: built.error } };
122
198
  if (built.degraded) {
123
199
  // 与 /sessions 同一个码:中转见到 5xx 会退回上一份好副本,手机那侧因此是「没收到新东西」
124
200
  // 而不是「一台会话都没有」。
125
- return sendJson(res, 500, {
126
- ok: false,
127
- error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' },
128
- });
201
+ return {
202
+ status: 500,
203
+ body: { ok: false, error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' } },
204
+ };
205
+ }
206
+ return { status: 200, body: { ok: true, generatedAt: Date.now(), revision: built.revision, items: built.items } };
207
+ }
208
+
209
+ /**
210
+ * GET /api/mobile/v1/monitor?rev=…[&wait=秒](token)
211
+ *
212
+ * `wait` 是实时那条路:带上它,且状态位与 `rev` 一字不差时,这次请求**先不回**,
213
+ * 挂到有变化(或挂满)才回。安卓那条前台服务在后台就挂着它,于是「一轮跑完」到
214
+ * 「手机响」只差一次本地网络往返,不再等下一拍轮询。
215
+ *
216
+ * 只挂一次:醒来之后不管变没变都如实回(变没变由 revision 说了算),由手机自己决定
217
+ * 要不要再挂。这样这里没有循环、没有重试,断线也不会在宿主侧留下一个转不停的请求。
218
+ */
219
+ async function handleMonitor(ctx, req, res) {
220
+ const deviceKey = canonicalDeviceKey(req);
221
+ const url = new URL(req.url, 'http://x');
222
+ const rev = (url.searchParams.get('rev') || '').trim();
223
+ const waitSeconds = Number(url.searchParams.get('wait') || 0) || 0;
224
+ // 没有 rev 就不挂:那不是「确认一下有没有变」,而是「给我一份完整状态」,立刻回。
225
+ const waitMs = rev ? Math.max(0, Math.min(WAIT_MAX_MS, Math.round(waitSeconds * 1000))) : 0;
226
+
227
+ const first = await buildMonitorView(ctx, deviceKey);
228
+ if (first.status !== 200) {
229
+ return sendJson(res, first.status, first.body);
129
230
  }
231
+ const unchanged = unchangedListBody(first.body, rev);
232
+ if (!unchanged) return sendJson(res, 200, first.body);
233
+ if (waitMs <= 0) return sendJson(res, 200, unchanged);
130
234
 
131
- const body = { ok: true, generatedAt: Date.now(), revision: built.revision, items: built.items };
132
- sendJson(res, 200, unchangedListBody(body, rev) || body);
235
+ // 头必须在挂起**之前**发出去。走中转那条路上,「等响应头」只有 7 秒
236
+ // (lib/relay/tunnel.js 的 UPSTREAM_HEADER_TIMEOUT_MS),不先发头,这次挂起会被
237
+ // 当成死请求掐掉,手机收到的是一次网络错误而不是一次「没变化」。发完头再等,
238
+ // 中转那边看的是「响应空转 30 秒」,20 秒的挂起正好在它之内。
239
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', vary: 'accept-encoding' });
240
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
241
+ await waitForChange(waitMs, res);
242
+ // 手机在这段时间里断了(切网 / 进程被杀):别为一个没人收的响应再算一遍。
243
+ if (res.destroyed || res.writableEnded) return;
244
+ const again = await buildMonitorView(ctx, deviceKey);
245
+ const body = again.status === 200 ? (unchangedListBody(again.body, rev) || again.body) : again.body;
246
+ // 头已经出去了,不能再走 sendJson(它会再写一次头):这里只补正文。
247
+ try { res.end(JSON.stringify(body)); } catch { /* 连接已断开 */ }
133
248
  }
134
249
 
135
250
  module.exports = { handleMonitor, buildMonitorState, monitorItem };