dsh-mobile-plugin 0.4.0 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +71 -74
  2. package/dsh-mobile-credentials/lib/credentials.js +47 -24
  3. package/host.js +22 -36
  4. package/lib/client.js +29 -82
  5. package/lib/desktop-view.cjs +5 -10
  6. package/lib/git/read.js +6 -8
  7. package/lib/host-compat.cjs +13 -25
  8. package/lib/host-guard.cjs +9 -39
  9. package/lib/host-patches.cjs +5 -16
  10. package/lib/http/routing.js +7 -27
  11. package/lib/impl.js +6 -12
  12. package/lib/index.js +1 -1
  13. package/lib/mirror/pusher.cjs +33 -85
  14. package/lib/mirror-snapshot.cjs +6 -24
  15. package/lib/mobile/agent-presets.js +6 -26
  16. package/lib/mobile/allowed-zone.js +100 -0
  17. package/lib/mobile/approvals.js +18 -44
  18. package/lib/mobile/archive-clean.js +13 -21
  19. package/lib/mobile/attachments.js +16 -37
  20. package/lib/mobile/channels.js +15 -41
  21. package/lib/mobile/credential-upgrade.js +7 -16
  22. package/lib/mobile/credentials.js +5 -21
  23. package/lib/mobile/directory-browse.js +8 -27
  24. package/lib/mobile/directory-listing.js +10 -23
  25. package/lib/mobile/drive-roots.js +59 -0
  26. package/lib/mobile/envelope.js +4 -15
  27. package/lib/mobile/events.js +35 -65
  28. package/lib/mobile/extensions.js +5 -8
  29. package/lib/mobile/file-read.js +168 -0
  30. package/lib/mobile/git-repos.js +6 -15
  31. package/lib/mobile/git.js +11 -19
  32. package/lib/mobile/history-derive.js +85 -134
  33. package/lib/mobile/host-bridge/endpoints.js +4 -6
  34. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  35. package/lib/mobile/host-bridge/shims.js +12 -22
  36. package/lib/mobile/host-bridge.js +8 -20
  37. package/lib/mobile/host-settings.js +9 -19
  38. package/lib/mobile/inbox-clean.js +7 -24
  39. package/lib/mobile/local-images.js +48 -23
  40. package/lib/mobile/model-vendors.js +105 -0
  41. package/lib/mobile/monitor.js +8 -17
  42. package/lib/mobile/pairing.js +7 -19
  43. package/lib/mobile/plugin-market.js +475 -0
  44. package/lib/mobile/plugin-self.js +6 -23
  45. package/lib/mobile/plugin-update.js +8 -27
  46. package/lib/mobile/plugins.js +6 -24
  47. package/lib/mobile/questions.js +15 -40
  48. package/lib/mobile/references.js +11 -31
  49. package/lib/mobile/relay-control.js +136 -33
  50. package/lib/mobile/rpc.js +5 -13
  51. package/lib/mobile/schedule.js +8 -33
  52. package/lib/mobile/self-check.cjs +9 -36
  53. package/lib/mobile/session-cache.js +13 -19
  54. package/lib/mobile/session-cwd.js +28 -0
  55. package/lib/mobile/session-file.js +9 -33
  56. package/lib/mobile/session-flags.js +7 -13
  57. package/lib/mobile/session-mirror.js +8 -15
  58. package/lib/mobile/session-models.js +3 -3
  59. package/lib/mobile/session-schedule-mark.js +8 -14
  60. package/lib/mobile/session-send.js +13 -21
  61. package/lib/mobile/session-snapshot.js +4 -6
  62. package/lib/mobile/sessions.js +7 -23
  63. package/lib/mobile/stats-collect.cjs +154 -37
  64. package/lib/mobile/stats-ledger.cjs +258 -75
  65. package/lib/mobile/stats-scheduler.cjs +41 -0
  66. package/lib/mobile/stream.js +39 -130
  67. package/lib/mobile/subagents.js +5 -18
  68. package/lib/mobile/uploads.js +75 -21
  69. package/lib/mobile/vendor-logos.cjs +30 -0
  70. package/lib/mobile/wire-cut.js +212 -0
  71. package/lib/mobile/workspaces.js +20 -8
  72. package/lib/panel/index.html +7 -7
  73. package/lib/read-receipt.cjs +10 -21
  74. package/lib/relay/apply-ledger.cjs +52 -0
  75. package/lib/relay/core.js +14 -3
  76. package/lib/relay/lan-network.cjs +14 -28
  77. package/lib/relay/lan-proxy.js +14 -21
  78. package/lib/relay/migrate.js +90 -0
  79. package/lib/relay/pairing-policy.js +6 -9
  80. package/lib/relay/preview.js +8 -25
  81. package/lib/relay/protocol.js +9 -12
  82. package/lib/relay/status.js +5 -8
  83. package/lib/relay/tunnel.js +9 -14
  84. package/lib/relay/verify.js +52 -0
  85. package/lib/routes/control-plane.js +39 -47
  86. package/lib/routes/mobile-api.js +47 -34
  87. package/lib/session-cleanup.cjs +26 -27
  88. package/lib/shared/credentials-entry.js +4 -11
  89. package/lib/shared/device-headers.js +12 -4
  90. package/lib/shared/dshtoken.js +4 -4
  91. package/lib/shared/e2ee-request.js +1 -1
  92. package/lib/shared/e2ee.js +8 -14
  93. package/lib/shared/fresh-request.js +6 -22
  94. package/lib/shared/http-util.js +3 -4
  95. package/lib/shared/list-revision.js +5 -14
  96. package/lib/shared/session-signal.js +7 -16
  97. package/lib/shared/sse-write.js +14 -20
  98. package/lib/shared/workspace-guard.js +7 -15
  99. package/lib/shell/device-ota.js +4 -6
  100. package/lib/shell/manager.js +12 -27
  101. package/lib/shell/ota-package.js +7 -12
  102. package/lib/shell/runtime.js +8 -16
  103. package/lib/shell/self-check-after-swap.js +9 -23
  104. package/lib/shell/updater.js +15 -28
  105. package/lib/sidecar-facade-worker.cjs +75 -41
  106. package/lib/sidecar-facade.cjs +18 -8
  107. package/lib/sidecar-lifecycle.cjs +21 -10
  108. package/lib/update-install.cjs +24 -62
  109. package/lib/update-registry.cjs +6 -18
  110. package/package.json +1 -1
  111. package/lib/auto-archive.cjs +0 -98
