dsh-mobile-plugin 0.5.21 → 0.5.23

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 (41) hide show
  1. package/README.md +3 -12
  2. package/assets/pets/README.md +9 -0
  3. package/assets/pets/deepy-whale/attention.gif +0 -0
  4. package/assets/pets/deepy-whale/done.gif +0 -0
  5. package/assets/pets/deepy-whale/error.gif +0 -0
  6. package/assets/pets/deepy-whale/idle.gif +0 -0
  7. package/assets/pets/deepy-whale/pack.json +54 -0
  8. package/assets/pets/deepy-whale/sleeping.gif +0 -0
  9. package/assets/pets/deepy-whale/working-3.gif +0 -0
  10. package/assets/pets/deepy-whale/working.gif +0 -0
  11. package/extensions/README.md +64 -0
  12. package/extensions/catalog.json +1 -0
  13. package/lib/impl.js +21 -1
  14. package/lib/mobile/approvals.js +22 -2
  15. package/lib/mobile/cli/acp.js +34 -20
  16. package/lib/mobile/cli/mobile.js +8 -1
  17. package/lib/mobile/cli/service.js +151 -10
  18. package/lib/mobile/extension-catalog.js +244 -0
  19. package/lib/mobile/live-steps.js +35 -3
  20. package/lib/mobile/local-images.js +166 -87
  21. package/lib/mobile/pet-tools.js +215 -0
  22. package/lib/mobile/pets.js +559 -0
  23. package/lib/mobile/session-local-image.js +9 -1
  24. package/lib/mobile/stats-collect.cjs +15 -7
  25. package/lib/mobile/stats-ledger.cjs +44 -5
  26. package/lib/mobile/stream.js +8 -5
  27. package/lib/mobile/wechat-tools.js +257 -0
  28. package/lib/mobile/wechat.js +489 -0
  29. package/lib/panel/index.html +10 -10
  30. package/lib/relay/pairing-payload.cjs +13 -5
  31. package/lib/relay/snapshot-auth.js +82 -0
  32. package/lib/relay/status.js +3 -0
  33. package/lib/routes/control-plane.js +65 -3
  34. package/lib/routes/mobile-api.js +54 -1
  35. package/lib/sidecar-facade-worker.cjs +8 -0
  36. package/lib/tailcat/host-process.cjs +270 -0
  37. package/lib/tailcat/ingress.cjs +98 -0
  38. package/lib/tailcat/session.cjs +264 -0
  39. package/lib/tailcat/wiring.cjs +96 -0
  40. package/lib/update-install.cjs +43 -0
  41. package/package.json +10 -1
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  <h3 align="center">AI-HUB 移动端插件</h3>
2
2
 
3
- <p align="center">
4
- <img src="assets/banner.jpg" alt="AI-HUB:手机和平板上的 DSH 会话界面" width="760">
5
- </p>
3
+ ![AI-HUB:手机和平板上的 DSH 会话界面](https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/banner.jpg)
6
4
 
7
5
  <p align="center">
8
6
  <a href="https://app.wnluo.com/public/apps/14"><img alt="安卓 分发下载" src="https://img.shields.io/badge/%E5%AE%89%E5%8D%93-%E5%88%86%E5%8F%91%E4%B8%8B%E8%BD%BD-3DDC84?style=for-the-badge&logo=android&logoColor=white"></a>
@@ -22,12 +20,7 @@
22
20
 
23
21
  **AI-HUB 的手机端插件。** 装在电脑这边,手机 App 就通过一条中转隧道或局域网配对连进你的 DSH —— 看会话、接着聊、管理文件和工作区,进度实时同步。它也可以脱离桌面外壳单独跑在服务器上,当作无头宿主用。
24
22
 
25
- <p align="center">
26
- <picture>
27
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-dark.svg">
28
- <img src="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-light.svg" alt="手机 App 经中转或局域网连到你电脑上的 DSH" width="720">
29
- </picture>
30
- </p>
23
+ ![手机 App 经中转或局域网连到你电脑上的 DSH](https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/connection-light.svg)
31
24
 
32
25
  手机连的是**你自己那台电脑上的 DSH**,不是某个云端服务。电脑不用公网 IP,也不用在路由器上开端口:插件从内网主动往外建一条隧道,手机沿这条路回来。
33
26
 
@@ -131,9 +124,7 @@ DSH 跑在电脑上,但我不是一直坐在电脑前。出门、在别的房
131
124
 
132
125
  ## 交流与反馈
133
126
 
134
- <p align="center">
135
- <img src="assets/wechat-group.png" alt="企业微信群二维码" width="180">
136
- </p>
127
+ ![企业微信群二维码](https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/wechat-group.png)
137
128
 
138
129
  扫上面的码进企业微信群。不方便的话发邮件到 <admin@wnluo.com> 或 <wnluo2@gmail.com>。用着顺手就给仓库点个 star,这是最实在的支持。
139
130
 
