dsh-mobile-plugin 0.5.18 → 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.
@@ -110,14 +110,23 @@
110
110
  config:
111
111
  - id: tool-subagent-control
112
112
  name: '@deepseek-ai/dsh-tool-subagent-control'
113
+ # 独立的 `list_agents` 入口只在 0.2.1 及更早存在;0.2.2 把它并进了主包,
114
+ # 子路径没了。这一行保留是为了老运行时,启动时宿主会按「这一代认不认这个
115
+ # 子路径」把副本里的这一行整行拿掉(见 lib/host-compat.cjs 的 dropRows)。
113
116
  - id: tool-subagent-list-agents
114
117
  name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
118
+ # 子代理挑模型的开关,两代运行时键名不同:0.2.1 及更早读
119
+ # `modelSelectionSettings`,0.2.2 起改成 `modelSelection`(另加
120
+ # `modelDiscovery` 才让模型查得到可选模型)。两个键都写:各自的 schema 只认
121
+ # 自己那个、另一个原样忽略,所以同一份预设挂到哪一代都对。
115
122
  - id: tool-subagent
116
123
  name: '@deepseek-ai/dsh-tool-subagent'
117
124
  config:
118
125
  provider: spawn
119
126
  toolName: subagent
120
127
  modelSelectionSettings: true
128
+ modelSelection: true
129
+ modelDiscovery: true
121
130
  backgroundMode: continuable
122
131
  - id: tool-subagent-fork
123
132
  name: '@deepseek-ai/dsh-tool-subagent'
package/host.js CHANGED
@@ -20,7 +20,7 @@ import { installProcessGuard } from './lib/host-guard.cjs'
20
20
  import { DIRECTORY_PICKER_ROW, TOOL_ROWS, pinInsertedRows, syncPresetDir } from './lib/host-compat.cjs'
21
21
  import { dropBareNameSelfMounts } from './lib/host-patches.cjs'
22
22
  import {
23
- assertEntriesActivated,
23
+ auditStartupEntries as assertEntriesActivated,
24
24
  boot,
25
25
  evaluatePluginCompatibility,
26
26
  getDshRuntimeVersion,
@@ -29,11 +29,19 @@ import {
29
29
  loadLayeredEnv,
30
30
  loadOverlayPatches,
31
31
  loadProfile,
32
- PROFILE_TEMPLATES,
33
32
  readProfileCompatibility,
34
33
  resolveProfileDir,
35
34
  } from '@deepseek-ai/dsh-app-boot'
36
35
 
36
+ /**
37
+ * 新建 profile 时的初始组合层。
38
+ *
39
+ * 不从 `dsh-app-boot` 取 `PROFILE_TEMPLATES`:0.2.2 把这份清单挪进了
40
+ * `@deepseek-ai/dsh-official-bundles`,旧位置不再导出,而静态导入一个不存在的
41
+ * 命名导出是让整个宿主起不来(不是降级)。这里本来就只要 web 那一档的两行。
42
+ */
43
+ const WEB_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
44
+
37
45
  const BIN_NAME = 'dsh-mobile-plugin'
38
46
  const PROFILE_NAME = process.env.DSH_MOBILE_PROFILE || 'mobile'
39
47
  const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url))
@@ -58,7 +66,7 @@ try {
58
66
  const home = process.env.DSH_HOME
59
67
  const profileDir = resolveProfileDir(PROFILE_NAME, home)
60
68
  // Idempotent: an already-initialized profile keeps its own patch layer.
61
- initProfile(profileDir, PROFILE_TEMPLATES.web)
69
+ initProfile(profileDir, WEB_PROFILE_BUNDLES)
62
70
  const profile = loadProfile(BIN_NAME, PROFILE_NAME, INSTALL_ANCHOR, home, { userLayer: true })
63
71
 
64
72
  /**
@@ -142,6 +150,19 @@ try {
142
150
  )
143
151
  }
144
152
 
153
+ // 预设里「列子代理」那一行挂的是 `dsh-tool-subagent-control` 的一个子入口。
154
+ // 0.2.2 把那个工具并进了主包,子路径没有了 —— 留着它,每建一个会话都会有一行
155
+ // 起不来,而能力本身还在。判据是**这一代运行时认不认这个子路径**,不按版本号猜。
156
+ const listAgentsEntry = '@deepseek-ai/dsh-tool-subagent-control/list-agents'
157
+ let listAgentsRowGone = false
158
+ try {
159
+ runtimeRequire?.resolve(listAgentsEntry)
160
+ } catch {
161
+ listAgentsRowGone = true
162
+ process.stderr.write(`${BIN_NAME}: 这一代运行时没有 ${listAgentsEntry}(该工具已并在主包里),预设副本里那一行不再挂\n`)
163
+ }
164
+ const dropPresetIds = listAgentsRowGone ? ['tool-subagent-list-agents'] : []
165
+
145
166
  // 预设里的两行默认按裸名写:Loader 从它自己的位置解析,命中哪一份不由我们决定。
146
167
  // 解析到了同代的那份就改写一份副本给它,路径写死成那一份的入口。
147
168
  const presetRoots = {}
@@ -149,11 +170,12 @@ try {
149
170
  const hit = pinnedRows.get(id)
150
171
  if (hit) presetRoots[id] = hit.entry
151
172
  }
152
- const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined)
173
+ const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined) || dropPresetIds.length > 0
153
174
  ? syncPresetDir(
154
175
  join(PACKAGE_DIR, 'config', 'agent-presets'),
155
176
  join(profileDir, '.mobile-agent-presets'),
156
177
  presetRoots,
178
+ { dropIds: dropPresetIds },
157
179
  )
158
180
  : join(PACKAGE_DIR, 'config', 'agent-presets')
159
181
 
@@ -8,6 +8,9 @@
8
8
  * 所以启动时先问运行时那棵树要同代的那一份,拿到就把行的 `name` 换成它的入口绝对路径,
9
9
  * 绕开按裸名解析;拿不到就保持原样,让官方检查去禁用并打印原因。
10
10
  *
11
+ * 还有另一种形状:入口本身没了(0.2.2 把 `list_agents` 并进主包,子路径随之下线)。
12
+ * 那一条换成哪一份都没用,只能整行从预设副本里拿掉 —— 见 `dropRows`。
13
+ *
11
14
  * 这里只放纯函数:`host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
12
15
  */
13
16
 
@@ -76,21 +79,64 @@ function rewriteToolNames(source, targets) {
76
79
  * @param {string} presetDir 包内 `config/agent-presets`
77
80
  * @param {string} outDir 宿主可写的目标目录
78
81
  * @param {Record<string, string>} targets 行 id → 入口绝对路径
82
+ * @param {{ dropIds?: string[] }} [options] 这一代运行时里不再有独立入口的行
79
83
  * @returns {string} 真正该交给 `agent-presets` roots 的目录
80
84
  */
81
- function syncPresetDir(presetDir, outDir, targets) {
85
+ function syncPresetDir(presetDir, outDir, targets, options = {}) {
82
86
  fs.rmSync(outDir, { recursive: true, force: true })
83
87
  fs.cpSync(presetDir, outDir, { recursive: true })
84
- if (Object.keys(targets).length === 0) return outDir
88
+ const dropIds = options.dropIds ?? []
89
+ if (Object.keys(targets).length === 0 && dropIds.length === 0) return outDir
85
90
  for (const entry of fs.readdirSync(outDir, { recursive: true, withFileTypes: true })) {
86
91
  if (!entry.isFile() || !entry.name.endsWith('.yml')) continue
87
92
  const file = path.join(entry.parentPath ?? entry.path, entry.name)
88
- const { text, changed } = rewriteToolNames(fs.readFileSync(file, 'utf8'), targets)
89
- if (changed.length > 0) fs.writeFileSync(file, text)
93
+ const original = fs.readFileSync(file, 'utf8')
94
+ const trimmed = dropRows(original, dropIds)
95
+ const { text, changed } = rewriteToolNames(trimmed, targets)
96
+ if (changed.length > 0 || trimmed !== original) fs.writeFileSync(file, text)
90
97
  }
91
98
  return outDir
92
99
  }
93
100
 
101
+ /**
102
+ * 去掉预设里指定的整行(含它下面缩进更深的配置)。
103
+ *
104
+ * 给「这一代运行时里已经没有这个独立入口」的行用:留着它,Loader 会按裸名去 import
105
+ * 一个不存在的子路径,那一行起不来;能力本身可能已经并进主包,所以不是缺工具,
106
+ * 只是多了一条注定失败的行。
107
+ *
108
+ * @param {string} source 预设文件内容
109
+ * @param {string[]} ids 要整行删掉的入口 id
110
+ * @returns {string} 删完的文本(没命中就原样返回)
111
+ */
112
+ function dropRows(source, ids) {
113
+ if (ids.length === 0) return source
114
+ const targets = new Set(ids)
115
+ const lines = source.split('\n')
116
+ const indentOf = (line) => line.length - line.trimStart().length
117
+ const kept = []
118
+ let dropping = false
119
+ let dropIndent = 0
120
+ for (const line of lines) {
121
+ const id = ROW_ID.exec(line)
122
+ if (dropping) {
123
+ // 同级的下一行或更浅的一行就是这条的边界;更深的仍是它的配置。
124
+ if (id || (line.trim() !== '' && indentOf(line) <= dropIndent)) dropping = false
125
+ }
126
+ if (id && targets.has(id[2])) {
127
+ dropIndent = indentOf(line)
128
+ // 紧挨着这一行上方的注释块是在说它,跟着一起删;留下会说反话的说明。
129
+ while (kept.length > 0 && kept[kept.length - 1].trim().startsWith('#') && indentOf(kept[kept.length - 1]) >= dropIndent) {
130
+ kept.pop()
131
+ }
132
+ dropping = true
133
+ continue
134
+ }
135
+ if (!dropping) kept.push(line)
136
+ }
137
+ return kept.join('\n')
138
+ }
139
+
94
140
  /**
95
141
  * 把 patch 层里命中目标的 `name` 换成入口绝对路径。
96
142
  *
@@ -115,6 +161,7 @@ module.exports = {
115
161
  TOOL_ROWS,
116
162
  DIRECTORY_PICKER_ROW,
117
163
  rewriteToolNames,
164
+ dropRows,
118
165
  syncPresetDir,
119
166
  pinInsertedRows,
120
167
  }
@@ -3,27 +3,10 @@
3
3
  /**
4
4
  * 「这个 CLI 该按哪一套配置跑」—— 一台电脑每个 CLI 记一份。
5
5
  *
6
- * 为什么要有它(用户 2026-11-04:「每次对话都要先去改权限再去发对话,习惯根本调不过来」):
7
- * ACP 的配置是**会话级**的 —— `session/new` 之后才有 `configOptions`,`set_config_option` 只对
8
- * 那一条会话生效。于是每开一条新对话都回到 CLI 自己的默认档:权限松到「完全放开」、或者紧到
9
- * 「每动一下都弹审批」,两种都得人再调一次。而调不了的时候最难受:AI 正在跑、审批卡挂着,
10
- * 这一轮没结束就改不动配置 —— 用户说的「得等这一轮结束才能去调权限」正是这个。
11
- *
12
- * 所以这里做的是「**记住,然后开局就套上**」:用户在配置页里定的那一套(权限档 / 模型 /
13
- * 思考档位 / 其它会话级下拉)存在这台电脑上,按 `agentId` 记;新建会话握手完、第一次发消息之前
14
- * 逐项套过去。ACP 没有「一次设置管所有会话」的开关,但对用户来说效果就是全局的。
15
- *
16
- * 三条口径:
17
- *
18
- * 1. **只记用户显式选过的项**(`values`)。没选过的不记,新会话就让 CLI 用它自己的默认 ——
19
- * 替用户猜一个档位,等于悄悄改了它的行为。
20
- * 2. **选项表也记一份**(`options`)。配置页要在**没开会话**的时候就能画出「权限档有哪几个」,
21
- * 而那是 CLI 握手之后才给的东西;不缓存就只能等开一条对话才看得见。缓存只用于显示,
22
- * 真正写的时候仍然按新会话自己给的那张表校验(CLI 升级换了档位名这类情况就不会写错)。
23
- * 3. **写不进去不算错**。新会话里那一项不存在(换了名字 / 这一版没这个选项)就跳过它,
24
- * 停在 CLI 自己的默认上,别让一条过期的偏好把整条会话建不起来。
25
- *
26
- * 文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
6
+ * ACP 的配置是**会话级**的:`session/new` 之后才有 `configOptions`,`set_config_option` 只对那一条
7
+ * 会话生效,每开一条新对话都回到 CLI 默认档。所以这里把用户定的那套按 `agentId` 存下来,新会话
8
+ * 握手完、第一次发消息之前逐项套过去。只记用户显式选过的项、没选过的不猜;选项表缓存一份供没开
9
+ * 会话时画,但写入仍按新会话校验;写不进去不算错。文件:`$DSH_HOME/storages/mobile-plugin/cli-config.json`。
27
10
  */
28
11
 
29
12
  const fs = require('node:fs');
@@ -488,6 +488,24 @@ function createCliMobile(deps) {
488
488
  const states = new WeakMap()
489
489
  /** rpcId → { session, requestId }:`/approval/respond` 靠它找回 ACP 的挂起项。 */
490
490
  const approvals = new Map()
491
+ /**
492
+ * 投递去重账:`${sessionId}:${clientMessageId}` → 收下的时刻。
493
+ *
494
+ * CLI 这条发送没有宿主那本日志,`/session/delivery` 早先恒回 `delivered:false` —— 于是 App
495
+ * 在一发超时后会认为「没送到」再发一次;若这一轮刚好结束,就会真的把同一句喂给模型两遍。
496
+ * 这里记一笔:`/session/send` 见到同一个投递 id 直接按成功收(幂等),`/session/delivery`
497
+ * 照实回「早就收下了」。只留最近若干条,旧的自然淘汰。
498
+ */
499
+ const deliveries = new Map()
500
+ const DELIVERIES_MAX = 300
501
+ function rememberDelivery(key) {
502
+ deliveries.set(key, Date.now())
503
+ while (deliveries.size > DELIVERIES_MAX) {
504
+ const oldest = deliveries.keys().next().value
505
+ if (oldest === undefined) break
506
+ deliveries.delete(oldest)
507
+ }
508
+ }
491
509
 
492
510
  function stateOf(session) {
493
511
  let state = states.get(session)
@@ -556,8 +574,10 @@ function createCliMobile(deps) {
556
574
  cwd: session.cwd,
557
575
  // 侧栏按 workspaceId 分组:给不准就会把 CLI 会话扔进「未分组」。
558
576
  workspaceId: session.workspaceId,
559
- // 对话页「⋯ → 终端」靠这两格:哪个 CLI、在哪个目录。
577
+ // 对话页「⋯ → 终端」靠这几格:哪个 CLI、展示名、在哪个目录。
560
578
  cliAgent: session.agentId,
579
+ // 展示名(`Codex CLI` 之类):终端页 / 模型管理页标题用它,别把 id 直接摆给用户看。
580
+ ...(session.label ? { cliAgentLabel: session.label } : {}),
561
581
  updatedAt: session.updatedAt || 0,
562
582
  // 置顶与宿主同一个字段:App 的行尾图钉读它(排序见 mergeList)。
563
583
  ...(state.pinned ? { pinned: true } : {}),
@@ -1005,9 +1025,13 @@ function createCliMobile(deps) {
1005
1025
  case '/session/goal':
1006
1026
  sendJsonQuiet(res, {})
1007
1027
  return
1008
- case '/session/delivery':
1009
- sendJsonQuiet(res, { delivered: false })
1028
+ case '/session/delivery': {
1029
+ // App 的离线发件箱超时后回查这一发到底收下没有(见 useOutboxFlush):照实回答,
1030
+ // 免得它再发一遍。没有 clientMessageId 的老客户端只能回「不知道」。
1031
+ const cid = textOf(queryOf(req).get('clientMessageId'))
1032
+ sendJsonQuiet(res, { delivered: Boolean(cid && deliveries.has(`${session.id}:${cid}`)) })
1010
1033
  return
1034
+ }
1011
1035
  case '/session/rename': {
1012
1036
  const title = textOf(body && body.title).trim()
1013
1037
  if (title) setTitle(session, title)
@@ -1025,6 +1049,13 @@ function createCliMobile(deps) {
1025
1049
  case '/session/send': {
1026
1050
  const content = Array.isArray(body && body.content) ? body.content : []
1027
1051
  const message = textOf(body && body.text) || textOfContent(content)
1052
+ // 同一个投递 id 再来一次就是重发:早就收下了,直接按成功回(幂等),别再喂模型一遍。
1053
+ const clientMessageId = textOf(body && body.clientMessageId)
1054
+ const deliveryKey = clientMessageId ? `${session.id}:${clientMessageId}` : ''
1055
+ if (deliveryKey && deliveries.has(deliveryKey)) {
1056
+ sendJson(res, 200, { ok: true, accepted: true })
1057
+ return
1058
+ }
1028
1059
  // 这一发带的图:先认下字节(手机发上来时已经落在收件箱里),块表里只记元数据。
1029
1060
  const sent = await saveImages(content)
1030
1061
  // 图的毛病先说清楚(上传过期 / 读不出来),比「这一发没有正文」有用得多。
@@ -1054,6 +1085,7 @@ function createCliMobile(deps) {
1054
1085
  }
1055
1086
  const result = runPrompt(session, message, sent.images)
1056
1087
  if (!result.ok) return sendJson(res, 409, { ok: false, error: { code: 'cli-busy', message: result.error || '这一轮还没结束' } })
1088
+ if (deliveryKey) rememberDelivery(deliveryKey)
1057
1089
  sendJson(res, 200, { ok: true, accepted: true })
1058
1090
  return
1059
1091
  }
@@ -1063,7 +1095,7 @@ function createCliMobile(deps) {
1063
1095
  case '/subagents':
1064
1096
  sendJsonQuiet(res, { entries: [], parentAvailable: true })
1065
1097
  return
1066
- case '/extension-cards':
1098
+ case '/extension/cards':
1067
1099
  sendJsonQuiet(res, { cards: [] })
1068
1100
  return
1069
1101
  default:
@@ -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
  },
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.18",
3
+ "version": "0.5.19",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",