@@ -3,31 +3,14 @@
3
3
  /**
4
4
  * 收件目录(`~/.dsh/mobile-inbox`)的占用统计与按龄清理。
5
5
  *
6
- * 手机分享进来的文件由 `uploads.js` 落到 `<收件根>/<日期>/<消毒名>`,那条通道**只进不出**:
7
- * 它在盘上只认文件名,不认识会话、也不认识工作区,所以没有任何「删掉这条对话就顺带清掉
8
- * 它传上来的文件」可言 —— 唯一的引用是会话日志正文里那句 `@路径`,那是文本,宿主不会
9
- * 跟着它去回收。不设一个出口,这个目录只会按天堆下去。
6
+ * 手机分享进来的文件由 `uploads.js` 落到 `<收件根>/<日期>/<消毒名>`,那条通道只进不出:盘上
7
+ * 只认文件名,不认识会话,会话日志里的 `@路径` 也不会触发回收。不设出口,目录只会按天堆。
10
8
  *
11
- * 因此这里的口径是**按龄**:只删 mtime 超过 `keepMs`(24 小时)的文件。
9
+ * 口径是按龄:只删 mtime 超过 24 小时的文件。按文件而不是按日期目录删,才和「放满一天」这个
10
+ * 承诺一致;今天传的文件不在范围内,正在跑的那一回合碰不到它。空出来的日期目录顺手删掉。
12
11
  *
13
- * 为什么是文件而不是日期目录:目录按「上传那天的本地日期」分堆,整段删会把昨天 23:50
14
- * 传的、才过 20 分钟的文件一起带走;按每个文件自己的 mtime 判才和「放满一天」这个承诺
15
- * 一致。空出来的日期目录顺手删掉。
16
- *
17
- * 为什么留着 24 小时而不是立刻删:助手拿到路径之后可能在同一个回合里反复读它,用户也可能
18
- * 隔一晚上让助手接着看同一份文件。今天传的文件一开始就不在清理范围内,正在跑的那一回合
19
- * 因此碰不到这里。
20
- *
21
- * 与 `session-cleanup.cjs` 的取舍刻意不同:那边进程活着时一条日志都不删(宿主随时可能去
22
- * 打开,ENOENT 会带走整个进程);这里删的是收件目录里的普通文件,宿主只在助手明确要读
23
- * 某个路径时才碰它,而那个路径只有在 24 小时窗口内才可能还有人要。
24
- *
25
- * ## 自动清理是开关,默认关(2026-10-21 用户定)
26
- *
27
- * 启动时那次自动清理**只有开关打开才跑**;面板上那颗按钮永远可用(那是用户自己按的,
28
- * 不需要开关授权)。开关落盘在 `<DSH_HOME>/storages/mobile-plugin/inbox-auto-clear.json`,
29
- * 与自动更新开关同一套写法(读不到 / 读坏了都当关)—— 自动删盘这种事,缺省必须在用户
30
- * 这一侧,而不是「装了就替你删」。
12
+ * 自动清理是开关、默认关:启动时那次只有开关打开才跑,面板上的按钮永远可用。开关落盘在
13
+ * `<DSH_HOME>/storages/mobile-plugin/inbox-auto-clear.json`,读不到或读坏都当关。
31
14
  */
