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.
package/lib/client.js CHANGED
@@ -508,7 +508,10 @@ window.__ModuleLoader__.load({
508
508
  nav: '移动端',
509
509
  heading: '移动端',
510
510
  cardPanel: '配置页面',
511
+ cardCanvas: '画布',
511
512
  openPanel: '打开配置页面',
513
+ openCanvas: '打开画布',
514
+ canvasHint: '在浏览器里打开无限画布;它的库与手机共用同一份。',
512
515
  openHint: '配对、网络配置与插件更新都在这个页面里。',
513
516
  panelPending: '正在读取页面地址…',
514
517
  panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
@@ -566,7 +569,10 @@ window.__ModuleLoader__.load({
566
569
  nav: 'Mobile',
567
570
  heading: 'Mobile',
568
571
  cardPanel: 'Configuration page',
572
+ cardCanvas: 'Canvas',
569
573
  openPanel: 'Open the configuration page',
574
+ openCanvas: 'Open the canvas',
575
+ canvasHint: 'Open the infinite canvas in your browser; it shares one library with your phone.',
570
576
  openHint: 'Pairing, network settings and plugin updates all live on this page.',
571
577
  panelPending: 'Reading the page address…',
572
578
  panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
@@ -794,6 +800,23 @@ window.__ModuleLoader__.load({
794
800
  }
795
801
  }
796
802
 
803
+ /**
804
+ * 画布页在**这个页面自己**的同源地址上(与面板同一台宿主、同一个前缀)。
805
+ *
806
+ * 桌面用户在 DSH 里点「打开画布」,就是跳到浏览器打开这一页;页面由插件托管,库与手机共用。
807
+ */
808
+ function sameOriginCanvasUrl() {
809
+ try {
810
+ const base = typeof document !== 'undefined' && document.baseURI
811
+ ? document.baseURI
812
+ : (typeof location !== 'undefined' ? location.href : '');
813
+ if (!base) return '';
814
+ return new URL('api/relay/canvas', base).href;
815
+ } catch {
816
+ return '';
817
+ }
818
+ }
819
+
797
820
  /**
798
821
  * 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
799
822
  *
@@ -1557,6 +1580,10 @@ window.__ModuleLoader__.load({
1557
1580
  }
1558
1581
 
1559
1582
  const hint = url === null ? t('panelPending') : url === '' ? t('panelMissing') : t('openHint');
1583
+ // 画布页与面板同源同宿主,地址从面板地址换尾巴;拿不到面板地址就按页面自身推。
1584
+ const canvasUrl = typeof url === 'string' && url
1585
+ ? url.replace(/\/panel(?:\/(?:setup|settings))?$/, '/canvas')
1586
+ : sameOriginCanvasUrl();
1560
1587
  return h('div', { className: 'dshm_root' },
1561
1588
  h('h2', { className: 'dshm_heading' }, t('heading')),
1562
1589
  h('div', { className: 'dshm_card' },
@@ -1574,7 +1601,19 @@ window.__ModuleLoader__.load({
1574
1601
  failure
1575
1602
  ? h('p', { className: 'dshm_error' },
1576
1603
  `${failure.kind === 'load' ? t('loadFailed') : t('openFailed')}:${failure.message}`)
1577
- : null));
1604
+ : null),
1605
+ // 画布单独一张薄卡:点「打开画布」跳到浏览器打开插件托管的画布页(库与手机共用),
1606
+ // 不嵌在桌面端里。跟面板同源同宿主,地址从面板地址换尾巴拿。
1607
+ h('div', { className: 'dshm_card' },
1608
+ h('h3', { className: 'dshm_cardTitle' }, t('cardCanvas')),
1609
+ h('p', { className: 'dshm_note' }, t('canvasHint')),
1610
+ h('div', { className: 'dshm_actions' },
1611
+ h('a', {
1612
+ className: 'dshm_btn dshm_btnPrimary',
1613
+ href: canvasUrl || undefined,
1614
+ target: '_blank',
1615
+ rel: 'noreferrer',
1616
+ }, t('openCanvas')))));
1578
1617
  }
1579
1618
 
1580
1619
  return {
@@ -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
  }
@@ -25,6 +25,10 @@ const path = require('node:path');
25
25
 
26
26
  const { dshHome } = require('../shared/paths.js');
27
27
  const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
28
+ const { imageSizeOf, sniffMediaType } = require('./local-images.js');
29
+
30
+ /** 量宽高只需要文件头;JPEG 的 SOF 段可能在几百字节之后,64KB 覆盖绝大多数实拍图。 */
31
+ const HEAD_BYTES = 64 * 1024;
28
32
 
29
33
  /** 一个会话最多记多少个附件 id(会话里几千张图也不该无限涨)。 */
30
34
  const MAX_IDS_PER_SESSION = 4096;
@@ -36,29 +40,52 @@ const TTL_MS = 30 * 60 * 1000;
36
40
  /** sessionId -> { at, entries: Map<attachmentId, { mediaType, at }> } */
37
41
  const SESSIONS = new Map();
38
42
 
39
- /** 内容哈希是 64 位小写十六进制;别的形状一律不认(顺带挡住路径穿越)。 */
40
- const OBJECT_ID = /^[0-9a-f]{64}$/;
43
+ /**
44
+ * 宿主附件编号的形状:`sha256:<64 位小写十六进制>`(见宿主 `attachment-local` 的 `ID_PATTERN`)。
45
+ *
46
+ * **前缀不能省**:宿主发的编号带 `sha256:`,磁盘上的文件名却是去掉前缀的那段哈希。以前这里
47
+ * 只认裸哈希,于是每个真实编号都被当成「不是我们发过的」,直读这条路一次都没生效过。
48
+ */
49
+ const OBJECT_ID = /^sha256:([0-9a-f]{64})$/;
41
50
 
42
51
  function nowMs() {
43
52
  return Date.now();
44
53
  }
45
54
 
55
+ /** 从附件编号里取出裸哈希(磁盘文件名那段);形状不对回 null。 */
56
+ function hexOf(attachmentId) {
57
+ if (typeof attachmentId !== 'string') return null;
58
+ const match = OBJECT_ID.exec(attachmentId);
59
+ return match ? match[1] : null;
60
+ }
61
+
46
62
  /**
47
63
  * 附件 id → 宿主附件库里的绝对路径。
48
64
  *
49
- * 形状不对(不是 64 位小写十六进制)直接回 null:这条路径要拼进 `createReadStream`,
50
- * 校验放在这里、而不是靠调用方记得先校验。
65
+ * 宿主 `normalizedImagePath` 的落盘形状是 `objects/<前两位>/<完整 64 位哈希>` —— 子目录取前两位、
66
+ * **文件名仍是完整哈希**(不是去掉前两位的那段)。曾经拼成 `objects/<前两位>/<其余>`,于是每一份
67
+ * 真实附件都找不到、直读与现量元数据全部落空(取图恒 404)。
68
+ *
69
+ * 形状不对(不是 `sha256:<64 位小写十六进制>`)直接回 null:这条路径要拼进
70
+ * `createReadStream`,校验放在这里、而不是靠调用方记得先校验。
51
71
  */
52
72
  function objectPathOf(attachmentId) {
53
- if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return null;
73
+ const hex = hexOf(attachmentId);
74
+ if (!hex) return null;
54
75
  const root = path.join(dshHome(), 'attachments', 'v1', 'objects');
55
- return path.join(root, attachmentId.slice(0, 2), attachmentId.slice(2));
76
+ return path.join(root, hex.slice(0, 2), hex);
56
77
  }
57
78
 
58
- /** 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。 */
59
- function remember(sessionId, attachmentId, mediaType) {
79
+ /**
80
+ * 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。
81
+ *
82
+ * 顺手把**完整引用**(媒体类型 / 字节数 / 宽高 / 名字)也记下来。回退去问宿主时,宿主新版
83
+ * 的取附件接口要的是整份 `ImageAttachmentRef`,只有编号它不收。取字节本身宿主只用到编号,
84
+ * 但请求形状这五个字段都必填。
85
+ */
86
+ function remember(sessionId, attachmentId, ref) {
60
87
  if (typeof sessionId !== 'string' || !sessionId) return;
61
- if (typeof attachmentId !== 'string' || !OBJECT_ID.test(attachmentId)) return;
88
+ if (!hexOf(attachmentId)) return;
62
89
  const at = nowMs();
63
90
  let slot = SESSIONS.get(sessionId);
64
91
  if (!slot) {
@@ -72,11 +99,16 @@ function remember(sessionId, attachmentId, mediaType) {
72
99
  }
73
100
  }
74
101
  slot.at = at;
75
- slot.entries.delete(attachmentId);
76
- slot.entries.set(attachmentId, {
102
+ const entry = {
77
103
  at,
78
- mediaType: typeof mediaType === 'string' && mediaType ? mediaType : 'application/octet-stream',
79
- });
104
+ mediaType: ref && typeof ref.mediaType === 'string' && ref.mediaType ? ref.mediaType : 'application/octet-stream',
105
+ };
106
+ if (ref && Number.isFinite(ref.bytes)) entry.bytes = ref.bytes;
107
+ if (ref && Number.isFinite(ref.width)) entry.width = ref.width;
108
+ if (ref && Number.isFinite(ref.height)) entry.height = ref.height;
109
+ if (ref && typeof ref.name === 'string' && ref.name) entry.name = ref.name;
110
+ slot.entries.delete(attachmentId);
111
+ slot.entries.set(attachmentId, entry);
80
112
  while (slot.entries.size > MAX_IDS_PER_SESSION) {
81
113
  const oldest = slot.entries.keys().next().value;
82
114
  if (oldest === undefined) break;
@@ -84,7 +116,9 @@ function remember(sessionId, attachmentId, mediaType) {
84
116
  }
85
117
  }
86
118
 
87
- /** 这个 id 是不是我们为这条会话下发过的;是就带上当时的媒体类型。 */
119
+ /**
120
+ * 这个 id 是不是我们为这条会话下发过的;是就带上当时的完整引用(少 `attachmentId` 与时间戳)。
121
+ */
88
122
  function lookup(sessionId, attachmentId, now = nowMs()) {
89
123
  if (typeof sessionId !== 'string' || !sessionId) return null;
90
124
  if (typeof attachmentId !== 'string' || !attachmentId) return null;
@@ -100,7 +134,8 @@ function lookup(sessionId, attachmentId, now = nowMs()) {
100
134
  slot.entries.delete(attachmentId);
101
135
  return null;
102
136
  }
103
- return { mediaType: hit.mediaType };
137
+ const { at: _at, ...ref } = hit;
138
+ return ref;
104
139
  }
105
140
 
106
141
  /** 打开一条只读流(调用方自己负责在连接关掉时 destroy)。 */
@@ -139,6 +174,66 @@ async function streamIfRemembered(req, res, sessionId, attachmentId) {
139
174
  return true;
140
175
  }
141
176
 
177
+ /**
178
+ * 从盘上这份附件对象量出它的真实元数据(类型 / 字节数 / 宽高)。
179
+ *
180
+ * 宿主取附件时会读同一份对象文件、用 sharp 探一遍,再跟我们递过去的引用**逐字段比对**:
181
+ * 类型、字节数、宽高任一对不上就回 `Stored attachment metadata does not match its reference.`
182
+ * (400)。所以引用不能瞎填占位值,必须和盘上那份字节对上。
183
+ *
184
+ * 附件存的是**归一化后**的字节(携带 EXIF 的图会被重编码、朝向烘进像素),因此文件头读出来的
185
+ * 就是宿主 `probeImage` 会量到的同一组数;`imageSizeOf` 本就按 EXIF 5–8 对调,口径一致。
186
+ * 读不出(不是位图 / 文件不在)回 null,由调用方决定怎么回。
187
+ */
188
+ function probeObject(attachmentId) {
189
+ const target = objectPathOf(attachmentId);
190
+ if (!target) return null;
191
+ let stat;
192
+ try {
193
+ stat = fs.statSync(target);
194
+ } catch {
195
+ return null;
196
+ }
197
+ if (!stat.isFile() || stat.size <= 0) return null;
198
+ let fd = null;
199
+ let head;
200
+ try {
201
+ fd = fs.openSync(target, 'r');
202
+ const length = Math.min(stat.size, HEAD_BYTES);
203
+ head = Buffer.alloc(length);
204
+ fs.readSync(fd, head, 0, length, 0);
205
+ } catch {
206
+ return null;
207
+ } finally {
208
+ if (fd !== null) {
209
+ try { fs.closeSync(fd); } catch { /* 关不上就算了,元数据已经读到 */ }
210
+ }
211
+ }
212
+ const mediaType = sniffMediaType(head);
213
+ const size = imageSizeOf(head);
214
+ if (!mediaType || !size || !(size.width > 0) || !(size.height > 0)) return null;
215
+ return { mediaType, bytes: stat.size, width: size.width, height: size.height };
216
+ }
217
+
218
+ /**
219
+ * 给宿主取附件接口拼一份完整 `ImageAttachmentRef`。
220
+ *
221
+ * 记过账的用记下的整份引用;没记到的(过期 / 超上限 / 插件重启后没重新登记 / 排队条里透传的图)
222
+ * 就从盘上那份对象现量一遍 —— 宿主校验要的就是盘上这份字节的真实元数据,占位值一定对不上。
223
+ * 对象文件不在或量不出时回 null(调用方回 404),不再拿假引用去碰宿主的那条校验。
224
+ */
225
+ function refFor(sessionId, attachmentId) {
226
+ const known = lookup(sessionId, attachmentId);
227
+ if (known && Number.isFinite(known.bytes) && Number.isFinite(known.width) && Number.isFinite(known.height)) {
228
+ return { attachmentId, ...known };
229
+ }
230
+ const probed = probeObject(attachmentId);
231
+ if (probed) {
232
+ return { attachmentId, ...probed, ...(known && typeof known.name === 'string' ? { name: known.name } : {}) };
233
+ }
234
+ return known ? { attachmentId, ...known } : null;
235
+ }
236
+
142
237
  /** 测试用:清空记账。 */
143
238
  function reset() {
144
239
  SESSIONS.clear();
@@ -151,6 +246,7 @@ module.exports = {
151
246
  objectPathOf,
152
247
  remember,
153
248
  lookup,
249
+ refFor,
154
250
  openObject,
155
251
  streamIfRemembered,
156
252
  reset,
@@ -58,12 +58,16 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
58
58
  if (referencesOnly) {
59
59
  // 顺便记一笔:手机接下来就可能拿着这个 id 来取字节。记下来之后那条路可以直接从
60
60
  // 宿主附件库流给它,不必再等宿主把整张图读成 base64(见 attachment-store.js)。
61
- attachmentStore.remember(sessionId, attachmentId, block.attachment.mediaType || block.mediaType);
61
+ // 记的是**整份引用**:回退问宿主时要的正是这个形状,光有 id 不够。
62
+ attachmentStore.remember(sessionId, attachmentId, {
63
+ ...block.attachment,
64
+ mediaType: block.attachment.mediaType || block.mediaType,
65
+ });
62
66
  return block;
63
67
  }
64
68
  let result;
65
69
  try {
66
- result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
70
+ result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachment: block.attachment }));
67
71
  } catch {
68
72
  return dataUnavailableBlock(block);
69
73
  }
@@ -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:
@@ -57,7 +57,13 @@ const ENDPOINTS = {
57
57
  'sessions.create': inv('session', 'create'),
58
58
  'sessions.search': inv('session', 'search'),
59
59
  'sessions.selectModel': inv('session', 'selectModel'),
60
- 'sessions.attachment': inv('session', 'attachment'),
60
+ // 宿主 `SessionAttachmentRequest` 是 `{ sessionId, attachment: ImageAttachmentRef }`,
61
+ // `attachment` 必填且内含 attachmentId / mediaType / bytes / width / height。只传
62
+ // `{ sessionId, attachmentId }` 会在 strict codec 的 parse 那一步被挡回
63
+ // `wire field "request" failed boundary validation`(400),App 把 400 catch 掉后图片就没了。
64
+ 'sessions.attachment': inv('session', 'attachment', (p) => ({
65
+ request: { sessionId: p && p.sessionId, attachment: p && p.attachment },
66
+ })),
61
67
  'sessions.updateQueue': inv('session', 'updateQueue'),
62
68
  // 宿主的 fork 请求体只有 sessionId / atSeq,increaseTitle 在 0.2.0 已删除。
63
69
  'sessions.fork': inv('session', 'fork', (p) => ({ request: pick(p, ['sessionId', 'atSeq']) })),
@@ -467,4 +467,4 @@ function inRanges(index, ranges) {
467
467
  return false;
468
468
  }
469
469
 
470
- module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, clearLocalImages };
470
+ module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };