dsh-mobile-plugin 0.5.17 → 0.5.19

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,565 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 给模型的那组手机动作。
5
+ *
6
+ * ## 能力面的边界
7
+ *
8
+ * 这里面**没有**「执行命令」。模型能说的是「看这块屏」「点第 7 个元素」「输入这段字」
9
+ * 「往上滑一屏」「返回」「打开这个 App」,参数全是结构化字段、全部经过校验;没有任何一格
10
+ * 能拼出一段 shell。手机侧认的动作表与这里一一对应(见 `phone/service.js` 的 `ACTIONS`),
11
+ * 多一个动作就是两边同时扩一次能力面。
12
+ *
13
+ * ## 为什么每个工具都要先问一次「操作哪台手机」
14
+ *
15
+ * 一台电脑可以连好几台手机,而一条会话可能在任意一台手机上被打开。目标由 `phone/binding`
16
+ * 决定(会话第一次收到谁的消息就绑谁),模型的参数里**没有** deviceId —— 它不该、也没法
17
+ * 自己挑一台机器。要换,就走 `phone_target`:那是显式的、用户看得见的一次改绑。
18
+ *
19
+ * ## 报错口径
20
+ *
21
+ * 目标不存在、那台手机断开、动作超时,都抛成工具失败 —— 消息写得像人话,模型据此改主意
22
+ * 或者问用户,而不是重试同一发。**绝不**在目标不可用时偷偷换一台在线的顶上。
23
+ */
24
+
25
+ const binding = require('./binding.js');
26
+
27
+ /** 元素的短标签:模型看到的是「第几个」,不是像素坐标。 */
28
+ const MAX_TEXT = 120;
29
+
30
+ function textOf(value, max) {
31
+ if (typeof value !== 'string') return '';
32
+ const trimmed = value.trim();
33
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
34
+ }
35
+
36
+ /**
37
+ * 这些工具都要拿到「这条会话绑在哪台手机上」,拿不到就抛一句能照做的话。
38
+ *
39
+ * @returns {{deviceId: string, service: object}}
40
+ */
41
+ function targetOf(ctx, deps, sessionId) {
42
+ const { deviceId, from } = binding.resolveTarget(sessionId);
43
+ if (!deviceId) {
44
+ throw new Error('这条对话还没有指定要操作哪台手机:让用户在这台手机上发一句话,或者用 phone_target 选定一台。');
45
+ }
46
+ const service = deps.getService();
47
+ if (!service) throw new Error('手机操作服务没在运行(插件刚启动或已停用),先让用户重新连一次。');
48
+ if (!service.isOnline(deviceId)) {
49
+ const label = deps.describeDevice ? deps.describeDevice(deviceId) : deviceId;
50
+ throw new Error(`要操作的那台手机(${label})现在没连上。让用户在手机上打开 App 让连接恢复,或者用 phone_target 换一台。`);
51
+ }
52
+ return { deviceId, from, service };
53
+ }
54
+
55
+ /** 把服务层的失败翻译成一句能给模型看的话。 */
56
+ async function run(service, deviceId, sessionId, action, args, signal) {
57
+ try {
58
+ const { data } = await service.call(deviceId, sessionId, action, args, { signal });
59
+ return data === undefined ? null : data;
60
+ } catch (error) {
61
+ const code = error && error.code ? error.code : 'failed';
62
+ const message = error && error.message ? error.message : '手机那边没能完成这一步';
63
+ if (code === 'offline') throw new Error('那台手机在这期间断开了连接,这一步没有做。');
64
+ if (code === 'timeout') throw new Error(`那台手机 ${message}。可以再试一次,或者先 phone_screen 看看它现在停在哪。`);
65
+ if (code === 'unsupported') throw new Error(`那台手机上装的 App 版本还不支持 ${action},需要把手机上的 App 升级到最新。`);
66
+ if (code === 'aborted') throw new Error('这一步被取消了。');
67
+ throw new Error(message);
68
+ }
69
+ }
70
+
71
+ /** 一句话总结「现在屏上是什么」,给工具结果用。 */
72
+ function summarizeScreen(data) {
73
+ if (!data || typeof data !== 'object') return '没有读到屏幕内容。';
74
+ // 「读屏没开」必须说在前面:否则「0 个元素」会被读成「界面是空的」或「App 没起来」,
75
+ // 模型就会反复重读、换 App 打开,越试越乱(2026-11-04 用户现场)。
76
+ if (data.a11yDisabled) {
77
+ return '读屏没有打开:只看到 0 个元素,不是界面空的,是我们看不见。让用户在「设置 › 权限 › 操作手机」里点「去打开」(现在点一下就开,不用去系统设置翻),然后重新读。';
78
+ }
79
+ const parts = [];
80
+ if (data.packageName) parts.push(`当前 App:${data.packageName}`);
81
+ if (data.activity) parts.push(`页面:${data.activity}`);
82
+ const count = Array.isArray(data.elements) ? data.elements.length : 0;
83
+ parts.push(`可见元素 ${count} 个`);
84
+ if (data.treeUnreadable) parts.push('没读到应用窗口(可能是自绘界面),坐标只能看截图');
85
+ return parts.join(';');
86
+ }
87
+
88
+ /**
89
+ * 注册这组工具。缺 `tools` 注入的 profile(比如纯手机端宿主)静默跳过 —— 与
90
+ * `installExtensions` 同一口径,别把整块装配掀掉。
91
+ *
92
+ * **必须走 `ctx.inject(['tools'], …)`,不能直接 `ctx.tools.register`**:宿主里 `tools` 是
93
+ * 子代理作用域提供的服务,插件这一层直接读 `ctx.tools` 拿到的是 undefined(或者一个没有
94
+ * `register` 的影子对象),`register` 一调就抛,被下面那句容错吞掉 —— 现象是八个工具**一条
95
+ * 都没交到模型手上**,而模型只知道能用 `bash` 去敲 adb,于是它去截了电脑上连着的另一台设备。
96
+ * 同树的 `dsh-free-search` 也是这么写的(`ctx.inject(['tools'], (sctx) => …)`)。
97
+ */
98
+ function installPhoneTools(ctx, deps) {
99
+ let defineTool;
100
+ try {
101
+ ({ defineTool } = require('@deepseek-ai/dsh-tools'));
102
+ } catch {
103
+ return;
104
+ }
105
+
106
+ const build = (scoped) => {
107
+ const register = (tool) => scoped.tools.register(defineTool(tool));
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
+
125
+ /**
126
+ * 所有工具都要的那一步:从执行上下文里认出这条会话。
127
+ *
128
+ * `exec.agent.id` 就是会话 id(本仓 `dsh-codex-subscription` 也这么取)。**别写成
129
+ * `exec.agent.session.id`**:那一格不存在,取出来恒为空串,于是每个工具都以为「这条对话
130
+ * 还没绑手机」,绑定账也永远写着另一个键 —— 症状是「明明绑过,它还是说没有目标」。
131
+ */
132
+ const sessionOf = (exec) => {
133
+ const id = exec && exec.agent && exec.agent.id;
134
+ return typeof id === 'string' ? id : '';
135
+ };
136
+
137
+ /**
138
+ * 拼出一句「这一轮来自谁、绑着谁」的提醒。
139
+ *
140
+ * 只在两者不一致时才说 —— 每一轮都说一遍等于噪音,模型很快就会学会忽略它。
141
+ */
142
+ const mentionMismatch = (sessionId, bound) => {
143
+ const sender = binding.lastSenderOf(sessionId);
144
+ if (!sender || !bound || sender === bound) return '';
145
+ const boundLabel = deps.describeDevice ? deps.describeDevice(bound) : bound;
146
+ const senderLabel = deps.describeDevice ? deps.describeDevice(sender) : sender;
147
+ return `(提醒:这条对话绑的是 ${boundLabel},而这句话是 ${senderLabel} 发来的。没让你换的话就继续操作 ${boundLabel},别擅自改绑。)`;
148
+ };
149
+
150
+ register({
151
+ name: 'phone_screen',
152
+ description: 'Read the phone screen the agent is working on: which app is in front, and the visible elements with a short ref each. This is the structured accessibility tree, not a picture; call it before tapping so refs are fresh.',
153
+ parameters: {
154
+ includeText: {
155
+ type: 'boolean',
156
+ description: 'Include the readable text of each element. Default true; turn off for very long lists.',
157
+ },
158
+ },
159
+ output: {
160
+ schema: {
161
+ type: 'object',
162
+ additionalProperties: false,
163
+ properties: {
164
+ deviceId: { type: 'string' },
165
+ packageName: { type: 'string' },
166
+ activity: { type: 'string' },
167
+ // 元素是**任意形状的界面节点**(不同 App 给的字段不一样),所以 items 必须显式允许
168
+ // 附加字段 —— dsh-tools 的 schema 编译器不接受留空的 additionalProperties。
169
+ elements: { type: 'array', items: { type: 'object', additionalProperties: true } },
170
+ // 无障碍没开时这一格为真:说清楚「是我们看不见」而不是「界面是空的」。
171
+ a11yDisabled: { type: 'boolean' },
172
+ treeUnreadable: { type: 'boolean' },
173
+ note: { type: 'string' },
174
+ },
175
+ },
176
+ render: (_args, value) => [{ type: 'text', text: summarizeScreen(value) }],
177
+ },
178
+ execute: async (args, exec) => {
179
+ const sessionId = sessionOf(exec);
180
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
181
+ const data = await run(service, deviceId, sessionId, 'screen.read', {
182
+ includeText: args.includeText !== false,
183
+ }, exec.signal);
184
+ const note = mentionMismatch(sessionId, deviceId);
185
+ // 这两个开关**只在这两件事真的成立时才有键**:宿主不允许工具结果里出现值为 undefined
186
+ // 的字段(整条结果会被判成不是合法 JSON,模型看到的是「读屏坏了」),所以按「有值才塞」
187
+ // 的写法构造,而不是摆一个 undefined 占位。
188
+ const value = {
189
+ deviceId,
190
+ packageName: textOf(data && data.packageName, MAX_TEXT),
191
+ activity: textOf(data && data.activity, MAX_TEXT),
192
+ elements: Array.isArray(data && data.elements) ? data.elements.slice(0, 200) : [],
193
+ note,
194
+ };
195
+ if (data && data.a11yDisabled === true) value.a11yDisabled = true;
196
+ if (data && data.treeUnreadable === true) value.treeUnreadable = true;
197
+ return value;
198
+ },
199
+ });
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
+
279
+ register({
280
+ name: 'phone_tap',
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.',
282
+ parameters: {
283
+ ref: { type: 'string', description: 'Element ref from the latest phone_screen, e.g. "e12".' },
284
+ x: { type: 'number', description: 'Horizontal position in percent of the screen width (0-100). Use only without ref.' },
285
+ y: { type: 'number', description: 'Vertical position in percent of the screen height (0-100). Use only without ref.' },
286
+ target: { type: 'string', description: 'What you are tapping, in a few words. Required for coordinate taps so the step stays readable.' },
287
+ },
288
+ output: {
289
+ schema: {
290
+ type: 'object',
291
+ additionalProperties: false,
292
+ properties: {
293
+ ok: { type: 'boolean' },
294
+ packageName: { type: 'string' },
295
+ // 元素是**任意形状的界面节点**(不同 App 给的字段不一样),所以 items 必须显式允许
296
+ // 附加字段 —— dsh-tools 的 schema 编译器不接受留空的 additionalProperties。
297
+ elements: { type: 'array', items: { type: 'object', additionalProperties: true } },
298
+ note: { type: 'string' },
299
+ },
300
+ },
301
+ render: () => [{ type: 'text', text: '已点击,界面见结果。' }],
302
+ },
303
+ execute: async (args, exec) => {
304
+ const sessionId = sessionOf(exec);
305
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
306
+ const hasRef = typeof args.ref === 'string' && args.ref.trim();
307
+ const hasPoint = Number.isFinite(args.x) && Number.isFinite(args.y);
308
+ if (!hasRef && !hasPoint) throw new Error('要么给 ref(phone_screen 里的元素编号),要么给 x / y 百分比。');
309
+ if (hasRef && hasPoint) throw new Error('ref 和坐标只能给一样:有 ref 就按元素点。');
310
+ if (!hasRef && !textOf(args.target, MAX_TEXT)) throw new Error('按坐标点的时候要说明点的是什么(target)。');
311
+ const data = await run(service, deviceId, sessionId, 'tap', {
312
+ ref: hasRef ? args.ref.trim() : '',
313
+ x: hasPoint ? args.x : null,
314
+ y: hasPoint ? args.y : null,
315
+ target: textOf(args.target, MAX_TEXT),
316
+ }, exec.signal);
317
+ return {
318
+ ok: true,
319
+ packageName: textOf(data && data.packageName, MAX_TEXT),
320
+ elements: Array.isArray(data && data.elements) ? data.elements.slice(0, 120) : [],
321
+ note: mentionMismatch(sessionId, deviceId),
322
+ };
323
+ },
324
+ });
325
+
326
+ register({
327
+ name: 'phone_type',
328
+ description: 'Type text into an input on the phone screen. Give the input element ref from phone_screen; without it the text goes to whatever field already has focus.',
329
+ parameters: {
330
+ text: { type: 'string', required: true, description: 'Text to enter. Replaces the field content when ref is given.' },
331
+ ref: { type: 'string', description: 'Input element ref from phone_screen. Omit to type into the focused field.' },
332
+ submit: { type: 'boolean', description: 'Press Enter afterwards. Default false.' },
333
+ },
334
+ output: {
335
+ schema: {
336
+ type: 'object',
337
+ additionalProperties: false,
338
+ properties: {
339
+ ok: { type: 'boolean' },
340
+ packageName: { type: 'string' },
341
+ // 元素是**任意形状的界面节点**(不同 App 给的字段不一样),所以 items 必须显式允许
342
+ // 附加字段 —— dsh-tools 的 schema 编译器不接受留空的 additionalProperties。
343
+ elements: { type: 'array', items: { type: 'object', additionalProperties: true } },
344
+ note: { type: 'string' },
345
+ },
346
+ },
347
+ render: () => [{ type: 'text', text: '已输入。' }],
348
+ },
349
+ execute: async (args, exec) => {
350
+ const sessionId = sessionOf(exec);
351
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
352
+ const value = typeof args.text === 'string' ? args.text : '';
353
+ if (!value) throw new Error('要输入的文字是空的。');
354
+ const data = await run(service, deviceId, sessionId, 'type', {
355
+ text: value,
356
+ ref: typeof args.ref === 'string' ? args.ref.trim() : '',
357
+ submit: args.submit === true,
358
+ }, exec.signal);
359
+ return {
360
+ ok: true,
361
+ packageName: textOf(data && data.packageName, MAX_TEXT),
362
+ elements: Array.isArray(data && data.elements) ? data.elements.slice(0, 120) : [],
363
+ note: mentionMismatch(sessionId, deviceId),
364
+ };
365
+ },
366
+ });
367
+
368
+ register({
369
+ name: 'phone_scroll',
370
+ description: 'Scroll the phone screen: up (content moves up, i.e. see what is below), down, left or right. Distance is a fraction of the screen.',
371
+ parameters: {
372
+ direction: { type: 'string', required: true, enum: ['up', 'down', 'left', 'right'], description: 'Direction the content moves.' },
373
+ amount: { type: 'number', description: 'How far, as a fraction of the screen (0.1-1). Default 0.7.' },
374
+ },
375
+ output: {
376
+ schema: {
377
+ type: 'object',
378
+ additionalProperties: false,
379
+ properties: {
380
+ ok: { type: 'boolean' },
381
+ packageName: { type: 'string' },
382
+ // 元素是**任意形状的界面节点**(不同 App 给的字段不一样),所以 items 必须显式允许
383
+ // 附加字段 —— dsh-tools 的 schema 编译器不接受留空的 additionalProperties。
384
+ elements: { type: 'array', items: { type: 'object', additionalProperties: true } },
385
+ note: { type: 'string' },
386
+ },
387
+ },
388
+ render: () => [{ type: 'text', text: '已滚动。' }],
389
+ },
390
+ execute: async (args, exec) => {
391
+ const sessionId = sessionOf(exec);
392
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
393
+ const direction = ['up', 'down', 'left', 'right'].includes(args.direction) ? args.direction : '';
394
+ if (!direction) throw new Error('direction 只能是 up / down / left / right。');
395
+ const amount = Number.isFinite(args.amount) ? Math.min(1, Math.max(0.1, args.amount)) : 0.7;
396
+ const data = await run(service, deviceId, sessionId, 'swipe', { direction, amount }, exec.signal);
397
+ return {
398
+ ok: true,
399
+ packageName: textOf(data && data.packageName, MAX_TEXT),
400
+ elements: Array.isArray(data && data.elements) ? data.elements.slice(0, 120) : [],
401
+ note: mentionMismatch(sessionId, deviceId),
402
+ };
403
+ },
404
+ });
405
+
406
+ register({
407
+ name: 'phone_key',
408
+ description: 'Press one system key on the phone: back, home, enter, or recents. Nothing else (no power, no volume) — those are the user\'s own buttons.',
409
+ parameters: {
410
+ key: { type: 'string', required: true, enum: ['back', 'home', 'enter', 'recents'], description: 'Which key to press.' },
411
+ },
412
+ output: {
413
+ schema: {
414
+ type: 'object',
415
+ additionalProperties: false,
416
+ properties: {
417
+ ok: { type: 'boolean' },
418
+ packageName: { type: 'string' },
419
+ // 元素是**任意形状的界面节点**(不同 App 给的字段不一样),所以 items 必须显式允许
420
+ // 附加字段 —— dsh-tools 的 schema 编译器不接受留空的 additionalProperties。
421
+ elements: { type: 'array', items: { type: 'object', additionalProperties: true } },
422
+ note: { type: 'string' },
423
+ },
424
+ },
425
+ render: () => [{ type: 'text', text: '已按键。' }],
426
+ },
427
+ execute: async (args, exec) => {
428
+ const sessionId = sessionOf(exec);
429
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
430
+ const key = ['back', 'home', 'enter', 'recents'].includes(args.key) ? args.key : '';
431
+ if (!key) throw new Error('key 只能是 back / home / enter / recents。');
432
+ const data = await run(service, deviceId, sessionId, 'key', { key }, exec.signal);
433
+ return {
434
+ ok: true,
435
+ packageName: textOf(data && data.packageName, MAX_TEXT),
436
+ elements: Array.isArray(data && data.elements) ? data.elements.slice(0, 120) : [],
437
+ note: mentionMismatch(sessionId, deviceId),
438
+ };
439
+ },
440
+ });
441
+
442
+ register({
443
+ name: 'phone_apps',
444
+ description: 'List the apps installed on the phone that can be opened (label + package), optionally filtered by a keyword. Use it to find the package before phone_open_app.',
445
+ parameters: {
446
+ query: { type: 'string', description: 'Case-insensitive keyword matched against label or package.' },
447
+ limit: { type: 'integer', description: 'How many to return, 1-100. Default 40.' },
448
+ },
449
+ output: {
450
+ schema: {
451
+ type: 'object',
452
+ additionalProperties: false,
453
+ properties: {
454
+ apps: { type: 'array', items: { type: 'object', additionalProperties: true } },
455
+ note: { type: 'string' },
456
+ },
457
+ },
458
+ render: (_args, value) => [{ type: 'text', text: `找到 ${Array.isArray(value.apps) ? value.apps.length : 0} 个 App。` }],
459
+ },
460
+ execute: async (args, exec) => {
461
+ const sessionId = sessionOf(exec);
462
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
463
+ const limit = Number.isInteger(args.limit) ? Math.min(100, Math.max(1, args.limit)) : 40;
464
+ const data = await run(service, deviceId, sessionId, 'app.list', {
465
+ query: textOf(args.query, 60),
466
+ limit,
467
+ }, exec.signal);
468
+ return {
469
+ apps: Array.isArray(data && data.apps) ? data.apps.slice(0, limit) : [],
470
+ note: mentionMismatch(sessionId, deviceId),
471
+ };
472
+ },
473
+ });
474
+
475
+ register({
476
+ name: 'phone_open_app',
477
+ description: 'Open an app on the phone screen the agent works on. Opening happens on a background display, so the user keeps their own screen.',
478
+ parameters: {
479
+ packageName: { type: 'string', required: true, description: 'Android package name, e.g. "com.tencent.mm". Find it with phone_apps.' },
480
+ fresh: { type: 'boolean', description: 'Restart the app instead of reusing its existing state. Default false.' },
481
+ },
482
+ output: {
483
+ schema: {
484
+ type: 'object',
485
+ additionalProperties: false,
486
+ properties: {
487
+ ok: { type: 'boolean' },
488
+ packageName: { type: 'string' },
489
+ // 元素是**任意形状的界面节点**(不同 App 给的字段不一样),所以 items 必须显式允许
490
+ // 附加字段 —— dsh-tools 的 schema 编译器不接受留空的 additionalProperties。
491
+ elements: { type: 'array', items: { type: 'object', additionalProperties: true } },
492
+ note: { type: 'string' },
493
+ },
494
+ },
495
+ render: (_args, value) => [{ type: 'text', text: `已打开 ${value.packageName || 'App'}。` }],
496
+ },
497
+ execute: async (args, exec) => {
498
+ const sessionId = sessionOf(exec);
499
+ const { deviceId, service } = targetOf(ctx, deps, sessionId);
500
+ const pkg = textOf(args.packageName, 200);
501
+ if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/.test(pkg)) throw new Error('packageName 看起来不像安卓包名。');
502
+ const data = await run(service, deviceId, sessionId, 'app.open', {
503
+ packageName: pkg,
504
+ fresh: args.fresh === true,
505
+ }, exec.signal);
506
+ return {
507
+ ok: true,
508
+ packageName: textOf(data && data.packageName, MAX_TEXT) || pkg,
509
+ elements: Array.isArray(data && data.elements) ? data.elements.slice(0, 120) : [],
510
+ note: mentionMismatch(sessionId, deviceId),
511
+ };
512
+ },
513
+ });
514
+
515
+ register({
516
+ name: 'phone_target',
517
+ description: 'Show or change which phone this conversation operates. Without arguments it reports the binding and the phones currently connected. With a deviceId it rebinds — only do that when the user asked to switch phones.',
518
+ parameters: {
519
+ deviceId: { type: 'string', description: 'Rebind this conversation to that phone. Omit to just look.' },
520
+ },
521
+ output: {
522
+ schema: {
523
+ type: 'object',
524
+ additionalProperties: false,
525
+ properties: {
526
+ deviceId: { type: 'string' },
527
+ from: { type: 'string' },
528
+ changed: { type: 'boolean' },
529
+ devices: { type: 'array', items: { type: 'object', additionalProperties: true } },
530
+ note: { type: 'string' },
531
+ },
532
+ },
533
+ render: (_args, value) => [{ type: 'text', text: value.deviceId ? `这条对话操作的是 ${value.deviceId}。` : '这条对话还没有指定手机。' }],
534
+ },
535
+ execute: async (args, exec) => {
536
+ const sessionId = sessionOf(exec);
537
+ const wanted = textOf(args.deviceId, 200);
538
+ if (wanted) {
539
+ const service = deps.getService();
540
+ if (!service || !service.isOnline(wanted)) {
541
+ throw new Error('那台手机现在没连上,先让用户在手机上打开 App 让连接恢复,再改绑。');
542
+ }
543
+ }
544
+ const resolved = binding.resolveTarget(sessionId, wanted);
545
+ return {
546
+ deviceId: resolved.deviceId,
547
+ from: resolved.from,
548
+ changed: resolved.changed,
549
+ devices: deps.listDevices ? deps.listDevices() : [],
550
+ note: mentionMismatch(sessionId, resolved.deviceId),
551
+ };
552
+ },
553
+ });
554
+ };
555
+
556
+ // 老宿主 / 无工具注入的 profile(纯手机端宿主):`ctx.inject` 不存在时什么也不做,
557
+ // 别把整块装配掀掉。注册本身抛错照旧往上抛 —— 那是真的坏了,不该被当成「没有这个 profile」。
558
+ if (typeof ctx.inject !== 'function') return;
559
+ ctx.inject(['tools'], (scoped) => {
560
+ if (!scoped || !scoped.tools || typeof scoped.tools.register !== 'function') return;
561
+ build(scoped);
562
+ });
563
+ }
564
+
565
+ module.exports = { installPhoneTools, summarizeScreen };
@@ -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
  },
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端那两个「在用户自己电脑上跑东西」的服务:远程终端与 CLI 对话。
5
+ *
6
+ * 为什么有这一层:装配层(`impl.js`)有行数上限,那一层只该出现「把东西接上」的代码,不该
7
+ * 为了两个 require 各占一行。这里把两份放一起,装配层少一行就够用。
8
+ *
9
+ * 两者是并列的两条口子,不是一回事:终端把 CLI 的 TUI 字节原样搬给手机,CLI 对话让 CLI
10
+ * 以 ACP 的方式跑、交结构化事件给对话页。
11
+ */
12
+
13
+ module.exports = {
14
+ ...require('./terminal.js'),
15
+ ...require('./cli/service.js'),
16
+ };
@@ -170,6 +170,7 @@ async function handleSend(ctx, req, res) {
170
170
  if (normalized.code === MODEL_UNAVAILABLE_ERROR.code) invalidateSessionModels(ctx, req, body.sessionId);
171
171
  return sendJson(res, 400, { ok: false, error: normalized });
172
172
  }