32
15
 
33
16
  const fs = require('node:fs');
@@ -241,7 +224,7 @@ function usageView(scan) {
241
224
  keepHours: Math.round(scan.keepMs / (60 * 60 * 1000)),
242
225
  /**
243
226
  * 自动清理开着没有。两个界面都靠它画那颗开关 —— 不自己猜、也不各自存一份,
244
- * 真值只在这台机器的 json 里。**默认 false**(用户 2026-10-21 定)。
227
+ * 真值只在这台机器的 json 里。**默认 false**。
245
228
  */
246
229
  autoClear: autoClearEnabled(),
247
230
  };
@@ -1,25 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 正文图片内联:把消息文本里指向**本机文件**的图片语法(`![](/abs/path.png)`)换成 data URI,
5
- * 让手机端能直接把它画出来。
4
+ * 正文图片内联:把消息文本里指向本机文件的图片语法(`![](/abs/path.png)`)换成 data URI,
5
+ * 让手机端能直接画出来。
6
6
  *
7
- * 为什么只能在这里换:手机拿到的只是文本,`/Users/…/a.png` 既不是手机上的文件,移动 API 也
8
- * 没有「按路径读文件」的能力面(见 host-bridge-endpoints 守卫的 forbidden 表),所以地址原样
9
- * 过去只会显示成一串字面 markdown。会话里用户自己发的图片本来就是内联形状(宿主把附件解成
10
- * base64,见 attachments.js 与 App 侧 chat.ts 的 images[]),这里只是把「正文里写的本机图片」
11
- * 拉齐到同一种形状,App 侧因此不需要新增任何通道或依赖。
7
+ * 手机拿到的只是文本,`/Users/…/a.png` 既不是手机上的文件,移动 API 也没有「按路径读文件」
8
+ * 的能力面,原样过去只会显示成一串字面 markdown。这里把「正文里写的本机图片」拉齐到附件那种
9
+ * 内联形状,App 不需要新增通道。
12
10
  *