@@ -0,0 +1,9 @@
1
+ # 内置宠物包
2
+
3
+ 这个目录放**随插件发版走**的默认宠物(见 [`docs/pet-overlay.md`](../../../docs/pet-overlay.md) §1)。
4
+
5
+ - 每个包一个子目录,目录名就是包 id(`[a-z0-9][a-z0-9-]{0,63}`),里面一份 `pack.json`
6
+ 加它引用的 `.gif` / `.png`。
7
+ - 这里的包**只读**:手机上删不掉,用户想要自己的一份,就用同一个 id 在
8
+ `$DSH_HOME/storages/mobile-plugin/pets/<id>/` 写一份 —— 用户那份会盖住这里的。
9
+ - 目录可以不存在、也可以是空的:宿主那边「少几个包」而已,不会报错,用户自己的包照常列。
Binary file
Binary file
@@ -0,0 +1,54 @@
1
+ {
2
+ "schema": 1,
3
+ "id": "deepy-whale",
4
+ "name": "Deepy 小鲸鱼",
5
+ "author": "DSH Mobile",
6
+ "license": "随本项目发布,可自由修改",
7
+ "canvas": {
8
+ "w": 52,
9
+ "h": 52
10
+ },
11
+ "anchor": {
12
+ "x": 28,
13
+ "baseline": 48
14
+ },
15
+ "states": {
16
+ "idle": {
17
+ "file": "idle.gif"
18
+ },
19
+ "working": {
20
+ "file": "working.gif"
21
+ },
22
+ "attention": {
23
+ "file": "attention.gif"
24
+ },
25
+ "done": {
26
+ "file": "done.gif"
27
+ },
28
+ "error": {
29
+ "file": "error.gif"
30
+ },
31
+ "sleeping": {
32
+ "file": "sleeping.gif"
33
+ }
34
+ },
35
+ "tiers": {
36
+ "working": [
37
+ {
38
+ "min": 3,
39
+ "file": "working-3.gif"
40
+ },
41
+ {
42
+ "min": 1,
43
+ "file": "working.gif"
44
+ }
45
+ ]
46
+ },
47
+ "loop": [
48
+ "idle",
49
+ "working",
50
+ "attention",
51
+ "sleeping"
52
+ ],
53
+ "sleepAfterMs": 60000
54
+ }
@@ -0,0 +1,64 @@
1
+ # 已审核的第三方扩展
2
+
3
+ 这个目录随 `dsh-mobile-plugin` 的 npm 包一起发布。手机上「随插件发布」那一档扩展从这里读:
4
+ 宿主只认 `catalog.json` 里登记的 slug,目录里有但没登记的一律不下发。
5
+
6
+ ## 提交流程
7
+
8
+ 1. 在 `extensions/<slug>/` 放扩展包:`manifest.json` 与 `index.html` 等,形状与 App 侧现有
9
+ 扩展包一致(自包含、相对路径引用,不许外链)。目录名就是 slug,只认字母、数字、点、
10
+ 下划线、横线,最长 64 个字符。**入口页固定叫 `index.html`**:手机打开扩展找的就是包目录
11
+ 下这一层,`manifest.json` 里写 `main` 指到别的文件不生效。缺 `index.html` 的包宿主列清单
12
+ 时会直接跳过,别等装到手机上才发现打不开。
13
+ 2. 在 `extensions/catalog.json` 的 `packages` 里加一条,填 `slug`(必须与目录名一致)与
14
+ `description` / `author` / `releaseNotes`。
15
+ 3. 提 PR 前在本地跑一遍自验:
16
+
17
+ ```sh
18
+ cd dsh-mobile-plugin
19
+ node --test test/extension-catalog.test.cjs # 宿主列包、入口页、取文件的判据
20
+ npm pack --dry-run # 看扩展目录是否真进了 npm 包
21
+ ```
22
+
23
+ 想按发版那套全量口径验(含「登记的扩展都带入口页」),跑 `node scripts/preflight-release.mjs`;
24
+ 空清单时这几条都是绿的,第一个包进来才真正验得到。
25
+ 4. 提 PR,一次只放一个包。合并即随下一次插件发版上架;从 `packages` 里删掉那一条即下架。
26
+
27
+ 这个目录是随包发布的只读内容,宿主不会创建它,也不会写它。
28
+
29
+ ## 审核口径
30
+
31
+ 登记即视为已审核,所以审的是包本身,不是清单里的那几行字:
32
+
33
+ - 入口页第一屏能不能打开,页面有没有模仿系统界面骗用户填东西;
34
+ - 包里会不会往外发请求。页面从 `file://` 加载,CSP 挡外链,资源必须都在包里;
35
+ - `manifest.json` 声明的能力与实际行为对不对得上;
36
+ - 右栏入口的图标与名字像不像 App 自带的功能(叫「设置」「账户」这类直接打回);
37
+ - 体积:整包不超过 20 MB、不超过 200 个文件,单文件不超过 8 MB。缺 `manifest.json`、
38
+ 缺 `index.html`、或体积超了,宿主都会静默跳过,包根本不会出现在手机上。
39
+
40
+ ## 字段含义
41
+
42
+ `catalog.json` 的形状是 `{"packages": [{"slug": "...", "description": "...", "author": "...", "releaseNotes": "..."}]}`。
43
+
44
+ - `slug`(必填):包目录名,也是手机侧的身份。重复只认第一条,不合规的连读都不读。
45
+ - `description` / `author` / `releaseNotes`(可选):只收非空字符串,缺了或不是字符串一律
46
+ 回 `null`。`author` 写显示名加一个能联系上的方式,下架时找得到人。
47
+ - **不要在这里写 `name`、`version`、`size`、`sha256`、`files`、`capabilities`**:文件清单与哈希
48
+ 由宿主扫目录现算,名字、版本、能力从包自己的 `manifest.json` 读,填了也不认。
49
+
50
+ 宿主下发给手机的一条 Package 还带这些从包里读出来的字段:`name`、`versionName`、
51
+ `apiVersion`、`capabilities`(从 `manifest.json` 收窄成字符串数组)、`size`、`fileCount`、
52
+ `files`(每项 `path` / `size` / `sha256`)。
53
+
54
+ ## 取文件
55
+
56
+ `GET /api/mobile/v1/extension/catalog/file?slug=&path=` 按清单里的文件逐个字节下发
57
+ (base64 + sha256)。`path` 只能是包目录内的相对路径,绝对路径、`..`、resolve 之后越出包目录
58
+ 的一律拒绝。没登记进 `catalog.json` 的 slug 取不到任何文件。
59
+
60
+ ## 与本地开发包的区别
61
+
62
+ 作者边写边看用 `$DSH_HOME/extension-dev`(作者电脑上的目录,走 `/extension/dev/*`),那条路
63
+ 绕过审核,只能手动导入、界面标「本地」。这里是随插件 npm 包发布的正式来源,要上架就得走
64
+ 上面的 PR 与审核。
@@ -0,0 +1 @@
1
+ {"packages": []}
package/lib/impl.js CHANGED
@@ -30,8 +30,9 @@ const { createMirrorPusher } = require('./mirror/pusher.cjs')
30
30
  const { randomBytes } = require('node:crypto')