173
+ require('./phone/binding.js').noteRequestSender(req, body.sessionId);
173
174
  sendJson(res, 200, { ok: true, accepted: true });
174
175
  }
175
176
 
@@ -3,24 +3,12 @@
3
3
  /**
4
4
  * 统计账本:按天累计的用量 + 每条会话的累计与身份(`$DSH_HOME/mobile-stats.json`)。
5
5
  *
6
- * 它不寄在会话日志上:用户会清理已归档的会话,删的就是日志本身;统计每次现算的话,清理之后
7
- * 那部分用量会凭空消失。一旦读到增量就落进这个文件,之后会话在不在都不影响历史数字。
6
+ * 不寄在会话日志上:用户会清理已归档的会话,一旦读到增量就落进这个文件,之后会话在不在都不影响
7
+ * 历史数字。口径与桌面 dsh-context 的每日账本一致:一天的账落在「观测到增量」的宿主本地日期上,
8
+ * 计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出。
8
9
  *
9
- * 口径(与桌面 dsh-context 的每日账本一致,只保留手机用得着的部分):一天的账落在「观测到
10
- * 增量」的宿主本地日期上;计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出,四个桶分开存;
11
- * 每条会话一条记录,范围内有量的都上卡片 —— 卡片条数就是页面那个「会话」数字,所以
12
- * **不按展示需要裁剪**(裁了那个数字就变成上限值了,不再是真的条数)。
13
- *
14
- * 第三张表是模型维度 `models`:按「渠道 + 模型」攒量,服务统计页那块「模型用量」。它账的是
15
- * 同一个增量 —— 也就是说这里没有「按模型拆过一遍」的原始数据,模型名取自**采集那一刻**这条
16
- * 会话正在用的那个选择(见 stats-collect.cjs 文件头),会话中途换模型的那一段整体归到换后的
17
- * 那个名下。这是水位法本来就有的近似,不是这里另加的简化。
18
- *
19
- * 金额(`cost`)是**估算**,不落盘:折算时按模型桶的四个 token 桶乘单价(`model-prices.cjs`),
20
- * 价格变了下次出数就跟着变,账本里不留一份会过期的钱。模型归属的近似照旧继承下来;查不到单价
21
- * 的那一截 token 由 `unpricedBilled` 明说,不摊进合计。
22
- *
23
- * 只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
10
+ * 每条会话一条记录,范围内有量的都上卡片,**不按展示裁剪**(卡片条数就是页面那个「会话」数字)。
11
+ * 第三张表 `models` 按「渠道 + 模型」攒量,模型名取自采集那一刻这条会话的选择;金额是估算、不落盘,只做纯数据、不碰网络,采集在 stats-collect.cjs。
24
12
  */
