dsh-mobile-plugin 0.5.18 → 0.5.20

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.
@@ -0,0 +1,425 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * magpie 网页版:把这台电脑上 magpie 自带的那个网页界面端给手机,需要时替它起来。
5
+ *
6
+ * **为什么走网页版而不是自己画一页**:magpie 的网页版就是它自己的界面(Agents / Providers /
7
+ * Routing / Usage / Settings 全在里面),而且它本来就是给手机做的 —— 页面源码里写着
8
+ * 「加到手机主屏后,作为独立 App 打开」,还有 manifest 与 apple-touch-icon。照着它的
9
+ * `/api/state` 再画一套,等于把人家已经做好的东西重做一遍,而且每加一个设置项我们就得跟一次。
10
+ * 所以手机那边直接嵌这个页面:一份实现、永远跟 magpie 同步。
11
+ *
12
+ * **为什么要在这里起进程**:网关那个端口(3425)只答 API,不端页面 —— 页面是 `magpie web`
13
+ * 这个独立进程端出来的,默认 `127.0.0.1:3430`,而且它起来之后要**换一个 key**(每次运行
14
+ * 随机,除非设了 `MAGPIE_WEB_KEY`)。用户点开那一页时网页版没在跑,这里就替他把进程拉起来,
15
+ * 拿到地址与 key 再交出去;已经跑着就直接用现成的。
16
+ *
17
+ * **key 是凭据**:它只出现在这一条端点与那一次 URL 里,不进日志、不回给别的端点。设了
18
+ * `MAGPIE_WEB_KEY` 就用它(每次运行同一个,手机那边刷新不用重新要地址);没有就给这个进程
19
+ * 临时定一个,随进程走。
20
+ */
21
+
22
+ const { spawn } = require('node:child_process');
23
+ const fs = require('node:fs');
24
+ const http = require('node:http');
25
+ const net = require('node:net');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+
29
+ const { sendJson } = require('../shared/http-util');
30
+
31
+ /** 网页版默认端口(magpie 自己的缺省)。 */
32
+ const DEFAULT_WEB_PORT = 3430;
33
+ /** 起来之后最多等这么久让它把页面端出来。冷启动实测 3~6 秒(要读各家配置与订阅)。 */
34
+ const START_TIMEOUT_MS = 25000;
35
+ /** 探测间隔。 */
36
+ const POLL_MS = 250;
37
+ /** 上面等不到时的退避:同一个端口短时间内不反复起进程。 */
38
+ const START_COOLDOWN_MS = 5000;
39
+
40
+ /** key 只要够长、能进查询串;magpie 自己要求 16 位以上字母数字与 `-._~`。 */
41
+ const KEY_RE = /^[A-Za-z0-9._~-]{16,}$/;
42
+
43
+ /** 正在起来的那个:同一时刻只许一个(连点两下不该起两个进程)。 */
44
+ let starting = null;
45
+ /**
46
+ * 我们自己起的那个进程用的 key(端口 → key)。
47
+ *
48
+ * **必须记住**:没设 `MAGPIE_WEB_KEY` 时 key 是这里现造的,而那一次运行之内它一直有效 ——
49
+ * 第二次点开时进程已经在跑,如果不记得当初用了哪把,就只能回「拿不到凭据」,用户看到的是
50
+ * 「第一次能开、第二次打不开」。进程随插件重载消失,这份记录跟着进程走,所以重启后重新起
51
+ * 一个新进程、配一把新 key,也是一致的。
52
+ */
53
+ const startedKeys = new Map();
54
+ /**
55
+ * 上次起失败的时刻与原因(按端口记):失败之后短时间内不再对**同一个端口**反复试。
56
+ *
57
+ * 按端口而不是全局:一个端口被别的东西占着起不来,不该把另一个能用的端口也一起封住。
58
+ */
59
+ const failures = new Map();
60
+
61
+ function text(value) {
62
+ return typeof value === 'string' ? value.trim() : '';
63
+ }
64
+
65
+ /** magpie 的配置目录(与 `magpie.js` 同一口径)。 */
66
+ function configDir(env = process.env) {
67
+ const xdg = text(env.XDG_CONFIG_HOME);
68
+ const base = xdg || path.join(os.homedir(), '.config');
69
+ return path.join(base, 'magpie');
70
+ }
71
+
72
+ /**
73
+ * 网页版该占哪个端口。
74
+ *
75
+ * magpie 自己的默认是 3430;`settings.json` 里没有单独的「网页版端口」这一项(那是命令行
76
+ * 参数),所以这里只认 `$MAGPIE_WEB_PORT` 与默认值。端口被别的东西占着时往上找几个。
77
+ */
78
+ function preferredPort(env = process.env) {
79
+ const raw = Number(text(env.MAGPIE_WEB_PORT));
80
+ return Number.isInteger(raw) && raw > 0 && raw < 65536 ? raw : DEFAULT_WEB_PORT;
81
+ }
82
+
83
+ /**
84
+ * 这个端口上是不是已经有一个 magpie 网页版在答。
85
+ *
86
+ * 判据是它回 303/302(`/?k=` 换 cookie 那一跳)或 401(认得出是它、只是没带 key)——
87
+ * 别的程序占着这个端口时不会这么答。不带 key 请求,所以这条探测不会泄漏任何东西。
88
+ */
89
+ function probeWeb(port, timeoutMs = 1200) {
90
+ return new Promise((resolve) => {
91
+ const socket = net.connect({ host: '127.0.0.1', port });
92
+ let done = false;
93
+ const finish = (ok) => {
94
+ if (done) return;
95
+ done = true;
96
+ try { socket.destroy(); } catch { /* 已经断了 */ }
97
+ resolve(ok);
98
+ };
99
+ socket.setTimeout(timeoutMs, () => finish(false));
100
+ socket.on('error', () => finish(false));
101
+ socket.on('connect', () => {
102
+ socket.write(
103
+ 'GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n'
104
+ + 'User-Agent: dsh-mobile-plugin\r\n\r\n',
105
+ );
106
+ });
107
+ let raw = '';
108
+ socket.on('data', (chunk) => {
109
+ raw += chunk.toString('latin1');
110
+ if (raw.length > 2048) raw = raw.slice(0, 2048);
111
+ const status = raw.match(/^HTTP\/1\.[01] (\d{3})/);
112
+ if (!status) return;
113
+ const code = Number(status[1]);
114
+ // 303/302 = 有 key 就换 cookie 的那一跳;401 = 是它、但这次没给 key。
115
+ finish(code === 303 || code === 302 || code === 401);
116
+ });
117
+ socket.on('close', () => finish(false));
118
+ });
119
+ }
120
+
121
+ /** 端口空着没有(起进程前先看一眼,别去撞一个不认识的程序)。 */
122
+ function portFree(port) {
123
+ return new Promise((resolve) => {
124
+ const server = net.createServer();
125
+ let done = false;
126
+ const finish = (free) => {
127
+ if (done) return;
128
+ done = true;
129
+ try { server.close(); } catch { /* 没起来 */ }
130
+ resolve(free);
131
+ };
132
+ server.once('error', () => finish(false));
133
+ server.listen(port, '127.0.0.1', () => finish(true));
134
+ server.unref();
135
+ });
136
+ }
137
+
138
+ /** 找一个能用的端口:优先默认,被占了就往上找几个。 */
139
+ async function pickPort(env = process.env) {
140
+ const base = preferredPort(env);
141
+ for (let offset = 0; offset < 10; offset += 1) {
142
+ const port = base + offset;
143
+ if (await probeWeb(port, 600)) return { port, running: true };
144
+ if (await portFree(port)) return { port, running: false };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * 找 magpie 可执行文件。
151
+ *
152
+ * magpie 三个桌面系统都有(菜单栏 / 窗口 / TUI / 网页 / CLI 是同一个程序的不同入口),
153
+ * 装法也各不相同,所以按「先 PATH、再各系统的常规落点」找,别只认 macOS 那一个 .app 包:
154
+ * - PATH:`install.sh` 与 `go install` 都往某个 bin 目录里放一个 `magpie`(Windows 是
155
+ * `magpie.exe`),只要那个目录在 PATH 上就能直接命中。
156
+ * - macOS:官方 .app 包里的可执行文件,外加 Homebrew 的两个前缀。
157
+ * - Windows:安装器放在 `%LOCALAPPDATA%\Programs\magpie`、`%ProgramFiles%\magpie`;
158
+ * `go install` 落在 `%USERPROFILE%\go\bin`。
159
+ * - Linux:`~/.local/bin`、`/usr/local/bin`、`/usr/bin`、`/opt/magpie`,以及 `go install`
160
+ * 的 `~/go/bin`。
161
+ *
162
+ * `MAGPIE_BIN` 给了就只认它:用户明确指了一条路径,指错时如实回「找不到」,而不是悄悄去
163
+ * PATH 里找另一个 —— 那样排查起来是「我明明改了变量,它却还在跑旧的那个」。
164
+ */
165
+ function findBinary(env = process.env) {
166
+ const explicit = text(env.MAGPIE_BIN);
167
+ if (explicit) return isExecutable(explicit) ? explicit : null;
168
+ const home = os.homedir();
169
+ const names = process.platform === 'win32' ? ['magpie.exe', 'magpie'] : ['magpie'];
170
+ const candidates = [];
171
+ for (const dir of (env.PATH || '').split(path.delimiter)) {
172
+ if (!dir) continue;
173
+ for (const name of names) candidates.push(path.join(dir, name));
174
+ }
175
+ if (process.platform === 'darwin') {
176
+ candidates.push('/Applications/Magpie.app/Contents/MacOS/magpie');
177
+ candidates.push(path.join(home, 'Applications', 'Magpie.app', 'Contents', 'MacOS', 'magpie'));
178
+ candidates.push('/opt/homebrew/bin/magpie');
179
+ candidates.push('/usr/local/bin/magpie');
180
+ } else if (process.platform === 'win32') {
181
+ const local = text(env.LOCALAPPDATA);
182
+ if (local) candidates.push(path.join(local, 'Programs', 'magpie', 'magpie.exe'));
183
+ for (const key of ['ProgramFiles', 'ProgramW6432', 'ProgramFiles(x86)']) {
184
+ const base = text(env[key]);
185
+ if (base) candidates.push(path.join(base, 'magpie', 'magpie.exe'));
186
+ }
187
+ candidates.push(path.join(home, 'go', 'bin', 'magpie.exe'));
188
+ } else {
189
+ candidates.push(path.join(home, '.local', 'bin', 'magpie'));
190
+ candidates.push('/usr/local/bin/magpie');
191
+ candidates.push('/usr/bin/magpie');
192
+ candidates.push('/opt/magpie/magpie');
193
+ candidates.push(path.join(home, 'go', 'bin', 'magpie'));
194
+ }
195
+ for (const file of candidates) {
196
+ if (isExecutable(file)) return file;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /** 这个路径上是不是一个能执行的文件。 */
202
+ function isExecutable(file) {
203
+ try {
204
+ const stat = fs.statSync(file);
205
+ if (!stat.isFile()) return false;
206
+ if (process.platform !== 'win32') fs.accessSync(file, fs.constants.X_OK);
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 网页版这次用的 key。
215
+ *
216
+ * 干净的「只按环境变量或现造」判定(纯函数,方便单测)。`ensureWeb` 实际用的是
217
+ * `resolveKey`:多一层「上次留下的那份」。
218
+ */
219
+ function webKey(env = process.env) {
220
+ const given = text(env.MAGPIE_WEB_KEY);
221
+ if (KEY_RE.test(given)) return given;
222
+ // 32 位十六进制,够长且只含字母数字。
223
+ return require('node:crypto').randomBytes(16).toString('hex');
224
+ }
225
+
226
+ /** 本地存 key 的文件:默认 `~/.dsh/magpie-web-key`,`MAGPIE_WEB_KEY_FILE` 可改(测试用)。 */
227
+ function keyFilePath(env = process.env) {
228
+ const override = text(env.MAGPIE_WEB_KEY_FILE);
229
+ if (override) return override;
230
+ return path.join(os.homedir(), '.dsh', 'magpie-web-key');
231
+ }
232
+
233
+ /** 读上次留下的 key;没有/不合规就回空串。 */
234
+ function readStoredKey(env = process.env) {
235
+ try {
236
+ const value = fs.readFileSync(keyFilePath(env), 'utf8').trim();
237
+ return KEY_RE.test(value) ? value : '';
238
+ } catch {
239
+ return '';
240
+ }
241
+ }
242
+
243
+ /** 留一份 key 给下次用(0600,只有当前用户能读)。写不进去就算了,退回「本次进程记住」,不阻断打开。 */
244
+ function writeStoredKey(env, key) {
245
+ try {
246
+ const file = keyFilePath(env);
247
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
248
+ fs.writeFileSync(file, key, { mode: 0o600 });
249
+ fs.chmodSync(file, 0o600);
250
+ } catch { /* 落盘失败不致命:startedKeys 在本次进程内仍然记得 */ }
251
+ }
252
+
253
+ /**
254
+ * 这次网页版用的 key,按优先级:用户设的 `MAGPIE_WEB_KEY` → 上次留下那份 → 现造并存下。
255
+ *
256
+ * **为什么要「上次留下那份」**:插件热重载 / 宿主重启后,内存里那份 `startedKeys` 就没了,
257
+ * 而网页版进程是 detached 的、还活着。若这时现造一把新 key,就跟那个已经在跑的进程对不上,
258
+ * 只能回「拿不到登录凭据」——用户被要求自己去设环境变量,这不是我们要的。留一份稳定的 key,
259
+ * 同一个进程永远对得上,用户什么都不用配(与 magpie 自己把 key 放进 service file 一个路数)。
260
+ */
261
+ function resolveKey(env = process.env) {
262
+ const given = text(env.MAGPIE_WEB_KEY);
263
+ if (KEY_RE.test(given)) return given;
264
+ const stored = readStoredKey(env);
265
+ if (stored) return stored;
266
+ const key = webKey(env);
267
+ writeStoredKey(env, key);
268
+ return key;
269
+ }
270
+
271
+ /**
272
+ * 带 key 请求一下,看这个端口上跑着的 magpie 认不认这把 key。
273
+ *
274
+ * 认(登录那一跳 3xx)就说明正是我们自己起的那一个;401 / 别的都算不认。用来在起进程之前
275
+ * 先「认领」一个还活着的旧进程,而不是又起一个新的。
276
+ */
277
+ function probeKey(port, key, timeoutMs = 1200) {
278
+ return new Promise((resolve) => {
279
+ let done = false;
280
+ const finish = (ok) => {
281
+ if (done) return;
282
+ done = true;
283
+ try { req.destroy(); } catch { /* 已经断了 */ }
284
+ resolve(ok);
285
+ };
286
+ const req = http.request({
287
+ host: '127.0.0.1',
288
+ port,
289
+ path: `/?k=${encodeURIComponent(key)}`,
290
+ method: 'GET',
291
+ timeout: timeoutMs,
292
+ }, (res) => {
293
+ const code = res.statusCode;
294
+ res.resume();
295
+ // 302/303 是换 cookie 的那一跳;3xx 都算「key 被接受」。
296
+ finish(typeof code === 'number' && code >= 300 && code < 400);
297
+ });
298
+ req.on('timeout', () => finish(false));
299
+ req.on('error', () => finish(false));
300
+ req.end();
301
+ });
302
+ }
303
+
304
+ /** 等网页版在这个端口上答起来。 */
305
+ async function waitForWeb(port, deadline, probe = probeWeb) {
306
+ for (;;) {
307
+ if (await probe(port, 800)) return true;
308
+ if (Date.now() >= deadline) return false;
309
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
310
+ }
311
+ }
312
+
313
+ /**
314
+ * 把这台电脑上的 magpie 网页版拿给手机。
315
+ *
316
+ * 三种结局:已经有一个我们对得上 key 的在跑(认领它)、没有就起一个新的、起不来(如实说为什么)。
317
+ * **失败不抛**:这是一次能力探测,抛出去只会变成一条看不出哪一格缺了的 500。
318
+ *
319
+ * 认领优先于新起:网页版进程是 detached 的,插件热重载 / 宿主重启之后它常常还活着,而内存里
320
+ * 的 `startedKeys` 已经没了 —— 这时用稳定 key 探一下就能把它认回来,不必再起第二个,也不必
321
+ * 让用户去设环境变量。
322
+ */
323
+ async function ensureWeb({
324
+ env = process.env,
325
+ spawnImpl = spawn,
326
+ probe = probeWeb,
327
+ probeKeyFn = probeKey,
328
+ portFreeFn = portFree,
329
+ startTimeoutMs = START_TIMEOUT_MS,
330
+ } = {}) {
331
+ const base = preferredPort(env);
332
+ const key = resolveKey(env);
333
+
334
+ // 一、先看有没有「认得出来是我们的」在跑:本进程记得的、或稳定 key 探得通的,都认领。
335
+ for (let offset = 0; offset < 10; offset += 1) {
336
+ const port = base + offset;
337
+ if (startedKeys.get(port) === key) {
338
+ return { available: true, port, key };
339
+ }
340
+ if (!(await probe(port, 500))) continue;
341
+ if (await probeKeyFn(port, key, 800)) {
342
+ startedKeys.set(port, key);
343
+ return { available: true, port, key };
344
+ }
345
+ }
346
+
347
+ // 正在起的那一个:搭它的车,别再起第二个。
348
+ if (starting) {
349
+ const shared = await starting;
350
+ return shared;
351
+ }
352
+
353
+ // 二、挑一个空端口起我们自己的。
354
+ let port = null;
355
+ for (let offset = 0; offset < 10; offset += 1) {
356
+ const candidate = base + offset;
357
+ if (await portFreeFn(candidate)) { port = candidate; break; }
358
+ }
359
+ if (port == null) return { available: false, reason: 'no-free-port' };
360
+
361
+ const lastFailure = failures.get(port);
362
+ if (lastFailure && Date.now() - lastFailure.at < START_COOLDOWN_MS) {
363
+ return { available: false, reason: lastFailure.reason, port };
364
+ }
365
+
366
+ const bin = findBinary(env);
367
+ if (!bin) return { available: false, reason: 'no-magpie' };
368
+
369
+ starting = (async () => {
370
+ let child;
371
+ try {
372
+ child = spawnImpl(bin, ['web', '--addr', `127.0.0.1:${port}`, '--no-open'], {
373
+ env: { ...env, MAGPIE_WEB_KEY: key },
374
+ // 脱离父进程:插件重载 / 宿主重启不该把它带走,用户下次点开还能直接连上。
375
+ // stdout/stderr 丢掉:那是 magpie 自己的日志,插件不替它收(收着不读会堵住管道)。
376
+ detached: true,
377
+ stdio: 'ignore',
378
+ });
379
+ child.unref();
380
+ } catch (error) {
381
+ failures.set(port, { at: Date.now(), reason: 'spawn-failed' });
382
+ return { available: false, reason: 'spawn-failed', port };
383
+ }
384
+ const ok = await waitForWeb(port, Date.now() + startTimeoutMs, probe);
385
+ if (!ok) {
386
+ failures.set(port, { at: Date.now(), reason: 'start-timeout' });
387
+ return { available: false, reason: 'start-timeout', port };
388
+ }
389
+ failures.delete(port);
390
+ startedKeys.set(port, key);
391
+ return { available: true, port, key };
392
+ })().finally(() => { starting = null; });
393
+ return starting;
394
+ }
395
+
396
+ /**
397
+ * GET /api/mobile/v1/magpie/web — 手机要打开 magpie 网页版时问这一条。
398
+ *
399
+ * 回 `{ ok: true, available, port, key, reason }`。**key 只在这里出现一次**,App 拿它拼
400
+ * `http://127.0.0.1:3430/?k=…` 交给预览管道 —— 之后那一跳 magpie 会换成 cookie,
401
+ * 地址栏里的 key 不会跟着页面里的链接到处跑。
402
+ */
403
+ async function handleMagpieWeb(ctx, _req, res) {
404
+ try {
405
+ const entry = await ensureWeb({});
406
+ sendJson(res, 200, { ok: true, ...entry });
407
+ } catch {
408
+ sendJson(res, 200, { ok: true, available: false, reason: 'unexpected' });
409
+ }
410
+ }
411
+
412
+ module.exports = {
413
+ handleMagpieWeb,
414
+ ensureWeb,
415
+ probeWeb,
416
+ probeKey,
417
+ portFree,
418
+ pickPort,
419
+ findBinary,
420
+ webKey,
421
+ resolveKey,
422
+ readStoredKey,
423
+ keyFilePath,
424
+ preferredPort,
425
+ };
@@ -106,6 +106,22 @@ function installPhoneTools(ctx, deps) {
106
106
  const build = (scoped) => {
107
107
  const register = (tool) => scoped.tools.register(defineTool(tool));
108
108
 
109
+ /**
110
+ * 宿主的附件库:截图要交给它存,编号才是宿主认得的那种(见 `phone_screenshot` 的说明)。
111
+ *
112
+ * 取用放在执行时、不在注册时:`attachments` 是宿主后装的,注册这一趟它可能还不在。先问
113
+ * 注入作用域、再问外层 —— 与 `schedule.js` 同一个口径(那边也是执行时 `ctx.get`)。
114
+ */
115
+ const attachmentsOf = () => {
116
+ for (const source of [scoped, ctx]) {
117
+ if (!source || typeof source.get !== 'function') continue;
118
+ let store = null;
119
+ try { store = source.get('attachments'); } catch { store = null; }
120
+ if (store && typeof store.saveImage === 'function') return store;
121
+ }
122
+ return null;
123
+ };
124
+
109
125
  /**
110
126
  * 所有工具都要的那一步:从执行上下文里认出这条会话。
111
127
  *
@@ -182,6 +198,84 @@ function installPhoneTools(ctx, deps) {
182
198
  },
183
199
  });
184
200
 
201
+ /**
202
+ * 截一张后台屏的画面。
203
+ *
204
+ * **为什么要有它**:读屏拿到的是界面树(文字、控件、坐标),但视频、画布、游戏、部分网页
205
+ * 根本没有可读的节点 —— 那时候模型只能瞎猜。实测(2026-11-04):让它去抖音里看内容,
206
+ * 它连标题都读不到,因为整屏都是自绘的画面。截图这条能力一直在手机侧(读屏读不到时兜底
207
+ * 用的就是它),只是没交到模型手上。
208
+ *
209
+ * 图必须存进**宿主**的附件库:工具结果里的图片引用会随会话历史再发给模型,而宿主只认自己
210
+ * 发的那种带 `sha256:` 前缀的编号。2026-11-04 真机:图存进了插件自己那份库里、编号是裸的
211
+ * 哈希,下一轮请求整条挂掉(「附件引用无效」)。插件那份库是给手机端发图用的,两边不是一回事。
212
+ */
213
+ register({
214
+ name: 'phone_screenshot',
215
+ description: 'Take a picture of the background screen the agent is working on. Use it when the accessibility tree cannot describe what is on screen: videos, canvas, games, image galleries. Returns the picture as an image.',
216
+ parameters: {
217
+ target: { type: 'string', description: 'What you are looking at, in a few words.' },
218
+ },
219
+ output: {
220
+ schema: {
221
+ type: 'object',
222
+ additionalProperties: false,
223
+ properties: {
224
+ deviceId: { type: 'string' },
225
+ width: { type: 'number' },
226
+ height: { type: 'number' },
227
+ note: { type: 'string' },
228
+ image: { type: 'object', additionalProperties: true },
229
+ },
230
+ },
231
+ render: (_args, value) => {
232
+ const blocks = [{ type: 'text', text: `后台屏截图(${value.width}×${value.height})` }];
233
+ if (value.image) blocks.push(value.image);
234
+ return blocks;
235
+ },
236
+ },
237
+ execute: async (args, exec) => {
238
+ const sessionId = sessionOf(exec);
239
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
240
+ const data = await run(service, deviceId, sessionId, 'screen.screenshot', {}, exec.signal);
241
+ const blob = data && typeof data.blob === 'string' ? data.blob : '';
242
+ if (!blob) throw new Error('没截到画面:手机那边没有回图片。');
243
+ const store = attachmentsOf();
244
+ if (!store) throw new Error('宿主没有附件库,这张图存不进去。');
245
+ let ref;
246
+ try {
247
+ ref = await store.saveImage({
248
+ data: Buffer.from(blob, 'base64'),
249
+ mediaType: 'image/png',
250
+ name: textOf(args.target, MAX_TEXT) || 'phone-screen',
251
+ });
252
+ } catch (error) {
253
+ throw new Error(`截到的画面存不下来:${String((error && error.message) || error).slice(0, 200)}`);
254
+ }
255
+ // 有值才塞键:宿主不接受值为 undefined 的字段(见 A3-79 的坑)。
256
+ const value = {
257
+ deviceId,
258
+ width: Number(ref.width) || 0,
259
+ height: Number(ref.height) || 0,
260
+ note: mentionMismatch(sessionId, deviceId),
261
+ // 引用块按宿主自己那种形状写(`attachmentId` 带 `sha256:` 前缀、另有字节数与宽高),
262
+ // 这一块会被原样留在历史里,下一轮请求按它取图。
263
+ image: {
264
+ type: 'image',
265
+ attachment: {
266
+ attachmentId: ref.attachmentId,
267
+ mediaType: ref.mediaType,
268
+ bytes: ref.bytes,
269
+ width: ref.width,
270
+ height: ref.height,
271
+ ...(ref.name ? { name: ref.name } : {}),
272
+ },
273
+ },
274
+ };
275
+ return value;
276
+ },
277
+ });
278
+
185
279
  register({
186
280
  name: 'phone_tap',
187
281
  description: 'Tap an element on the phone screen by its ref (from phone_screen), or by viewport-relative percentage coordinates when no ref fits. One call taps once.',
@@ -255,14 +255,20 @@ function noAnswerer(error) {
255
255
  * 撤回那一发,卡会一直悬着直到人手动点。那一发被撤时 `downstream` 会再落一次;上面的
256
256
  * race 已经挂着它的处理函数,迟到的那次不会冒成未处理异常,`settle` 本身也是幂等的。
257
257
  *
258
- * `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)不是结论:手机正在看这个会话时
259
- * 卡还在手机上等着,继续等手机答;没人看时照旧把失败交回去,纯桌面表现一字不改。
258
+ * `NO_PROVIDER`(桌面没开 / 桌面把请求 delegate 回来)**任何情况下都不是结论**,也跟
259
+ * 「手机此刻有没有在看这条会话」无关:手机这条路本来就是「人离开了再看」,通知与侧栏的
260
+ * 待处理标记就是把人带回来的那两根线。此前只在「有观众」时才据此继续等,于是手机停在
261
+ * 别的会话 / 在后台时,一条本来还能答的提问当场被判成没人能答,模型当作被跳过继续跑 ——
262
+ * 用户看到的就是「点通知进来,问题已经自己跳过了」。
263
+ *
264
+ * 收尾只有两条:手机作答(`entry.wait`),或者宿主的取消信号(停止这一轮 / 轮次被中止,
265
+ * 见 `bindRequest` 的 onAbort)。一直没人答也没有取消时,这条提问就停在原地等人回来。
260
266
  */
261
267
  function holdForPhone(entry, next) {
262
268
  const downstream = Promise.resolve().then(() => next()).then(
263
269
  (answer) => { settle(entry, null); return answer; },
264
270
  (error) => {
265
- if (noAnswerer(error) && watcherCount(entry.sessionId) > 0) return new Promise(() => {});
271
+ if (noAnswerer(error)) return new Promise(() => {});
266
272
  settle(entry, null);
267
273
  throw error;
268
274
  },
@@ -283,25 +283,25 @@ async function handleSessionAttachment(ctx, req, res) {
283
283
  || typeof body.attachmentId !== 'string' || !body.attachmentId) {
284
284
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, attachmentId }' } });
285
285
  }
286
- // 直读宿主附件库(2026-10-31):登记过的 id 直接从本地盘流给手机,省掉「等宿主把整张
287
- // 读成 base64 再返回」那一段(宿主主线程忙时手机会一个字节都收不到)。前提与回退见
288
- // `attachment-store.js` 的 `streamIfRemembered`:不满足就往下走原来的宿主接口。
286
+ // 直读宿主附件库(2026-10-31):登记过的 id 直接从本地盘流给手机,省掉「等宿主把整张读成
287
+ // base64 再返回」那段(宿主主线程忙时手机一个字节都收不到)。回退见 `streamIfRemembered`。
289
288
  if (body.raw === true && await attachmentStore.streamIfRemembered(req, res, body.sessionId, body.attachmentId)) return;
290
- const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachmentId: body.attachmentId }));
289
+ // 要递一份**和盘上字节对得上**的引用:宿主会逐字段比对类型 / 字节数 / 宽高,占位值会被
290
+ // 挡回 `Stored attachment metadata does not match its reference.`。拼不出来(对象不在)回 404。
291
+ const ref = attachmentStore.refFor(body.sessionId, body.attachmentId);
292
+ if (!ref) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
293
+ const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachment: ref }));
291
294
  if (error) return sendJson(res, 400, { ok: false, error });
292
295
  const data = value && typeof value.data === 'string' ? value.data : '';
293
- // 附件已被会话清理掉时宿主回的是空字节:这不是「稍后再试就好」,如实回 404,
294
- // 让 App 那边把重试按钮的失败说清楚,而不是转圈到超时。
296
+ // 宿主回空字节=附件已被会话清理掉:如实回 404,别让 App 那边的重试按钮转圈到超时。
295
297
  if (!data) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
296
298
  const attachment = (value && value.attachment) || {};
297
299
  const mediaType = typeof attachment.mediaType === 'string' && attachment.mediaType ? attachment.mediaType : 'image/png';
298
- // `dims: true` = 只要原始宽高,不要字节。老会话里宿主没存附件的宽高,正文下发时就带不上,
299
- // 手机侧只能按 240 方盒先占位、等字节到了再变高(那一行一变高,屏幕上就是「跳」)。这一档
300
- // 让手机在挂载那一帧就能按真比例占好:回给它的只有两格数字,整张图的 base64 不出这台电脑。
301
- // `raw: true` = 要原始字节,不要 base64(2026-10-30)。宿主给的仍是 base64,这里解成
302
- // Buffer 后按分块信封流出去:手机那边落盘的是密文,靠原生解盘口边解边写,整张图的
303
- // base64 不进 JS 堆 —— 一张两兆的截图在手机上原本要摊着两兆七的字符串加一份解析副本。
304
- // **不封包不许走这一档**:那就成了明文传输,正好把这条路本来的意义抹掉,如实回 400。
300
+ // `dims: true` = 只要原始宽高,不要字节:老会话里宿主没存宽高,手机得先按兜底盒占位、等字节
301
+ // 到了再变高(那一行一跳,上面整截就被推走)。回给它的只有两格数字,base64 不出这台电脑。
302
+ // `raw: true` = 要原始字节(2026-10-30):宿主给的仍是 base64,这里解成 Buffer 后按分块信封
303
+ // 流出去,手机那边靠原生解盘口边解边写,整张图的 base64 不进 JS 堆。
304
+ // **不封包不许走这一档**:那就成了明文传输,如实回 400。
305
305
  if (body.raw === true) {
306
306
  const opened = req && req.__dshE2ee;
307
307
  if (!opened || !opened.keys || !opened.req) {