31
31
  const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManualIpv6Candidate } = require('./relay/lan-network.cjs')
32
32
  // 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
33
- const { pairingPayload } = require('./relay/pairing-payload.cjs')
33
+ const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
34
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
35
+ const { mountTailcat } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
35
36
  const { createPreviewService } = require('./relay/preview.js')
36
37
  const { mountTerminal, mountCli } = require('./mobile/services.js')
37
38
  // 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
@@ -53,6 +54,9 @@ const statsScheduler = require('./mobile/stats-scheduler.cjs')
53
54
  // 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
54
55
  const { installExtensions } = require('./mobile/extensions.js')
55
56
  const { mountPhoneControl } = require('./mobile/phone/index.js') // 「让 AI 操作手机」,见 mobile/phone/
57
+ // 公众号文章库:给 AI 的两个工具(写 / 读),见 mobile/wechat-tools.js。
58
+ const { installWechatTools } = require('./mobile/wechat-tools.js')
59
+ const { installPetTools } = require('./mobile/pet-tools.js') // 悬浮宠物的两个工具(看 / 写),见 mobile/pet-tools.js。
56
60
  const { activeGuard } = require('./host-guard.cjs')
57
61
  // 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
58
62
  // 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
@@ -299,6 +303,8 @@ function apply(ctx) {
299
303
  let lanPortProbe = null
300
304
  const lanPortInUse = () => (state.lanEnabled && effectiveLanPort > 0 ? effectiveLanPort : state.lanPort)
301
305
  const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
306
+ // 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
307
+ const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, lanPortInUse, hostName: () => state.name, ctx, name })
302
308
  // 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
303
309
  // lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
304
310
  // 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
@@ -362,6 +368,14 @@ function apply(ctx) {
362
368
  }
363
369
  return undefined
364
370
  }, `${name}: extension tool`)
