dsh-mobile-plugin 0.5.1 → 0.5.4

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.
@@ -9,15 +9,17 @@
9
9
  * 1. 游离的 Promise 拒绝——Node 15+ 默认 throw 并杀进程,插件里大量 fire-and-forget;
10
10
  * 2. 第三方回调(事件流、WS、timer)里抛错,没有 try 包着就是 uncaughtException。
11
11
  *
12
- * 策略:全部记入内存账本并保持存活;只有 uncaughtException 计入熔断,且必须落在时间窗
13
- * 内连发到 `fatalCount` 才主动退出,交给 `Restart=on-failure` 拉起。`createGuard()` 是
14
- * 纯状态机,`installProcessGuard()` 才是进程接线,拆分是为了能被守卫测试覆盖。
12
+ * 策略:全部记入内存账本并保持存活,**不主动退出进程**。这个进程里跑的不止我们:无头宿主
13
+ * 把整份 profile 的插件都装在同一个 process 上,别人的崩溃循环也走同一条 `uncaughtException`。
14
+ * 在这里 exit 就等于「别人的插件崩五次,我们把用户的 DSH 关掉」,而桌面端根本没有守护来拉起。
15
+ * 所以只记账、只报警,生死交给部署方(systemd 的 Restart、宿主的 installFailLoud 只管装载期)。
16
+ *
17
+ * `createGuard()` 是纯状态机,`installProcessGuard()` 才是进程接线,拆分是为了能被守卫测试覆盖。
15
18
  */
16
19
 
17
20
  const FATAL_FAULT_COUNT = 5;
18
21
  const CONSECUTIVE_FAULT_WINDOW_MS = 60 * 1000;
19
22
  const FAULT_LEDGER_LIMIT = 50;
20
- const FATAL_EXIT_CODE = 1;
21
23
 
22
24
  /** 把任意 throw 出来的值压成一行可读文本。 */