25
13
 
26
14
  const fs = require('node:fs');
@@ -120,12 +108,9 @@ function modelKeyOf(provider, model) {
120
108
  /**
121
109
  * 模型选择的清洗:provider / model 缺一个才算没有,展示名缺了留空串。
122
110
  *
123
- * 展示名不算有效性判据:目录里那条模型下架了、渠道被卸载了,都只是「名字对不上」,这条用量
124
- * 该记在哪个渠道哪个模型名下仍然是确定的。以前把「展示名齐不齐」当成归属是否有效的判据,
125
- * 于是这种记录一重启读盘就变回「没有模型」,下一轮又触发一次老账本回填,同一段累计量在
126
- * 模型桶里翻倍。
127
- *
128
- * 展示名按「非空才覆盖」的规矩用,空串不能把已经记下的名字擦掉。
111
+ * 展示名不算有效性判据:目录下架、渠道卸载只是「名字对不上」,这条用量记在哪个渠道哪个模型名下
112
+ * 仍是确定的。别拿「展示名齐不齐」当归属判据 —— 那样记录一重启读盘就变回「没有模型」,下一轮
113
+ * 又触发老账本回填,同一段累计量在模型桶里翻倍。展示名按「非空才覆盖」用,空串不能擦掉已知名。
129
114
  */
130
115
  function sanitizeModelSelection(source) {
131
116
  if (!source || typeof source !== 'object') return null;