13
- * 保守取舍(宁可保留原样的字面 markdown,也不给个半截东西):
14
- * - 只认能解析成**绝对路径**的地址:`/x/y.png`、`file:///x/y.png`、`~/y.png`;
15
- * http(s)、`data:`、`www.`、相对路径一律不碰。
16
- * - 只认 png/jpg/jpeg/webp/gif(与 app/src/services/viewmodels/image-limits.ts 的 mediaTypes
17
- * 同白名单),并且**核对魔术字节**:字节不是图片就放弃,绝不按扩展名撒谎。
18
- * - 三级体积预算(单图 / 单段文本 / 单次响应,见下方常量):超预算的图保持字面 markdown,
19
- * 不截断、不缩水成半张图。
20
- * - 读不到、不是普通文件、读取失败:原样保留 —— 一条图片取不到不能让整段历史或整帧 SSE 挂掉。
21
- * - 围栏代码块与行内代码里的图片语法一律不碰:那是**写法示例**,不是要画的图;换掉会把
22
- * 三行代码变成几十万字符的 base64,App 的代码块会铺成很长很长的一块。
11
+ * 保守取舍——宁可保留字面 markdown,也不给半截东西:
12
+ * - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
13
+ * 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
14
+ * - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
15
+ * - 三级体积预算(单图 / 单段 / 单次响应),超预算就保持字面;
16
+ * - 读不到、不是普通文件、读取失败一律原样保留;
17
+ * - 围栏代码块与行内代码里的图片语法不碰,那是写法示例。
23
18
  */
24
19
 
25
20
  const fs = require('node:fs/promises');
@@ -69,10 +64,13 @@ function sniffMediaType(buf) {
69
64
  }
70
65
 
71
66
  /**
72
- * `![](…)` 里的地址 → 本机绝对路径;不是本机图片(远程、data、相对路径、非图片后缀)返回 null。
67
+ * `![](…)` 里的地址 → 本机路径;不是本机图片(远程、data、非图片后缀)返回 null。
73
68
  * 尖括号包裹的目的地(CommonMark 允许 `![](</abs/a.png>)`)在这里一并剥掉。
69
+ *
70
+ * 相对地址(`deliverables/a.png`、`./a.png`)按 `cwd` 解析——那是会话的工作区目录,也是
71
+ * 桌面端画正文图的基准;没给 `cwd` 时相对地址照样返回 null(老口径:保持字面 markdown)。
74
72
  */