23
25
  function describeError(err) {
@@ -38,7 +40,6 @@ function describeError(err) {
38
40
  * @param {number} [options.ledgerLimit] 账本上限,缺省 50
39
41
  * @param {() => number} [options.now] 注入时钟,便于测试
40
42
  * @param {(line: string) => void} [options.log] 注入日志,便于测试
41
- * @param {(code: number) => void} [options.exit] 注入退出,便于测试
42
43
  * @returns {{handleUncaughtException: Function, handleUnhandledRejection: Function,
43
44
  * recordFault: Function, faults: Function, stats: Function, clear: Function,
44
45
  * isFatal: Function}}
@@ -48,7 +49,6 @@ function createGuard(options = {}) {
48
49
  const windowMs = options.windowMs ?? CONSECUTIVE_FAULT_WINDOW_MS;
49
50
  const ledgerLimit = options.ledgerLimit ?? FAULT_LEDGER_LIMIT;
50
51
  const now = options.now ?? (() => Date.now());
51
- const exit = options.exit ?? ((code) => process.exit(code));
52
52
  const log = options.log ?? ((line) => { process.stderr.write(`${line}\n`); });
53
53
 
54
54
  /** @type {{at: number, kind: string, message: string, fatal: boolean}[]} */
@@ -65,8 +65,8 @@ function createGuard(options = {}) {
65
65
 
66
66
  return {
67
67
  /**
68
- * `process.on('uncaughtException')` 的处理函数。
69
- * 记一笔,计入连续计数,够数才退出。
68
+ * `process.on('uncaughtException')` 的处理函数。只记一笔并计入连续计数:够数**不退出**,
69
+ * 只把这条故障在账本里标成 fatal,让 `/api/relay/status` 的自检能说出「这台在崩溃循环」。
70
70
  */
71
71
  handleUncaughtException(err) {
72
72
  const at = now();
@@ -77,16 +77,15 @@ function createGuard(options = {}) {
77
77
  const fatal = consecutive >= fatalCount;
78
78
  if (fatal) fatalEver = true;
79
79
  record('uncaughtException', describeError(err), fatal);
80
- log(`[dsh-mobile-plugin] 未捕获异常(连续 ${consecutive}/${fatalCount}${fatal ? ',判定崩溃循环,将退出' : ''}): ${describeError(err)}`);
81
- if (fatal) exit(FATAL_EXIT_CODE);
80
+ log(`[dsh-mobile-plugin] 未捕获异常(连续 ${consecutive}/${fatalCount}${fatal ? ',判定崩溃循环;不退出进程,请查宿主日志' : ''}): ${describeError(err)}`);
82
81
  return fatal;
83
82
  },
84
83
 
85
84
  /**
86
85
  * `process.on('unhandledRejection')` 的处理函数。
87
86
  *
88
- * 刻意**不计入熔断**:游离 reject 在长驻服务里非常常见(一次后台推送失败就
89
- * 一个),把它们累加成退出条件会制造新的崩溃源。挂上处理器本身就已经把
87
+ * 刻意**不计入连续计数**:游离 reject 在长驻服务里非常常见(一次后台推送失败就
88
+ * 一个),把它们算进崩溃循环的判据会制造新的误报。挂上处理器本身就已经把
90
89
  * Node 的默认「throw 并杀进程」改成了「记录并继续」。
91
90
  */
92
91
  handleUnhandledRejection(reason) {
@@ -196,5 +195,4 @@ module.exports = {
196
195
  FATAL_FAULT_COUNT,
197
196
  CONSECUTIVE_FAULT_WINDOW_MS,
198
197
  FAULT_LEDGER_LIMIT,
199
- FATAL_EXIT_CODE,
200
198
  };
package/lib/impl.js CHANGED
@@ -51,37 +51,27 @@ const { activeGuard } = require('./host-guard.cjs')
51
51
  // 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
52
52
 
53
53
  export const name = 'dsh-mobile-plugin'
54
- // The mobile API runs headless inside the Cordis Host. `webServer` and
55
- // `sessions` are the services it needs to serve /api/mobile/v1.
54
+ // 0.2.0 移除了 `settings.register(namespace, schema, { base, applies })` 那套 API,所以不再
55
+ // 注入 `settings`:配置改由插件自己的 relay.json 存储负责(见下面 createConfigStore)。
56
+ // 0.2.0 里也没有 `apiProxy` 这个服务,宿主 RPC 全走 `typertGateway`;`ctx.apiProxy` 的
57
+ // 旧形状由 apply 在本 fiber 上 provide 的兼容桥兜住(fiber.store 里就有,get trap 第一站
58
+ // 就命中)。漏了这条分支,读 `ctx.apiProxy` 会抛 "without inject",服务器宿主上 mirror
59
+ // 推送曾整条挂掉。
56
60
  //
57
- // 0.2.0 移除了 `settings.register(namespace, schema, { base, applies })` 这套 API
58
- // (`@deepseek-ai/dsh-settings` 现在只导出 SettingsForms / redactSecrets /
59
- // SettingsConflictError),所以这里不再注入 `settings`,配置改由插件自己的
60
- // relay.json 存储负责 —— 见下面 createConfigStore。
61
+ // 只有 `webServer` 是硬依赖:没有它这个插件一行路由都注册不上,等不到就是真的没得做。
61
62
  //
62
- // 0.2.0 里也没有 `apiProxy` 这个服务(整个 asar 零命中):宿主 RPC 全部走
63
- // `ctx.get('typertGateway')` 的 `invoke({ namespace, method, args })`,见
64
- // mobile/plugins.js、mobile/references.js 里同款封装。留着 apiProxy 只会让插件永远
65
- // pending —— 服务不存在时 loader 不会激活这一行,于是路由、sidecar、中转全都不出现,
66
- // 而日志只有一行 "pending (waiting for service: apiProxy)"。
63
+ // 其余服务**一律不写进 inject**,改成用到时 `ctx.get`。cordis 的 inject 是「缺一个就整行
64
+ // pending」:`typertGateway` 由后装载的另一行提供、`sessions` 来自别的 bundle,任何一个被
65
+ // 别人关掉 / 换掉 / 延迟,注进去的代价都是路由、sidecar、配对、二维码**一起不出现**,日志
66
+ // 里只剩一行 pending —— 手机上表现为「装了插件但完全连不上」。
67
67
  //
68
- // 所以 `apiProxy` 是**按运行时二选一**的:
69
- // 旧运行时(自带 @deepseek-ai/dsh-host-apiproxy)→ 注入它,读宿主那份真服务;
70
- // 0.2.0 → 不注入,改由 apply 在本 fiber 上
71
- // provide 兼容桥(fiber.store 里
72
- // 有,get trap 第一站就命中)。
73
- // 漏了这两个分支里的任何一个,读 `ctx.apiProxy` 都会抛
74
- // `cannot get property "apiProxy" without inject`:服务器宿主上 mirror 推送曾整条挂掉。
68
+ // 取不到时如实降级:宿主 RPC 那条回 `host-unavailable`(见 host-bridge/transport.js 的
69
+ // gatewayOf,它每次调用都现取),路由、局域网直连、控制面、镜像照常工作。实时流那条在
70
+ // 网关永远不出现时保持空闲 + 心跳(`host-stream-unavailable`),下一次重连就会接上。
75
71
  //
76
- // `typertGateway` 必须出现在 inject 里(0.2.0 那一支):宿主 RPC 全部经它派发,
77
- // 而它是**后于本插件**装载的服务。只 `ctx.get('typertGateway')` 而不注入,取到的是
78
- // 装载那一刻的空值,于是每一条事件流都抛「当前宿主版本没有提供该能力」——服务器宿主
79
- // 上实时推送整条挂掉,而收发消息、会话列表这些走普通请求的功能一切正常,看着像只有
80
- // 「刷新不实时」的小毛病。注入之后 cordis 会等服务就绪再激活本插件。
81
- // 旧运行时那一支不加:那版没有这个服务,注入只会让这一行永远 pending。
82
- export const inject = runtimeProvidesApiProxy()
83
- ? ['webServer', 'apiProxy', 'sessions']
84
- : ['webServer', 'sessions', 'typertGateway']
72
+ // 旧运行时那一支多一个 `apiProxy`:宿主自己提供它(`@deepseek-ai/dsh-host-apiproxy` 装上
73
+ // 了,就一定会被提供),读 `ctx.apiProxy` 必须有 inject,否则 get trap 直接抛。
74
+ export const inject = runtimeProvidesApiProxy() ? ['webServer', 'apiProxy'] : ['webServer']
85
75
 
86
76
  export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
87
77
  export const RemoteControlSettingsSchema = z.object({
@@ -258,7 +248,27 @@ function apply(ctx) {
258
248
  // 这两条二选一,判据同源:0.1.x 宿主自带真 apiProxy(审批帧与回答都还走得通),
259
249
  // 0.2.x 才需要兼容桥,也才需要审批桥(见 mobile/approvals.js 的文件头)。
260
250
  const hasHostApiProxy = runtimeProvidesApiProxy()
261
- if (!hasHostApiProxy) ctx.reflect.provide('apiProxy', createApiProxy(ctx))
251
+ /** 装配层自己的告警出口:拿不到 logger 也照样往下走,不打日志不是失败。 */
252
+ const warn = (message, ...rest) => {
253
+ try { ctx.logger?.warn?.(`${name}: ${message}`, ...rest) } catch { /* 日志本身不许反噬装配 */ }
254
+ }
255
+ /**
256
+ * 每一块装配各自兜住:一块挂不上只留一行日志,剩下的照常启动。
257
+ *
258
+ * 代价说清楚:别人撞了同一个路由、先占了一个服务名,缺的必须是**那一块**,不能是整张
259
+ * 移动 API —— 装配层里一个没兜住的 throw 会让 fiber 回滚,已经注册好的路由一起消失。
260
+ */
261
+ const mount = (label, fn) => {
262
+ try { return fn() } catch (error) {
263
+ warn(`${label} 没有挂上:%s`, error?.message || error)
264
+ return undefined
265
+ }
266
+ }
267
+ if (!hasHostApiProxy) {
268
+ // `apiProxy` 是公共名字:另一个兼容旧 API 的插件也可能 provide 它。撞上时用对方那份
269
+ // (同形状、同语义),不跟它抢 —— 抢的结果是我们自己整块装配失败,手机那边全断。
270
+ mount('apiProxy 兼容桥', () => ctx.reflect.provide('apiProxy', createApiProxy(ctx)))
271
+ }
262
272
 
263
273
  const state = core.loadState()
264
274
  // 0.2.0 之前是 `ctx.settings.register(REMOTE_CONTROL_SETTINGS_NAMESPACE,
@@ -329,11 +339,11 @@ function apply(ctx) {
329
339
  // 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
330
340
  sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
331
341
 
332
- ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`)
333
- ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`)
342
+ mount('sidecar', () => ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`))
343
+ mount('镜像推送', () => ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`))
334
344
 
335
- if (!hasHostApiProxy) ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`)
336
- if (!hasHostApiProxy) ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`)
345
+ if (!hasHostApiProxy) mount('审批桥', () => ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`))
346
+ if (!hasHostApiProxy) mount('提问桥', () => ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`))
337
347
  ctx.effect(() => {
338
348
  // Profiles without the optional tools injection still support core remote control.
339
349
  try { installSessionCleanup(ctx) } catch (error) {
@@ -348,7 +358,7 @@ function apply(ctx) {
348
358
  }
349
359
  return undefined
350
360
  }, `${name}: extension tool`)
351
- ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`)
361
+ mount('统计采集', () => ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`))
352
362
 
353
363
  const status = createStatusView({
354
364
  state,
@@ -365,11 +375,24 @@ function apply(ctx) {
365
375
  function applyServices() {
366
376
  void syncSidecar().catch(() => undefined)
367
377
  }
378
+ /**
379
+ * 一条路由挂不上只丢这一条。
380
+ *
381
+ * `webServer.register` 对重复地址是抛错(另一个插件用了同一个路径、上一代没卸干净都在
382
+ * 这一类),而这个调用本来就在 effect 里 —— 让它抛出去会把整个 fiber 带走,连已经挂上
383
+ * 的几百条一起消失。所以这里逐条兜住,日志里留下路径,别让手机端拿到一片 404。
384
+ */
385
+ const mountRoute = (route, upgrade) => mount(`路由 ${route.path}`, () => ctx.effect(() => {
386
+ try { return upgrade ? ctx.webServer.registerUpgrade(route) : ctx.webServer.register(route) } catch (error) {
387
+ warn('路由 %s 没挂上:%s', route.path, error?.message || error)
388
+ return () => {}
389
+ }
390
+ }, `${name}: ${route.path}`))
368
391
  function registerRoute(route) {
369
- ctx.effect(() => ctx.webServer.register(route), `${name}: ${route.path}`)
392
+ mountRoute(route)
370
393
  }
371
394
 
372
- registerControlPlaneRoutes({
395
+ mount('控制面路由', () => registerControlPlaneRoutes({
373
396
  registerRoute,
374
397
  // 更新的机制住在外壳(`lib/index.js`)里,代这边只把它开成本机接口。取不到就如实报
375
398
  // 不可用(`lib/impl.js` 被单独挂载时就是这样)。
@@ -408,7 +431,7 @@ function apply(ctx) {
408
431
  // 吊销时收掉这台设备已经开着的流(§3.3):token 换了只保证「下一次请求」被拒,
409
432
  // 一条已经开着的 SSE 不会自己停,被踢的手机还能继续收消息直到它自己重连。
410
433
  dropDeviceStreams: (deviceId) => streamRegistry.drop(deviceId),
411
- })
434
+ }))
412
435
 
413
436
 
414
437
  /**
@@ -452,13 +475,11 @@ function apply(ctx) {
452
475
 
453
476
  // 移动端路由表在 lib/routes/mobile-api.js:那里是一张可以对着 App 侧 apiPaths.ts
454
477
  // 核对的「路径 → handler」表,不用在 1000 行装配代码里翻。
455
- registerMobileRoutes({
478
+ mount('移动端路由', () => registerMobileRoutes({
456
479
  registerRoute,
457
480
  // 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
458
481
  // 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
459
- registerUpgradeRoute: (route) => {
460
- ctx.effect(() => ctx.webServer.registerUpgrade(route), `${name}: ${route.path}`)
461
- },
482
+ registerUpgradeRoute: (route) => mountRoute(route, true),
462
483
  preview,
463
484
  guarded,
464
485
  state,
@@ -495,8 +516,8 @@ function apply(ctx) {
495
516
  credentials,
496
517
  activeGuard,
497
518
  },
498
- })
499
- ctx.effect(() => {
519
+ }))
520
+ mount('服务同步', () => ctx.effect(() => {
500
521
  const stopWatching = settings.watch(next => {
501
522
  Object.assign(state, next)
502
523
  core.saveState(state)
@@ -509,7 +530,7 @@ function apply(ctx) {
509
530
  // 那个 effect 独占。两个 effect 都停一次会让 worker 在 dispose 期间收到
510
531
  // 两次 stop,第二次撞上已关闭的进程句柄并抛出未捕获异常。
511
532
  }
512
- }, `${name}: services`)
533
+ }, `${name}: services`))
513
534
  }
514
535
 
515
536
  export { apply }
package/lib/index.js CHANGED
@@ -31,14 +31,14 @@ import { createDeviceOta } from './shell/device-ota.js'
31
31
  import { installHostHalf } from './shell/half-route.js'
32
32
 
33
33
  export const name = 'dsh-mobile-plugin'
34
- export const inject = ['webServer', 'sessions']
34
+ // 外壳同样只等 `webServer`:它不读 `sessions`,多注一个只是多一种「整行 pending」的理由。
35
+ export const inject = ['webServer']
35
36
 
36
37
  /**
37
38
  * 装载当前代,并把「查 / 装 / 换代码」这套动作暴露成服务。
38
39
  *
39
- * 对照 `lib/impl.js` 的 `apply`:那边签名也是 `(ctx)`,只是它注册的是真正的移动 API;
40
- * 这里注册的是「装载它」。两者的 effect 各自归属自己那层 fiber —— 外壳被卸载时,代的
41
- * fiber 作为它的子节点一起被 dispose,代里那些 `ctx.effect` 的清理动作照常执行。
40
+ * 对照 `lib/impl.js` 的 `apply`:那边注册真正的移动 API,这里注册「装载它」。两份 effect
41
+ * 各自归属自己那层 fiber —— 外壳被卸载时,代的 fiber 作为子节点一起 dispose。
42
42
  */
43
43
  export function apply(ctx) {
44
44
  const runtime = createRuntime()
@@ -97,32 +97,31 @@ export function apply(ctx) {
97
97
  * 就重试」都在 `updater.performUpdate` 里 —— 入口只留装载逻辑,行数钉着。
98
98
  */
99
99
  async function applyUpdate({ force = false, auto = false } = {}) {
100
- return updater.performUpdate({
101
- runtime, base, hostService, updateState, force, auto, log,
102
- beforeInstall: snapshotRunning,
103
- })
100
+ return updater.performUpdate({ runtime, base, hostService, updateState, force, auto, log, beforeInstall: snapshotRunning })
104
101
  }
105
102
 
106
- // 换代前要知道有没有请求在飞(见 runtime.drain)。宿主 webServer 没有中间件机制,所以
107
- // 这里包一层 register,把每条路由的 handler 变成「进 +1 / 出 -1」。包的时机在任何一代
108
- // 装载之前,且 `wrapHandler` 遇到不认识的形状会原样放行。
103
+ // 换代前要知道有没有请求在飞(见 runtime.drain)。webServer 没有中间件机制,所以包一层
104
+ // register 把每条 handler 变成「进 +1 / 出 -1」(`wrapHandler` 认不出的形状原样放行)。
105
+ //
106
+ // 这层包装必须**可叠加**:别人后来也包一层时闭包着我们的包装,撤销时无条件还原会把
107
+ // 别人的那层一起摘掉。所以只在「现在挂的就是我们这层」时才还原。
109
108
  ctx.effect(() => {
110
109
  const server = ctx.webServer
111
- const original = server?.register
112
- if (typeof original !== 'function') return () => {}
113
- server.register = function register(route) {
114
- return original.call(this, runtime.wrapHandler(route))
110
+ const outer = server?.register
111
+ if (typeof outer !== 'function') return () => {}
112
+ const wrapper = function register(route) {
113
+ return outer.call(this, runtime.wrapHandler(route))
115
114
  }
115
+ server.register = wrapper
116
116
  return () => {
117
- server.register = original
117
+ if (server.register === wrapper) server.register = outer
118
118
  return runtime.detach()
119
119
  }
120
120
  }, 'dsh-mobile-plugin: generation runtime')
121
121
 
122
122
  const installedShellHash = () => updater.shellFingerprint(installedPackageDir())
123
-
124
- // 宿主半边的自检口(实现在 `shell/half-route.js`):实现装载失败时整张控制面路由
125
- // 都不在,只有外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
123
+ // 宿主半边的自检口(`shell/half-route.js`):实现装载失败时整张控制面路由都不在,只有
124
+ // 外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
126
125
  ctx.effect(() => installHostHalf(ctx, runtime, { installedVersion, installedShellHash, runningShellHash }), 'dsh-mobile-plugin: host half self check')
127
126
 
128
127
  ctx.reflect.provide('dshMobilePluginRuntime', {
@@ -163,9 +162,8 @@ export function apply(ctx) {
163
162
  .start(ctx, { log })
164
163
  .then(() => {
165
164
  log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
166
- // 每 2 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
167
- // 自动安装仍由开关控制,开着就**不等正在跑的对话**(`auto` 那一档,见 update-install)。
168
- // 补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
165
+ // 每 2 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那次换代(补换代不
166
+ // 受开关管:它不下载任何东西);自动安装由开关控制,开着就不等正在跑的对话。
169
167
  const timer = setInterval(() => {
170
168
  deviceOta.reconcileOfficial()
171
169
  .then(() => updater.reconcileInstalledGeneration({ runtime, log }))
@@ -179,6 +177,7 @@ export function apply(ctx) {
179
177
  ctx.effect(() => () => clearInterval(timer), 'dsh-mobile-plugin: auto update timer')
180
178
  // 启动后延后一次:别和绑端口、连隧道抢带宽。
181
179
  if (updater.autoUpdateEnabled()) {
180
+
182
181
  const kick = setTimeout(() => {
183
182
  applyUpdate({ auto: true }).catch(error => {
184
183
  updateState.lastError = String(error?.message || error)
@@ -4,7 +4,8 @@
4
4
  * 移动端「Agent 预设管理」API:把桌面端设置页的预设管理分区补齐到手机上。
5
5
  *
6
6
  * 手机端只保留管理分区(app 的 PresetManager):桌面那边设置里的一行与新建会话芯片已下线,
7
- * 手机一律吃宿主默认档。三条路由都只转发宿主的 agentPreset 域:read / copy / remove。
7
+ * 手机一律吃宿主默认档。四条路由:read / copy / remove 转发宿主的 agentPreset 域;default
8
+ * 写宿主 settings 里存「默认档」的那一格(见 handleAgentPresetDefault)。
8
9
  *
9
10
  * 语义按宿主原文透传:copy 是整份复制,标识符成为目录名且事后无法更改;remove 只对 user
10
11
  * 预设有效(随附预设回 agent-preset-read-only)。宿主的失败对象是 code + details,按 code
@@ -143,10 +144,73 @@ async function handleAgentPresetRemove(ctx, req, res) {
143
144
  sendJson(res, 200, { ok: true, agentPreset: id.value });
144
145
  }
145
146
 
147
+ /** 宿主 settings 里存「默认预设」的那一格:桌面端「设为默认」写的就是它。 */
148
+ const PRESET_SETTINGS_NS = 'agent-preset-registry';
149
+ const PRESET_DEFAULT_FIELD = 'selectedDefault';
150
+
151
+ /**
152
+ * POST /api/mobile/v1/agent-presets/default(token){ agentPreset } → { ok, agentPreset }
153
+ *
154
+ * 「哪一档是新会话的默认档」不是预设自己的属性:宿主注册表从 settings 的
155
+ * `agent-preset-registry.selectedDefault` 读(没写过就退回部署时那个 default),桌面端那颗
156
+ * 「设为默认」写的就是这一格。手机此前没有这个入口,新会话只能吃部署时那一档。
157
+ *
158
+ * 先按名单确认这一档真在,再写:写一个不存在的 id 不会报错,但之后每次新建会话都会被宿主
159
+ * 拒(agent-preset-not-found),那时用户已经不知道是自己哪一步弄坏的。
160
+ */
161
+ async function handleAgentPresetDefault(ctx, req, res) {
162
+ const body = await readJsonBody(req, PRESET_MAX_BODY);
163
+ const id = normalizeId(body && body.agentPreset);
164
+ if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
165
+
166
+ const listed = unwrap(await callAgentPresets(ctx, 'list', {}));
167
+ if (listed.error) return sendJson(res, statusOf(listed.error.code), { ok: false, error: failure(listed.error) });
168
+ const rows = listed.value && Array.isArray(listed.value.presets) ? listed.value.presets : [];
169
+ const row = rows.find((item) => item && item.id === id.value);
170
+ if (!row) {
171
+ return sendJson(res, 404, {
172
+ ok: false,
173
+ error: {
174
+ code: 'agent-preset-not-found',
175
+ message: presetMessage('agent-preset-not-found'),
176
+ details: { agentPreset: id.value, available: rows.map((item) => item && item.id).filter(Boolean) },
177
+ },
178
+ });
179
+ }
180
+ // 已经是默认档就不再写:settings.mutate 一次就推进一次 revision,白写等于让别处正在
181
+ // 改设置的人撞上冲突。
182
+ if (row.isDefault === true) return sendJson(res, 200, { ok: true, agentPreset: id.value });
183
+
184
+ const settings = ctx.apiProxy && ctx.apiProxy.settings;
185
+ if (!settings || typeof settings.mutate !== 'function') {
186
+ return sendJson(res, 400, {
187
+ ok: false,
188
+ error: { code: 'unsupported', message: '这台宿主没有设置写入接口,改不了默认预设' },
189
+ });
190
+ }
191
+ const { error } = unwrap(await settings.mutate({
192
+ rpcId: 'mobile',
193
+ method: 'settings.mutate',
194
+ payload: { ns: PRESET_SETTINGS_NS, ops: [{ op: 'set', path: [PRESET_DEFAULT_FIELD], value: id.value }] },
195
+ }));
196
+ if (error) {
197
+ const hostMessage = error && typeof error.message === 'string' ? error.message : '';
198
+ return sendJson(res, 400, {
199
+ ok: false,
200
+ error: {
201
+ code: error && typeof error.code === 'string' && error.code ? error.code : 'internal',
202
+ message: hostMessage ? `默认预设没写进去:${hostMessage}` : '默认预设没写进去',
203
+ },
204
+ });
205
+ }
206
+ sendJson(res, 200, { ok: true, agentPreset: id.value });
207
+ }
208
+
146
209
  module.exports = {
147
210
  handleAgentPresetRead,
148
211
  handleAgentPresetCopy,
149
212
  handleAgentPresetRemove,
213
+ handleAgentPresetDefault,
150
214
  // 下面这些是给守卫测试直接驱动的纯函数(与 references.js 同做法)
151
215
  normalizeId,
152
216
  normalizeName,
@@ -159,4 +223,6 @@ module.exports = {
159
223
  PRESET_ID_MAX_CHARS,
160
224
  PRESET_NAME_MAX_CHARS,
161
225
  PRESET_BODY_MAX_CHARS,
226
+ PRESET_SETTINGS_NS,
227
+ PRESET_DEFAULT_FIELD,
162
228
  };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端调试轨迹的落盘口(`POST /api/mobile/v1/debug/trace`)。
5
+ *
6
+ * 为什么要有它:滑动手感出问题时,现场在手机上(偏移轨迹、每一行的实际高度、图片什么时候
7
+ * 才到),而能看这些东西的人在这台电脑上。让人把几万行日志抄进聊天不现实,所以 App 边录边把
8
+ * 一行行 JSONL 打到这里,插件落进 `<DSH_HOME>/mobile-debug/<capture>.jsonl`,维护者在这台
9
+ * 电脑上直接读文件。
10
+ *
11
+ * 三条边界,别放宽:
12
+ * - **不是任意文件写入**:文件名只由 `capture` 生成,且只认 `[A-Za-z0-9._-]`。构造一个别的
13
+ * capture 只会在这一个目录里多一份空文件,碰不到用户的任何东西;
14
+ * - **只追加**:一次请求往 `<capture>.jsonl` 末尾续一段,从不覆盖、从不删别的文件;
15
+ * - **封顶**:一份轨迹 8MB 之后停写(回 `capped: true`,App 收到就停手),目录里最多留 10 份,
16
+ * 第 11 份进来时删掉最旧那份。录音不会把盘写满。
17
+ *
18
+ * 与 `session/file`、`session/attachment` 那些读口方向相反:这条只写、写的位置固定,回给手机
19
+ * 的只有文件名与字节数,不回路径。
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const fsp = require('node:fs/promises');
24
+ const path = require('node:path');
25
+
26
+ const { dshHome } = require('../shared/paths');
27
+ const { sendJson, readJsonBody } = require('../shared/http-util');
28
+
29
+ /** 一份轨迹的字节上限。真到这一步说明录得太久,该停了。 */
30
+ const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
31
+ /** 目录里最多留几份(按 mtime,新的来、旧的走)。 */
32
+ const MAX_CAPTURES = 10;
33
+ /** 一次请求最多带多少行;App 那边按 40KB 切批,正常远小于这个数。 */
34
+ const MAX_LINES_PER_BATCH = 4000;
35
+ /** 一次请求最多多少字节(`readJsonBody` 本身还有 344KB 的封顶,这里再收一道)。 */
36
+ const MAX_BATCH_BYTES = 512 * 1024;
37
+ /** 轨迹名(也就是文件名):只认这几类字符,别的一律 400。 */
38
+ const CAPTURE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
39
+
40
+ /** 轨迹目录。按调用时解析,测试可注入 `DSH_HOME`。 */
41
+ function traceDir() {
42
+ return path.join(dshHome(), 'mobile-debug');
43
+ }
44
+
45
+ /** 某一份轨迹的落盘路径(`capture` 已经过白名单校验)。 */
46
+ function capturePath(capture) {
47
+ return path.join(traceDir(), `${capture}.jsonl`);
48
+ }
49
+
50
+ /**
51
+ * 目录里留最近 `MAX_CAPTURES` 份。
52
+ *
53
+ * 按 mtime 而不是文件名:轨迹名由 App 带时间戳生成,但同一秒的两份、或者用户手改过的名字都
54
+ * 不该让清理算错。读不动 stat 的(正在被删)直接跳过,清理失败不该让这次上传失败。
55
+ */
56
+ async function pruneCaptures() {
57
+ let names;
58
+ try {
59
+ names = await fsp.readdir(traceDir());
60
+ } catch {
61
+ return;
62
+ }
63
+ const files = [];
64
+ for (const name of names) {
65
+ if (!name.endsWith('.jsonl')) continue;
66
+ const full = path.join(traceDir(), name);
67
+ try {
68
+ const info = await fsp.stat(full);
69
+ if (info.isFile()) files.push({ name, full, mtime: info.mtimeMs });
70
+ } catch { /* 刚被删掉 / 读不动:跳过 */ }
71
+ }
72
+ if (files.length <= MAX_CAPTURES) return;
73
+ files.sort((a, b) => a.mtime - b.mtime);
74
+ for (const file of files.slice(0, files.length - MAX_CAPTURES)) {
75
+ try {
76
+ await fsp.unlink(file.full);
77
+ } catch { /* 删不掉就留着,下次再说 */ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 续写一段轨迹。
83
+ *
84
+ * `lines` 是已经序列化好的 JSON 行(App 侧拼的),这里只做「拼成一段文本 + 追加」。一行都
85
+ * 不合法的批次按空处理:回 ok、写 0 字节,别让手机那头因为一次形状不对就整段录不上。
86
+ */
87
+ async function handleDebugTrace(_ctx, req, res) {
88
+ const body = await readJsonBody(req);
89
+ const capture = body && typeof body.capture === 'string' ? body.capture : '';
90
+ if (!CAPTURE_RE.test(capture)) {
91
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { capture },且只认字母数字与 ._-' } });
92
+ }
93
+ const raw = body && Array.isArray(body.lines) ? body.lines : null;
94
+ if (!raw) {
95
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { lines: string[] }' } });
96
+ }
97
+ if (raw.length > MAX_LINES_PER_BATCH) {
98
+ return sendJson(res, 413, { ok: false, error: { code: 'too-many-lines', message: `一次最多 ${MAX_LINES_PER_BATCH} 行` } });
99
+ }
100
+ const lines = raw.filter((line) => typeof line === 'string' && line.length > 0);
101
+ if (lines.length === 0) {
102
+ return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: 0, lines: 0, capped: false });
103
+ }
104
+ const text = `${lines.join('\n')}\n`;
105
+ const bytes = Buffer.byteLength(text, 'utf8');
106
+ if (bytes > MAX_BATCH_BYTES) {
107
+ return sendJson(res, 413, { ok: false, error: { code: 'too-large', message: `一次最多 ${MAX_BATCH_BYTES} 字节` } });
108
+ }
109
+
110
+ const file = capturePath(capture);
111
+ try {
112
+ await fsp.mkdir(traceDir(), { recursive: true });
113
+ } catch (error) {
114
+ return sendJson(res, 500, { ok: false, error: { code: 'io', message: `建目录失败:${error && error.message ? error.message : ''}` } });
115
+ }
116
+
117
+ let existing = 0;
118
+ let created = false;
119
+ try {
120
+ existing = (await fsp.stat(file)).size;
121
+ } catch {
122
+ created = true;
123
+ }
124
+
125
+ if (existing >= MAX_CAPTURE_BYTES) {
126
+ // 已经写满:回 capped 让 App 停手。文件留着,人要看的正是前面那一段。
127
+ return sendJson(res, 200, { ok: true, file: `${capture}.jsonl`, bytes: existing, lines: 0, capped: true });
128
+ }
129
+ // 快写满时**按整行留**:切在字节中间会让最后一行变成半截 JSON,读的人只会看到一行坏数据。
130
+ const room = MAX_CAPTURE_BYTES - existing;
131
+ let payload = text;
132
+ if (bytes > room) {
133
+ const kept = [];
134
+ let used = 0;
135
+ for (const line of lines) {
136
+ const size = Buffer.byteLength(line, 'utf8') + 1;
137
+ if (used + size > room) break;
138
+ kept.push(line);
139
+ used += size;
140
+ }
141
+ payload = kept.length > 0 ? `${kept.join('\n')}\n` : '';
142
+ }
143
+ try {
144
+ await fsp.appendFile(file, payload);
145
+ } catch (error) {
146
+ return sendJson(res, 500, { ok: false, error: { code: 'io', message: `写盘失败:${error && error.message ? error.message : ''}` } });
147
+ }
148
+ // 清理放在写完之后:这一份刚建出来的文件按 mtime 是最新的,留下来的正是它。
149
+ if (created) await pruneCaptures();
150
+ let total = existing;
151
+ try {
152
+ total = (await fsp.stat(file)).size;
153
+ } catch { /* 写成功但 stat 失败:回一个估计值,不影响 App */ }
154
+ sendJson(res, 200, {
155
+ ok: true,
156
+ file: `${capture}.jsonl`,
157
+ bytes: total,
158
+ lines: payload === text ? lines.length : (payload ? payload.split('\n').length - 1 : 0),
159
+ // 「这一段被切了」或「已经顶到上限」都算满:App 收到就停手,别继续往一条死路上发。
160
+ capped: payload !== text || total >= MAX_CAPTURE_BYTES,
161
+ });
162
+ }
163
+
164
+ /** 同步读一份轨迹(维护者在电脑上直接看文件;这里只给测试与将来的面板用)。 */
165
+ function readCapture(capture) {
166
+ if (!CAPTURE_RE.test(capture)) return null;
167
+ try {
168
+ return fs.readFileSync(capturePath(capture), 'utf8');
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ handleDebugTrace,
176
+ readCapture,
177
+ traceDir,
178
+ MAX_CAPTURE_BYTES,
179
+ MAX_CAPTURES,
180
+ };
@@ -45,6 +45,9 @@ function normalizeProfile(result) {
45
45
  status: 'ready',
46
46
  name: textOrNull(profile.name),
47
47
  contact: textOrNull(profile.contact),
48
+ // 头像是资料的一部分(和名字、联系方式同级),手机拿去当 <Image> 的 uri 用;
49
+ // 账号 ID、token、attempt 仍然不出这一层。
50
+ avatarUrl: textOrNull(profile.avatarUrl),
48
51
  };
49
52
  }
50
53
 
@@ -15,9 +15,14 @@
15
15
  * 2000 不是随手取的上限:本机 1420 个真实回合量下来,最长一轮 576 条消息、超过 500 的
16
16
  * 有 3 个、超过 200 的有 18 个。它只是**上界**,宿主在跨过 `minTurns` 且遇到 turn/start
17
17
  * 时就会停,所以正常情况下读的仍是那几十条;只有碰上超长回合才会真往上读到 2000。
18
+ *
19
+ * `minTurns` 取 1,**不是 6**:进对话要的是「尾部这一屏」,不是「六轮上下文」。
20
+ * 取 6 的时候,一轮 40 步的会话会把窗口撑到一百多条,手机侧要铺十几屏才追得到底 ——
21
+ * 用户看到的是「进来之后内容一路往下滑」。窗口大小是这条链的总闸:窗口小,
22
+ * 「一屏就是终态」才成立;更早的内容由「加载更早」按需补。
18
23
  */
19
24
  const ENTRY_THIN_MAX_MESSAGES = 2000;
20
- const ENTRY_THIN_MIN_TURNS = 6;
25
+ const ENTRY_THIN_MIN_TURNS = 1;
21
26
 
22
27
  /** 把入口窗口的取数参数写进宿主请求体(就地修改并返回,调用方只关心副作用)。 */
23
28
  function entryWindowPayload(payload, limit) {