371
+ ctx.effect(() => {
372
+ // 缺 tools 注入的 profile 只少这两个工具;文章库本身与它的路由照常可用。
373
+ try { installWechatTools(ctx) } catch (error) {
374
+ if (!String(error).includes('without inject')) throw error
375
+ }
376
+ return undefined
377
+ }, `${name}: wechat article tools`)
378
+ mount('宠物工具', () => installPetTools(ctx)) // 缺 tools 注入的 profile 只少这两个,包目录与路由照常可用。
365
379
  mount('统计采集', () => ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`))
366
380
  mount('过程播报', () => ctx.effect(() => installNarration(ctx), `${name}: process narration`))
367
381
 
@@ -376,6 +390,8 @@ function apply(ctx) {
376
390
  getSidecarRestartedAt: sidecarLifecycle.getRestartedAt,
377
391
  getSidecarRestartError: sidecarLifecycle.getRestartError,
378
392
  getMirrorPush: mirror.getMirrorPush,
393
+ // 直连隧道那一格:手机轮询 `/status` 时据此自己把通道加进来。
394
+ getTailcat: () => (tailcat ? tailcat.status() : null),
379
395
  })
380
396
  function applyServices() {
381
397
  void syncSidecar().catch(() => undefined)
@@ -420,6 +436,8 @@ function apply(ctx) {
420
436
  lanPortInUse: advertisedLanPort, // 二维码里的端口也必须是实绑值
421
437
  getLanPortAdjusted: () => lanPortAdjusted,
422
438
  pairingPayload,
439
+ // 直连隧道:二维码要多带一段「去这个一次性节点换正式地址」,状态接口要多一行。
440
+ tailcat,
423
441
  restartSidecar,
424
442
  // 「手机上最近来过没有」——控制面状态里补给面板用(见 ./mobile/activity.cjs)。
425
443
  mobileActivity: activity.snapshot,
@@ -487,6 +505,8 @@ function apply(ctx) {
487
505
  // 核对的「路径 → handler」表,不用在 1000 行装配代码里翻。
488
506
  mount('移动端路由', () => registerMobileRoutes({
489
507
  registerRoute,
508
+ // 直连隧道:已配对的手机走这条拿到自己的入口地址,不用重新扫码。
509
+ tailcat,
490
510
  // 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
491
511
  // 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
492
512
  registerUpgradeRoute: (route) => mountRoute(route, true),
@@ -18,6 +18,7 @@
18
18
 
19
19
  const { randomUUID } = require('node:crypto');
20
20
  const { sendJson, readJsonBody } = require('../shared/http-util');
21
+ const { cancelForwardedRequests } = require('./host-bridge/transport.js');
21
22
 
22
23
  /** `/approval/respond` 只认这两种结论(与 App 的两个按钮一一对应)。 */
23
24
  const ANSWER_OUTCOMES = new Set(['allowed-once', 'rejected']);
@@ -234,6 +235,8 @@ function noteDecided(sessionId, data) {
234
235
  */
235
236
  function bindRequest(entry, request) {
236
237
  entry.bound = true;
238
+ // 留一个身份引用:手机答完之后要按它找回宿主转发给桌面窗口的那一发(见 cancelForwardedCards)。
239
+ entry.requestRef = request;
237
240
  entry.toolName = entry.toolName || (typeof request.toolName === 'string' ? request.toolName : undefined);
238
241
  entry.callId = entry.callId || (typeof request.callId === 'string' ? request.callId : undefined);
239
242
  const nicer = displayReasonOf(request) || (typeof request.reason === 'string' ? request.reason : undefined);
@@ -261,8 +264,10 @@ function bindRequest(entry, request) {
261
264
  * 输的那张卡由结算帧收掉;两边都没人答就一直挂着。
262
265
  *
263
266
  * 不传 `next`(手机正在看这个会话)就是手机独家作答:不往下游转发,桌面从源头就不弹卡。
264
- * 桌面那张卡挂在被转发的请求上,本桥没有替它收尾的口子,转发出去手机先答,它只会永远
265
- * 悬着。独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
267
+ * 独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
268
+ *
269
+ * 唯一兜不住的边角是「审批发出时手机没在看、卡已转发到桌面,之后手机才连上来」:那一发
270
+ * 已经转发出去,手机后来答时靠 `cancelForwardedCards` 把它撤掉(见下)。
266
271
  */
267
272
  function holdForPhone(entry, next) {
268
273
  const downstream = next === undefined
@@ -275,6 +280,20 @@ function holdForPhone(entry, next) {
275
280
  return Promise.race([entry.wait.then((result) => result.outcome), downstream]);
276
281
  }
277
282
 
283
+ /**
284
+ * 收掉桌面那张审批卡:把宿主已经发给桌面窗口、还挂着的那一发转发撤掉。
285
+ *
286
+ * 手机在看这条会话时审批桥本就不转发,桌面没有卡可收;会走到这里的是那条边角 —— 审批
287
+ * 发出时手机没在看、卡已转发到桌面,之后手机才连上来答。那一发只有桌面自己答 / 本轮取消
288
+ * / 宿主撤回三条路会消失,本桥替它补上最后一条(与提问桥 2026-10-31 同一手法:把钩子收到
289
+ * 的请求对象交给传输层,按它认回网关 `pendingRemoteEvents` 里那一发并撤回)。认不到(老
290
+ * 宿主没这道网关)就什么都不做 —— 「桌面没卡可收」本来就是对的。
291
+ */
292
+ function cancelForwardedCards(ctx, entry) {
293
+ if (!entry.requestRef) return;
294
+ cancelForwardedRequests(ctx, entry.requestRef, new Error('手机已经回答了这条审批'));
295
+ }
296
+
278
297
  function onApprovalRequest(request, next) {
279
298
  const sessionId = sessionIdOfRequest(request);
280
299
  if (!sessionId) return next();
@@ -333,6 +352,7 @@ async function handleApprovalRespond(ctx, req, res) {
333
352
  if (entry && !entry.settled && entry.bound
334
353
  && entry.sessionId === body.sessionId && entry.approvalId === body.approvalId) {
335
354
  settle(entry, body.outcome);
355
+ cancelForwardedCards(ctx, entry);
336
356
  return sendJson(res, 200, { ok: true, accepted: true });
337
357
  }
338
358
  const receipt = await respondViaLegacyProxy(ctx, {
@@ -114,32 +114,46 @@ async function startAgent({ spec, cwd, env, onUpdate, onPermission, onStderr, on
114
114
  });
115
115
 
116
116
  let promptCapabilities = null;
117
- const session = await Promise.race([
118
- (async () => {
119
- const initialized = await conn.agent.request(sdk.methods.agent.initialize, {
120
- protocolVersion: sdk.PROTOCOL_VERSION,
121
- // 不认领文件系统与终端能力:四家 CLI 都有自己的文件访问,我们替它读写反而要多做
122
- // 一套实现,做漏了就是一条静默失败的路。
123
- clientCapabilities: { terminal: false },
124
- clientInfo: CLIENT_INFO,
125
- });
126
- // 这家认不认图片输入:握手时它自己说。不说就按「不支持」处理 —— 硬塞 image 块过去,
127
- // 有的适配器会直接回一条参数错误,把整轮提问连正文一起废掉。
128
- promptCapabilities = (initialized && initialized.agentCapabilities && initialized.agentCapabilities.promptCapabilities) || null;
129
- const created = await conn.agent.request(sdk.methods.agent.session.new, {
130
- cwd,
131
- mcpServers: [],
132
- });
133
- return { sessionId: created.sessionId, created };
134
- })(),
135
- deadline,
136
- ]);
137
117
 
118
+ /**
119
+ * 收掉这条连接与它的子进程。
120
+ *
121
+ * 必须定义在握手**之前**:握手失败或超时时这里直接抛错,而那时子进程已经起来了 —— 不在这
122
+ * 条路径上关掉,它就成了一个没人管的孤儿进程(卡在登录界面、还在等输入)。探配置那条路会
123
+ * 反复进来,漏一次就是攒一个进程。
124
+ */
138
125
  function kill() {
139
126
  try { conn.close(); } catch { /* 已经关了 */ }
140
127
  try { child.kill('SIGTERM'); } catch { /* 已经退出 */ }
141
128
  }
142
129
 
130
+ let session;
131
+ try {
132
+ session = await Promise.race([
133
+ (async () => {
134
+ const initialized = await conn.agent.request(sdk.methods.agent.initialize, {
135
+ protocolVersion: sdk.PROTOCOL_VERSION,
136
+ // 不认领文件系统与终端能力:四家 CLI 都有自己的文件访问,我们替它读写反而要多做
137
+ // 一套实现,做漏了就是一条静默失败的路。
138
+ clientCapabilities: { terminal: false },
139
+ clientInfo: CLIENT_INFO,
140
+ });
141
+ // 这家认不认图片输入:握手时它自己说。不说就按「不支持」处理 —— 硬塞 image 块过去,
142
+ // 有的适配器会直接回一条参数错误,把整轮提问连正文一起废掉。
143
+ promptCapabilities = (initialized && initialized.agentCapabilities && initialized.agentCapabilities.promptCapabilities) || null;
144
+ const created = await conn.agent.request(sdk.methods.agent.session.new, {
145
+ cwd,
146
+ mcpServers: [],
147
+ });
148
+ return { sessionId: created.sessionId, created };
149
+ })(),
150
+ deadline,
151
+ ]);
152
+ } catch (error) {
153
+ kill();
154
+ throw error;
155
+ }
156
+
143
157
  return {
144
158
  child,
145
159
  sessionId: session.sessionId,
@@ -46,6 +46,7 @@ const { createFrameSealer } = require('../stream.js')
46
46
  const { imageBlockOf, metaOf, openImage, saveImage, MAX_IMAGE_BYTES, MEDIA_EXT } = require('./images.js')
47
47
  const { resolveFinishedUpload } = require('../uploads.js')
48
48
  const { rememberModel } = require('./model-prefs.js')
49
+ const { setValue } = require('./config-prefs.js')
49
50
  const { statsOf } = require('./usage.js')
50
51
  const { hiddenOf, setVisible } = require('./model-visibility.js')
51
52
  const { lookupModelMeta } = require('../model-meta.js')
@@ -1197,7 +1198,12 @@ function createCliMobile(deps) {
1197
1198
  const effort = textOf(body.reasoningEffort)
1198
1199
  if (effort) {
1199
1200
  const effortOption = thoughtOptionOf(options)
1200
- if (effortOption) await session.handle.setConfigOption(effortOption.id, effort)
1201
+ if (effortOption) {
1202
+ await session.handle.setConfigOption(effortOption.id, effort)
1203
+ // 在对话里调过的档位也变成「以后新开的默认」—— 与配置页写的是同一本账
1204
+ // (`config-prefs`),两条路不分家(见 A3-80)。
1205
+ setValue(session.agentId, effortOption.id, effort)
1206
+ }
1201
1207
  }
1202
1208
  } catch { /* 选不动就维持原样:下一次读 models 会回真实值 */ }
1203
1209
  }
@@ -1213,6 +1219,7 @@ function createCliMobile(deps) {
1213
1219
  if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions
1214
1220
  const option = (session.configOptions || []).find((item) => item && item.id === optionId)
1215
1221
  if (option && option.category === 'model') rememberModel(session.agentId, optionId, textOf(body.value))
1222
+ else if (option) setValue(session.agentId, optionId, textOf(body.value))
1216
1223
  } catch { /* 同上 */ }
1217
1224
  }
1218
1225
 
@@ -19,6 +19,7 @@
19
19
  * 带全量块重放,页面回到原样。
20
20
  */
21
21
 
22
+ const { homedir } = require('node:os');
22
23
  const { WebSocketServer } = require('ws');
23
24
 
24
25
  const { readJsonBody, sendJson } = require('../../shared/http-util');
@@ -55,6 +56,14 @@ const FLUSH_MS = 60;
55
56
  /** 标题兜底截断长度(宿主没给 `session_info_update.title` 时用首条提问)。 */
56
57
  const TITLE_MAX = 40;
57
58
 
59
+ /**
60
+ * 「探一次配置」的失败重试间隔。
61
+ *
62
+ * 探一次要起一个适配器(首次还可能要 npx 下载),不能每次进配置页都重来一遍;但也不该把
63
+ * 一次失败记一辈子 —— 用户多半是去装了 CLI / 登录了才回来的。
64
+ */
65
+ const PROBE_RETRY_MS = 60 * 1000;
66
+
58
67
  class CliRequestError extends Error {
59
68
  constructor(code, message) {
60
69
  super(message);
@@ -93,6 +102,8 @@ function pendingPrefsOf(session) {
93
102
  for (const option of options) {
94
103
  if (option && option.category === 'model') skip.add(option.id);
95
104
  }
105
+ // 模型那一项记在 `model-prefs` 那本账里(`applyPreferredModel` 负责),这里一律跳过 ——
106
+ // 包括 id 不叫 `model` 的那些(claude 的 `agent` 子代理、opencode 的分组模型)。
96
107
  const out = [];
97
108
  for (const [configId, value] of Object.entries(recorded)) {
98
109
  if (skip.has(configId)) continue;
@@ -112,6 +123,38 @@ async function bodyJson(req) {
112
123
  return body;
113
124
  }
114
125
 
126
+ /**
127
+ * 这一项是不是「模型」。
128
+ *
129
+ * 判据是 ACP 给的 `category`,不是 id 的字面值:codex/opencode 叫 `model`,claude 的模型
130
+ * 项也叫 `model`,但换一家可能就是别的名字 —— 按 id 猜就会漏掉一家,而漏掉的表现正是
131
+ * 「配置页勾上了、开新对话还是原来的模型」。认不出(配置页还没拿到选项表)就按 id 兜底。
132
+ */
133
+ function isModelOption(options, configId) {
134
+ const list = Array.isArray(options) ? options : [];
135
+ const option = list.find((item) => item && item.id === configId);
136
+ if (option) return option.category === 'model';
137
+ return configId === 'model';
138
+ }
139
+
140
+ /**
141
+ * 配置页要画的「记着的那一套」。
142
+ *
143
+ * 两本账合到一处:模型记在 `model-prefs`(新会话开局由 `applyPreferredModel` 套),其余记在
144
+ * `config-prefs`。配置页只认一张表,所以在这里并起来 —— 不并的话,配置页里模型那一格永远
145
+ * 显示「跟 CLI 自己的一样」,用户选过的模型看不见。
146
+ */
147
+ function valuesBodyOf(agentId, options) {
148
+ const values = { ...prefsOf(agentId).values };
149
+ const preferred = preferredModel(agentId);
150
+ if (preferred) values[preferred.configId] = preferred.value;
151
+ // 两本账里都不该有「模型」,但真出现过(旧版本写进去的)就别让它冒充生效值。
152
+ for (const key of Object.keys(values)) {
153
+ if (isModelOption(options, key) && !(preferred && preferred.configId === key)) delete values[key];
154
+ }
155
+ return values;
156
+ }
157
+
115
158
  /** 会话表与它的所有出口。一个宿主进程一份(装配层建、装配层关)。 */
116
159
  function createCliService({ authorized, onLog } = {}) {
117
160
  /** sessionId → 会话。 */
@@ -142,6 +185,72 @@ function createCliService({ authorized, onLog } = {}) {
142
185
  return agentsPromise;
143
186
  }
144
187
 
188
+ /** agentId → `{ running, at, error, done }`:这一家探配置的状态(同时只允许一次)。 */
189
+ const probes = new Map();
190
+
191
+ function probeState(agentId) {
192
+ return probes.get(agentId) || null;
193
+ }
194
+
195
+ /**
196
+ * 现去问一次这个 CLI 有哪些可调项。
197
+ *
198
+ * 为什么要这一步:选项表是 CLI 自己在握手之后给的,缓存里只有「开过对话的那几家」。用户新
199
+ * 装一个 CLI、想先把权限档配好再去用,正是最需要配置页的时候,而那时缓存是空的 —— 配置页
200
+ * 就是一句「没有可调的项」,四个配置入口点下去长得一模一样。
201
+ *
202
+ * 代价是起一次适配器(首次还要 npx 下载),所以:同一个 CLI 同时只探一次;探到手就落缓存,
203
+ * 之后的配置页直接读;失败记一段时间(`PROBE_RETRY_MS`),别让用户每次进来都等一轮下载。
204
+ * 它不进 `sessions` 那张表 —— 这是一次性的探测进程,拿到表就关,侧栏里不该多出一行。
205
+ */
206
+ function startProbe(ctx, agentId) {
207
+ if (probes.has(agentId) && probes.get(agentId).running) return;
208
+ const entry = { running: true, at: Date.now(), error: '', done: false };
209
+ probes.set(agentId, entry);
210
+ void (async () => {
211
+ let handle = null;
212
+ try {
213
+ const agent = (await listAgents()).find((item) => item && item.id === agentId) || null;
214
+ if (!agent) throw new Error('这台电脑上没有这个 CLI');
215
+ const spec = resolveAgent(agent);
216
+ if (!spec.ok) throw new Error(spec.reason || '这个 CLI 起不来');
217
+ // 起在哪:配置项与目录无关,随便借一个已注册工作区(没有工作区就借家目录 —— 有些适配器
218
+ // 对 cwd 有要求,给个存在且可写的路径总比空串安全)。
219
+ const cwd = await pickProbeCwd(ctx);
220
+ handle = await startAgent({ spec, cwd, env: childEnv(spec.command) });
221
+ if (closed) return;
222
+ // 探到了就记下来(这家确实一项都不给时记的是空表,配置页据此说「没有可调的项」
223
+ // 而不是一直转圈)。
224
+ rememberOptions(agentId, handle.configOptions);
225
+ entry.error = '';
226
+ // 这一家**问过了**:哪怕它一项配置都不给,也不许再探第二次 —— 那等于每进一次配置页
227
+ // 就起一个适配器进程,而结果永远是同一张空表。
228
+ entry.done = true;
229
+ } catch (error) {
230
+ entry.error = error && error.message ? error.message : '问不到这个 CLI 的配置';
231
+ log(`cli 探配置失败(${agentId}):${entry.error}`);
232
+ } finally {
233
+ entry.running = false;
234
+ entry.at = Date.now();
235
+ try { handle?.close(); } catch { /* 已经退了 */ }
236
+ }
237
+ })();
238
+ }
239
+
240
+ /** 探测进程的工作目录:优先借一个已注册工作区,取不到就用家目录。 */
241
+ async function pickProbeCwd(ctx) {
242
+ try {
243
+ const listed = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
244
+ const { value } = unwrap(listed);
245
+ const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
246
+ for (const item of items) {
247
+ const dir = text(item && item.path);
248
+ if (dir) return dir;
249
+ }
250
+ } catch { /* 取不到就退到家目录 */ }
251
+ return homedir();
252
+ }
253
+
145
254
  function send(ws, frame) {
146
255
  if (ws.readyState !== ws.OPEN) return;
147
256
  try { ws.send(JSON.stringify(frame)); } catch { /* 已经断了 */ }
@@ -664,11 +773,11 @@ function createCliService({ authorized, onLog } = {}) {
664
773
  const updated = await session.handle.setConfigOption(configId, body.value);
665
774
  if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions;
666
775
  // 选过的模型要记住:下一次在这个 CLI 上开新会话时自动套上(见 applyPreferredModel)。
667
- // 只记模型那一项 —— 思考档 / 权限模式跟着模型走,单独记会把两边记岔。
776
+ // 模型只记在 `model-prefs` 那本账里;其余每一项记进 `config-prefs.js` —— 那是「下次
777
+ // 开局自动套上」的唯一来源,两本账各记一份的话生效的那本必须是被写的那本。
668
778
  const option = (session.configOptions || []).find((item) => item && item.id === configId);
669
779
  if (option && option.category === 'model') rememberModel(session.agentId, configId, body.value);
670
- // 每一项都记:这是「下次开局自动套上」的唯一来源(见 `cli/config-prefs.js`)。
671
- if (option) setValue(session.agentId, configId, text(body.value));
780
+ else if (option) setValue(session.agentId, configId, text(body.value));
672
781
  } else throw fail('BAD_REQUEST', '需要 configId 或 modeId');
673
782
  } catch (error) {
674
783
  if (error && error.code && STATUS_BY_CODE[error.code]) throw error;
@@ -680,11 +789,15 @@ function createCliService({ authorized, onLog } = {}) {
680
789
  /**
681
790
  * GET /cli/prefs?agent=:一个 CLI 的配置全貌(配置页读它)。
682
791
  *
683
- * 配置页在**没有开会话**的时候就要能画:所以这里回的是「上次记下的选项表 + 记着的值」,
684
- * 一个进程都不用起。`live` 说的是这份表是从正在跑的那条会话读的(更新),还是缓存(CLI 那之后
685
- * 可能升级过)。
792
+ * 优先顺序:正开着的那条会话手上的表(最新)→ 记忆里那份(CLI 握过手就记下)→ **现去问一次**。
793
+ * 最后一档是必须的:选项表是 CLI 自己给的,一家从没在这台电脑上开过对话的 CLI(全新装的
794
+ * Gemini CLI 之类)记忆里一条都没有,配置页就成了一句「没有可调的项」—— 而「提前把权限配
795
+ * 好」正是最需要它的时候。问的那一次在后台跑(起一个适配器、拿到 `configOptions` 就关掉),
796
+ * 回来之前回 `pending`,App 按退避重问。
797
+ *
798
+ * `live` 说的是这份表是从跑着的会话读的;`pending` 说的是正在问。
686
799
  */
687
- function handlePrefs(_ctx, req, res) {
800
+ async function handlePrefs(ctx, req, res) {
688
801
  const agentId = text(query(req).get('agent'));
689
802
  if (!agentId) throw fail('BAD_REQUEST', '需要 agent');
690
803
  const recorded = prefsOf(agentId);
@@ -700,12 +813,29 @@ function createCliService({ authorized, onLog } = {}) {
700
813
  live = true;
701
814
  break;
702
815
  }
816
+ let pending = false;
817
+ let probeError = '';
818
+ // `force` 是配置页那行「重试」:探测失败之后,用户去装了 CLI / 登了录才回来,等
819
+ // `PROBE_RETRY_MS` 到点是不对的 —— 他这一下就是要现在再问一次。
820
+ const force = query(req).get('probe') === '1';
821
+ if (options.length === 0 && !live) {
822
+ const state = probeState(agentId);
823
+ if (state && state.running) pending = true;
824
+ else if (!state || force || (!state.done && (!state.error || Date.now() - state.at > PROBE_RETRY_MS))) {
825
+ startProbe(ctx, agentId);
826
+ pending = true;
827
+ } else if (!state.done && state.error) {
828
+ probeError = state.error;
829
+ }
830
+ }
703
831
  sendJson(res, 200, {
704
832
  ok: true,
705
833
  agent: agentId,
706
834
  options,
707
- values: recorded.values,
835
+ values: valuesBodyOf(agentId, options),
708
836
  live,
837
+ pending,
838
+ ...(probeError ? { probeError } : {}),
709
839
  });
710
840
  }
711
841
 
@@ -724,14 +854,21 @@ function createCliService({ authorized, onLog } = {}) {
724
854
  * 那几条」。配置页在没有会话时用它,所以它也不启动任何进程。
725
855
  *
726
856
  * `value` 空 = 撤掉这一项,回到 CLI 自己的默认。
857
+ *
858
+ * **模型那一项记在 `model-prefs` 那本账上**:新会话开局是 `applyPreferredModel` 在读它,
859
+ * 记进 `config-prefs` 只会出现「配置页勾上了、开新对话却是别的」—— 两本账各记一份时,
860
+ * 生效的那本必须是被写的那本。
727
861
  */
728
862
  async function handleSetPref(_ctx, req, res) {
729
863
  const body = await bodyJson(req);
730
864
  const agentId = text(body.agent);
731
865
  const configId = text(body.configId);
732
866
  if (!agentId || !configId) throw fail('BAD_REQUEST', '需要 agent 与 configId');
733
- setValue(agentId, configId, text(body.value));
734
- sendJson(res, 200, { ok: true, agent: agentId, values: prefsOf(agentId).values });
867
+ const value = text(body.value);
868
+ const options = prefsOf(agentId).options;
869
+ if (isModelOption(options, configId)) rememberModel(agentId, configId, value);
870
+ else setValue(agentId, configId, value);
871
+ sendJson(res, 200, { ok: true, agent: agentId, values: valuesBodyOf(agentId, options) });
735
872
  }
736
873
 
737
874
  /** POST cli/close { sessionId }:主动收掉一个会话(用户点「结束」)。 */
@@ -897,5 +1034,9 @@ module.exports = {
897
1034
  mountCli,
898
1035
  createCliService,
899
1036
  pendingPrefsOf,
1037
+ // 配置页要画的那张表与「哪一项是模型」的判据:两条都在这里,测试直接钉这两份真函数
1038
+ // (照抄一份等价实现的话,实现写错测试照样绿)。
1039
+ isModelOption,
1040
+ valuesBodyOf,
900
1041
  MAX_SESSIONS,
901
1042
  };