75
- function localImagePathOf(url) {
73
+ function localImagePathOf(url, cwd) {
76
74
  if (typeof url !== 'string') return null;
77
75
  let value = url.trim();
78
76
  if (!value) return null;
@@ -88,12 +86,39 @@ function localImagePathOf(url) {
88
86
  } else if (value === '~' || value.startsWith('~/')) {
89
87
  value = path.join(os.homedir(), value.slice(1));
90
88
  }
91
- if (!path.isAbsolute(value)) return null;
89
+ if (!path.isAbsolute(value)) {
90
+ if (!cwd) return null;
91
+ value = path.resolve(cwd, value);
92
+ }
92
93
  if (!mediaTypeFor(value)) return null;
93
94
  return path.normalize(value);
94
95
  }
95
96
 
96
- /** 一次响应共用的内联预算;不给就按单次调用算一份。 */
97
+ /**
98
+ * 正文里有没有「相对地址」的本机图片语法。
99
+ *
100
+ * 只有这种文本才值得去取会话的工作区目录(`sessions.list` 要扫一遍全部会话),
101
+ * 其余情况一条会话列表都不查:绝对路径照常内联,相对地址原样留着。
102
+ */
103
+ function hasRelativeImage(text) {
104
+ if (typeof text !== 'string' || text.indexOf('![') < 0) return false;
105
+ for (const m of text.matchAll(IMAGE_RE)) {
106
+ let dest = m[2].trim();
107
+ if (dest.startsWith('<') && dest.endsWith('>')) dest = dest.slice(1, -1).trim();
108
+ if (!dest || path.isAbsolute(dest)) continue;
109
+ if (dest === '~' || dest.startsWith('~/')) continue;
110
+ if (/^[a-z][a-z0-9+.-]*:/i.test(dest)) continue;
111
+ if (mediaTypeFor(dest)) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * 一次响应共用的内联预算;不给就按单次调用算一份。
118
+ *
119
+ * `cwd` 由调用方按需补上(相对地址的解析基准,见 localImagePathOf):`undefined` 表示
120
+ * 「还没查过工作区目录」,空串表示「查过了,这条会话没有」。
121
+ */
97
122
  function createTextImageBudget(limit = MAX_RESPONSE_CHARS) {
98
123
  return { remaining: limit };
99
124
  }
@@ -221,7 +246,7 @@ async function inlineTextImages(text, budget) {
221
246
  let cursor = 0;
222
247
  for (const m of matches) {
223
248
  if (textBudget <= 0 || pool.remaining <= 0) break;
224
- const filePath = localImagePathOf(m[2]);
249
+ const filePath = localImagePathOf(m[2], pool.cwd);
225
250
  if (!filePath) continue;
226
251
  const uri = await dataUriFor(filePath);
227
252
  if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
@@ -233,4 +258,4 @@ async function inlineTextImages(text, budget) {
233
258
  return cursor === 0 ? text : out + text.slice(cursor);
234
259
  }
235
260
 
236
- module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
261
+ module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 模型 → 厂商的识别,以及厂商图标(`vendor-logos.cjs` 那张表)的取用。
5
+ *
6
+ * 为什么需要猜:宿主只给「渠道 id + 模型 id」,没有厂商这一栏,而同一个模型在不同渠道下写法
7
+ * 各不相同 —— `deepseek-v4.1-flash`、`DeepSeek V4.1 flash`、`deepseek_v4_1_flash` 都是同一个
8
+ * 东西;有些渠道还把模型名整个改掉(`route-model-a`)。所以先规范化(小写、去掉所有分隔符),
9
+ * 再按关键词认;模型名认不出时再看渠道名(`anthropic`、`moonshotai` 这类渠道 id 往往就是厂商)。
10
+ *
11
+ * 认不出来的就**不猜**:宁可那一行退回首字母徽标,也不要给一个错的品牌 —— 厂商名是用户用来
12
+ * 认模型的,猜错比空着更糟。这条与「取不到就整块不出」是同一个口径。
13
+ *
14
+ * 纯函数、不碰网络也不碰文件,守卫直接跑真实现。
15
+ */
16
+
17
+ const { VENDOR_LOGOS } = require('./vendor-logos.cjs');
18
+
19
+ /**
20
+ * 规范化:小写、只留字母与数字(汉字保留 —— 国产模型的别名经常是中文)。
21
+ *
22
+ * 去掉分隔符是关键:`v4.1` / `v4-1` / `v4_1` 都应该落到同一个串上,否则每种写法都要单独列一条。
23
+ */
24
+ function foldText(value) {
25
+ return typeof value === 'string' ? value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '') : '';
26
+ }
27
+
28
+ /** 按非字母数字切开的小写片段:给「o3」「phi」这种两三个字符的短词用(它们不能按子串认)。 */
29
+ function tokensOf(value) {
30
+ return typeof value === 'string'
31
+ ? value.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean)
32
+ : [];
33
+ }
34
+
35
+ /**
36
+ * 认法表。匹配顺序就是优先级,长关键词在前。
37
+ *
38
+ * `terms` 在规范化后的串上按子串认(`deepseekv41flash` 里含 `deepseek`);
39
+ * `tokens` 按切开后的整段认(`o3-mini` 的 `o3`、`phi-4` 的 `phi`)—— 短词按子串认会把
40
+ * 一堆无关模型认成同一家。
41
+ */
42
+ const VENDOR_PATTERNS = [
43
+ ['deepseek', { terms: ['deepseek', '深度求索', '深度探索'] }],
44
+ ['anthropic', { terms: ['claude', 'anthropic', 'sonnet', 'opus', 'haiku'] }],
45
+ ['openai', { terms: ['openai', 'chatgpt', 'gpt', 'codex', 'davinci', 'omni'], tokens: ['o1', 'o3', 'o4'] }],
46
+ ['google', { terms: ['gemini', 'gemma', 'google', 'vertex', 'bard'] }],
47
+ ['alibaba', { terms: ['qwen', 'qwq', 'tongyi', 'alibaba', '通义', '千问', '阿里'] }],
48
+ ['moonshot', { terms: ['kimi', 'moonshot', '月之暗面'] }],
49
+ ['zhipu', { terms: ['glm', 'chatglm', 'zhipu', '智谱', '清言'] }],
50
+ ['meta', { terms: ['llama', 'meta'] }],
51
+ ['mistral', { terms: ['mistral', 'mixtral', 'codestral', 'devstral', 'magistral'] }],
52
+ ['xai', { terms: ['grok', 'xai'] }],
53
+ ['cohere', { terms: ['cohere', 'commandr'] }],
54
+ ['bytedance', { terms: ['doubao', 'bytedance', 'seed', '豆包', '火山'] }],
55
+ ['baidu', { terms: ['ernie', 'wenxin', 'baidu', '文心', '百度'] }],
56
+ ['tencent', { terms: ['hunyuan', 'tencent', '混元', '腾讯'] }],
57
+ ['minimax', { terms: ['minimax', 'abab', '海螺'] }],
58
+ ['nvidia', { terms: ['nemotron', 'nvidia', '英伟达'] }],
59
+ ['microsoft', { terms: ['microsoft', 'azure'], tokens: ['phi', 'phi3', 'phi4', 'phi35'] }],
60
+ ];
61
+
62
+ /** 一段文字认哪家;认不出返回空串。 */
63
+ function vendorOfText(value) {
64
+ const folded = foldText(value);
65
+ if (!folded) return '';
66
+ for (const [vendor, rule] of VENDOR_PATTERNS) {
67
+ if (rule.terms && rule.terms.some((term) => folded.includes(term))) return vendor;
68
+ }
69
+ const tokens = tokensOf(value);
70
+ if (tokens.length > 0) {
71
+ for (const [vendor, rule] of VENDOR_PATTERNS) {
72
+ if (rule.tokens && rule.tokens.some((token) => tokens.includes(token))) return vendor;
73
+ }
74
+ }
75
+ return '';
76
+ }
77
+
78
+ /**
79
+ * 一条模型用量认哪家:先看模型名 / 模型 id,再看渠道名 / 渠道 id。
80
+ *
81
+ * 顺序有意如此 —— 渠道可以挂着任何模型(自建渠道尤其如此),名字写对了就以名字为准;
82
+ * 只有名字认不出来时才用渠道兜底,这也是很多网关渠道的实际情况(渠道叫 `anthropic`、
83
+ * 模型名是它自己起的别名)。
84
+ *
85
+ * 返回的是**认出来的那一家**,跟它有没有图标无关:有一家(比如 GLM / Grok)暂时没有图标,
86
+ * 这一行就该退回首字母徽标,而不是接着往下试渠道名 —— 那样「模型名认出 A、渠道名像 B」时会
87
+ * 认成 B,摆上别家的 logo。认错厂商比不认更糟,这一条是硬规矩。
88
+ */
89
+ function identifyVendor(entry) {
90
+ if (!entry || typeof entry !== 'object') return '';
91
+ const candidates = [entry.modelName, entry.model, entry.providerName, entry.provider];
92
+ for (const candidate of candidates) {
93
+ const vendor = vendorOfText(candidate);
94
+ if (vendor) return vendor;
95
+ }
96
+ return '';
97
+ }
98
+
99
+ /** 厂商的展示名与图标标记;不在表里返回 null。 */
100
+ function vendorLogoOf(vendor) {
101
+ const entry = VENDOR_LOGOS[vendor];
102
+ return entry ? { name: entry.name, icon: entry.icon } : null;
103
+ }
104
+
105
+ module.exports = { foldText, tokensOf, vendorOfText, identifyVendor, vendorLogoOf, VENDOR_PATTERNS };
@@ -3,22 +3,14 @@
3
3
  /**
4
4
  * GET /api/mobile/v1/monitor —— 安卓后台提醒服务专用的「状态位」视图(只读)。
5
5
  *
6
- * 为什么单开一条:那条前台服务活在 JS 之外(App 退到后台、进程被系统回收,只有它还在),
7
- * 所以「这一轮跑完了 / 跑挂了 / 停下来等你回话」只能由它自己定时去问。它此前问的是完整
8
- * 会话清单(`?fresh=1`),而那份清单为了画侧栏还捎着**最后一条消息预览** —— 预览要为最近
9
- * 20 个会话各读一次 history。后台每 8 秒白读 20 次日志,只为判断「completed 翻没翻」。
6
+ * 那条前台服务活在 JS 之外,只能自己定时来问「这一轮跑完了 / 跑挂了 / 停下来等你回话」。
7
+ * 它此前问完整清单,而清单为了画侧栏还捎着最后一条消息预览,后台每 8 秒白读 20 次 history。
10
8
  *
11
- * 这条只回监控需要的那几格:在不在跑、刚完成没有、在等谁回话、上一轮是不是跑挂的、标题。
12
- * 并支持 `?rev=` 条件刷新:状态位与上次一模一样时回一句 `unchanged`,连 items 都不带
13
- * (稳态下这一发是几十字节)。再带上 `?wait=秒` 时,条件刷新的那一发**先挂住**,等状态位
14
- * 真的变了才回 —— 安卓后台服务靠它把「定时去问」变成「一有变化立刻送到」。
9
+ * 这条只回监控需要的那几格,支持 `?rev=` 条件刷新(状态位没变就回 unchanged)与 `?wait=秒`
10
+ * (条件不满足时先挂住,真变了才回),让安卓服务不必轮询。完成态仍以 completion 状态机为准,
11
+ * 常态下不读 history,只有「跑过、不在跑、结局还没定」的那几条才读一次。
15
12
  *
16
- * 完成态仍以 completion 状态机为准,与 `/sessions` 用同一本账:常态下这里**不读 history**,
17
- * 只有「跑过、现在不在跑、结局还没定」的那几条才读一次 —— 那是唯一能把 failedTurn 折出来
18
- * 的地方(事件流漏帧时全靠它,见 completion.js 的坑)。
19
- *
20
- * 刻意不进 `/sessions`:那边一旦按调用方分叉视图,镜像推送与 App 的写后刷新也会跟着
21
- * 受影响,而这两条都要求「一份完整清单」。分路由比加参数安全。
13
+ * 刻意不进 `/sessions`:那边一旦按调用方分叉视图,镜像推送与写后刷新都会受影响。
22
14
  */
23
15
 
24
16
  const { sendJson } = require('../shared/http-util');
@@ -50,8 +42,7 @@ function monitorItem(summary, state, fact) {
50
42
  // 为什么:那个按设备记账的完成态要靠「这一次真的读回了 history」才折得出来,而
51
43
  // 「刚跑完」那一刻宿主的镜像推送早把同一份预览按新指纹写进缓存了 —— 监控这一侧
52
44
  // 拿到的是缓存命中(events 为空)、永远折不出来,`completed` 就一直 false,后台
53
- // 一条提醒都不会响(2026-10-25 真机:宿主日志里全是「preview cache-hit」)。
54
- // 事实那本账由 turn/end 事件当场记下,不依赖预览缓存,所以拿它判完成。
45
+ // 一条提醒都不会响。事实那本账由 turn/end 事件当场记下,不依赖预览缓存。
55
46
  //
56
47
  // 已读水位仍然生效:这一轮结束在「这台手机读过」之前就不算提醒。
57
48
  const clearedAt = typeof state?.clearedAt === 'number' ? state.clearedAt : null;
@@ -103,7 +94,7 @@ async function buildMonitorState(ctx, deviceKey) {
103
94
  const deleted = deletedSessionIdsOf(ctx);
104
95
  const { archived } = await workspaceSessionFlags(ctx);
105
96
  // 子代理会话不参与后台提醒:它们是主轮内部的执行单元,App 侧栏也不列;把它们算进来
106
- // 会在后台弹一堆「子代理跑完了」(2026-10-26 真机日志里四十多个 UUID 会话都是这种)。
97
+ // 会在后台弹一堆「子代理跑完了」。
107
98
  const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
108
99
  && s.origin !== 'subagent'
109
100
  && !deleted.has(s.sessionId) && !archived.has(s.sessionId));
@@ -3,27 +3,15 @@
3
3
  /**
4
4
  * 移动端配对:手机在中转上用自己的配对码换到 token 与设备凭据。
5
5
  *
6
- * **这条路由归宿主,不归中转。**「谁可以进这台机器」是宿主自己的判断,配对码本来就存在
7
- * 宿主的 `state` 里;中转只按实例标识把请求转过来。早先这套判断长在独立的中转进程里
8
- * (`dsh-mobile-server/server.js` 的 `/pair`),换成 Rust 中转时整块没搬过去,于是手机
9
- * 按老地址 POST `/pair`、请求被中转原样转给宿主、宿主没有这条路由,只能回 405。
6
+ * 这条路由归宿主、不归中转:「谁可以进这台机器」是宿主自己的判断,配对码存在宿主的 state
7
+ * 里,中转只按实例标识把请求转过来。
10
8
  *
11
- * 与局域网那条(`lib/relay/lan-proxy.js` 的 `/__lan_pair`)是**同一件事的两个入口**:
12
- * 都是「配对码 / 票据 → 签发凭据」,差别只在网络路径。凭据本身两条路签发的是同一种 HMAC
13
- * (都用宿主 lanSecret 签名),所以经中转配对拿到的 token 可以直接用于局域网请求。
9
+ * 与局域网那条(`lib/relay/lan-proxy.js` 的 `/__lan_pair`)是同一件事的两个入口,签发的
10
+ * 是同一种 HMAC(都用宿主 lanSecret 签名),所以经中转配对拿到的 token 也能用于局域网请求。
14
11
  *
15
- * 请求体**两种形状都认**:
16
- * 老形状 `{ instanceId?, code, device }` —— 旧 App,明文,一字不改地照旧处理;
17
- * 新形状 `{ instanceId, sealed }` —— 协议 v2,`sealed` 是用二维码票据派生的
18
- * 密钥封的,解出来是设备主密钥与设备信息。
19
- * 新形状里**没有配对码**:票据本身就是资格证明。回包同样是密文 —— 里面是 token / cookie /
20
- * 设备凭据,明文回包等于让中转顺手拿到这台设备未来一年的通行证。
21
- *
22
- * 故意**不**在这里做的事:
23
- * - 不问宿主再确认一次。宿主的配对策略(pairingPolicy)已经决定了它接不接受新设备,
24
- * `deny` 时中转根本连不上;而这条路由本身就跑在宿主进程里。
25
- * - 不做「多台机器撞同一个配对码」的处理。那是中转的职责(它才看得见全部实例),
26
- * 在这台宿主上,配对码要么对上要么对不上。
12
+ * 请求体两种形状都认:老 App 的 `{ instanceId?, code, device }` 明文照旧;协议 v2 的
13
+ * `{ instanceId, sealed }` 没有配对码,票据本身就是资格证明,回包同样是密文 —— 里面是
14
+ * token / cookie / 设备凭据,明文回包等于让中转拿到这台设备的长期通行证。
27
15
  */
28
16
 
29
17
  const { sendJson } = require('../shared/http-util');