dsh-plugin-mobile-link 0.6.0 → 0.6.6

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/router.js CHANGED
@@ -17,6 +17,7 @@ import { FileFetcher, FSGET_CANCEL } from './files-out.js'
17
17
  import { GitBridge, isGitMethod } from './git.js'
18
18
  import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
19
19
  import { isHistoryMethod } from './history.js'
20
+ import { trimRecordsForWire } from './history/records.js'
20
21
 
21
22
  /**
22
23
  * 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
@@ -75,6 +76,62 @@ export function errorObject(code, message, details = {}) {
75
76
  return { code, message, details }
76
77
  }
77
78
 
79
+ /**
80
+ * 旧分页通道(`session/page`)的下发形态清洗(016)。
81
+ *
82
+ * 这一路是**降级通道**:宿主自己回一页记录,连接器只是转发。它和精确窗口
83
+ * (`_link/sessionWindow`)必须守同一条口径 —— 只用于诊断的宿主模型输出日志
84
+ * 不下发(见 `specs/016-history-load-perf/contracts/history-record-trim.md`)。
85
+ *
86
+ * 两种形态都要认:宿主 RPC 的信封 `{ ok, value: { records } }`,以及直接给
87
+ * `{ records }` 的桩/测试形态。其余方法或结构一律原样返回,不做推断。
88
+ */
89
+ export function trimHistoryPageForWire(method, result) {
90
+ if (method !== 'session/page') return result
91
+ if (result === null || typeof result !== 'object') return result
92
+ const page = result.value ?? result
93
+ if (page === null || typeof page !== 'object' || !Array.isArray(page.records)) return result
94
+ const trimmed = { ...page, records: trimRecordsForWire(page.records) }
95
+ return result.value === undefined ? trimmed : { ...result, value: trimmed }
96
+ }
97
+
98
+ import { DeviceTimeline } from './diagnostics.js'
99
+ import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest } from './session-digest.js'
100
+
101
+ /** 慢请求阈值默认值(毫秒);0 = 关闭逐条慢请求记录。 */
102
+ export const DEFAULT_SLOW_REQUEST_MS = 2000
103
+
104
+ /** App 把自己那份阶段记录推上来的方法(契约 connector-diagnostics-method.md)。 */
105
+ export const DIAGNOSTICS_METHOD = '_link/diagnostics'
106
+ /** 单批上限:超过就截断并如实报 `accepted`,剩下的由 App 下次再推(水位不推进)。 */
107
+ export const DIAGNOSTICS_BATCH_BYTES = 32 * 1024
108
+ /** 单条事件里字符串字段的保留长度。 */
109
+ const DIAGNOSTICS_MAX_TEXT = 400
110
+
111
+ /**
112
+ * 只留标量(字符串/数字/布尔)与标量数组。
113
+ *
114
+ * **必须把 App 的 `detail` 摊平到顶层**:App 侧的 JSONL 信封是
115
+ * `{kind, seq, ms, at, detail:{...}}`(见 DSHKit 的 `LinkDiagnostics.Event`),而连接器
116
+ * 自己的流水是**扁平**的。不摊平的话 `phase`/`ms` 这些字段会在清洗时被当成嵌套对象丢掉——
117
+ * 实测症状:`app.dial` 落盘后一个字段都没有(2026-10-13 09:25 的中转运行)。
118
+ */
119
+ function sanitizeDiagnosticEvent(event) {
120
+ if (!event || typeof event !== 'object' || typeof event.kind !== 'string') return null
121
+ const { detail, ...envelope } = event
122
+ const flat = { ...envelope, ...(detail && typeof detail === 'object' ? detail : {}) }
123
+ const clean = { kind: event.kind }
124
+ for (const [key, value] of Object.entries(flat)) {
125
+ if (key === 'kind') continue
126
+ if (typeof value === 'string') clean[key] = value.slice(0, DIAGNOSTICS_MAX_TEXT)
127
+ else if (typeof value === 'number' || typeof value === 'boolean') clean[key] = value
128
+ else if (Array.isArray(value) && value.every((item) => typeof item === 'string' || typeof item === 'number')) {
129
+ clean[key] = value.slice(0, 32)
130
+ }
131
+ }
132
+ return clean
133
+ }
134
+
78
135
  export class DeviceRouter {
79
136
  /**
80
137
  * @param {object} options
@@ -84,13 +141,28 @@ export class DeviceRouter {
84
141
  */
85
142
  constructor({
86
143
  dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
87
- eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {},
144
+ eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {}, onEvent = () => {},
145
+ slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true,
88
146
  } = {}) {
89
147
  this.dsh = dsh
90
148
  this.eventsBacklog = eventsBacklog
149
+ this.slowRequestMs = Number(slowRequestMs) || 0
150
+ this.diagnosticsEnabled = diagnostics !== false
151
+ /** 每个 deviceId 当前这次接入的计时(attach 时建、detach 时收)。 */
152
+ this.timelines = new Map()
153
+ /**
154
+ * 当前这次请求已经发出去的字节数(在 `noteSent` 里累加、在请求收尾时取走)。
155
+ *
156
+ * 同一设备上并发请求会把字节算到一起——诊断只要量级对得上("这次响应是几 KB 还是几 MB"),
157
+ * 不值得为它引入逐请求关联(那要改所有回包点的形状)。
158
+ */
159
+ this._sentBytes = new Map()
91
160
  // 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
92
161
  // 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
93
162
  this.notify = notify
163
+ // 生命周期事件(设备 attach/detach 等)的去处,由 link.js 接到流水(journal.js)。
164
+ // 默认空函数,所以 router 单独用时(单测、本地脚本)不需要日志设施。
165
+ this.onEvent = onEvent
94
166
  // Reported to the app so it can tell which wire revision it is talking to.
95
167
  this.protocolVersion = protocolVersion
96
168
  this.send = send
@@ -156,9 +228,12 @@ export class DeviceRouter {
156
228
  return
157
229
  }
158
230
  switch (frame.t) {
159
- case 'req':
231
+ case 'req': {
232
+ const startedAt = this.now()
160
233
  await this.handleRequest(deviceId, frame)
234
+ this.#noteRequest(deviceId, frame.method, startedAt)
161
235
  return
236
+ }
162
237
  case 'open':
163
238
  await this.handleOpen(deviceId, frame)
164
239
  return
@@ -259,16 +334,117 @@ export class DeviceRouter {
259
334
 
260
335
  // ── device lifecycle ────────────────────────────────────────────────────
261
336
 
337
+ /**
338
+ * 记一条请求的耗时与字节(spec 020 的 FR-001–FR-003)。
339
+ *
340
+ * `bytes` 取"这次请求期间发出去的字节"(见 `noteSent`)——它是一个近似值,
341
+ * 只在同一设备上并发请求时才会串味,而诊断要回答的是"这次响应是几 KB 还是几 MB"。
342
+ */
343
+ #noteRequest(deviceId, method, startedAt) {
344
+ const timeline = this.timelines.get(deviceId)
345
+ if (!timeline) return
346
+ const bytes = this._sentBytes.get(deviceId) ?? 0
347
+ this._sentBytes.set(deviceId, 0)
348
+ timeline.noteRequest({ method, startedAt, ms: this.now() - startedAt, bytes })
349
+ }
350
+
351
+ /** 每发一帧给某台设备就累加它的字节数(由 `link.js` 的 `sendToDevice` 调用)。 */
352
+ noteSent(deviceId, bytes) {
353
+ if (!deviceId || !Number.isFinite(bytes)) return
354
+ this._sentBytes.set(deviceId, (this._sentBytes.get(deviceId) ?? 0) + bytes)
355
+ }
356
+
357
+ /**
358
+ * 收下 App 推来的阶段记录(spec 020 的 FR-011 / 契约 §2)。
359
+ *
360
+ * 三条硬规则:
361
+ * 1. **时刻照抄**:App 事件的 `at`/`ms` 是它自己钟上的读数,连接器不许改写
362
+ * (否则"手机侧几点了"这条信息就没了,而它正是区分"App 挂起"与"拨号卡住"的依据);
363
+ * 2. **按序号幂等**:同一批重推不会写两遍(水位记在设备记录上);
364
+ * 3. **超批上限就截断**:只收前若干条并如实报 `accepted`/`lastSeq`,剩下的下次再推——
365
+ * 绝不假装收全了。
366
+ *
367
+ * @returns {{ok: true, value: {accepted: number, lastSeq: number}}}
368
+ */
369
+ acceptDiagnostics(deviceId, args = {}) {
370
+ if (!this.diagnosticsEnabled) {
371
+ // 回退闸:关掉时明确拒绝,手机据此静默忽略(不影响连接)。
372
+ return { ok: false, error: errorObject('diagnostics/disabled', 'diagnostics intake is disabled on this connector') }
373
+ }
374
+ const record = this.devices.get(deviceId)
375
+ const watermark = Number(record?.diagnosticsSeq) || 0
376
+ const events = Array.isArray(args.events) ? args.events : []
377
+ let accepted = 0
378
+ let lastSeq = watermark
379
+ let bytes = 0
380
+ for (const raw of events) {
381
+ const event = sanitizeDiagnosticEvent(raw)
382
+ if (!event) continue
383
+ const seq = Number(event.seq)
384
+ if (Number.isFinite(seq) && seq <= watermark) continue
385
+ const size = JSON.stringify(event).length
386
+ if (bytes > 0 && bytes + size > DIAGNOSTICS_BATCH_BYTES) break
387
+ // 用 recordForeign:App 的时刻照抄(它是"手机侧几点"的唯一来源)。
388
+ // 补 `deviceId`:App 自己不知道它的 id,而多设备时没有这一列就没法把事件归到某台手机上。
389
+ this.onEvent(event.kind, { ...event, deviceId, source: 'app' }, { foreign: true })
390
+ bytes += size
391
+ accepted += 1
392
+ if (Number.isFinite(seq)) lastSeq = Math.max(lastSeq, seq)
393
+ }
394
+ if (record) record.diagnosticsSeq = lastSeq
395
+ const sentAt = Number(args.sentAt)
396
+ this.onEvent('app.push', {
397
+ deviceId,
398
+ count: accepted,
399
+ dropped: Number(args.dropped) || 0,
400
+ sentAt: Number.isFinite(sentAt) ? sentAt : undefined,
401
+ // 手机时钟与本机时钟的差(单向延迟几十毫秒);采集侧的对齐就靠这一个数。
402
+ skewMs: Number.isFinite(sentAt) ? this.now() - sentAt : undefined,
403
+ })
404
+ return { ok: true, value: { accepted, lastSeq } }
405
+ }
406
+
407
+ /** 起一次新的接入计时(回头客也重起:那确实是一次新连接)。 */
408
+ #startTimeline(deviceId) {
409
+ this.timelines.get(deviceId)?.finish()
410
+ this.timelines.set(deviceId, new DeviceTimeline({
411
+ deviceId,
412
+ slowRequestMs: this.slowRequestMs,
413
+ now: this.now,
414
+ onEvent: this.onEvent,
415
+ }))
416
+ this._sentBytes.set(deviceId, 0)
417
+ }
418
+
419
+ /** 收掉一次接入计时(断开、撤销、进程收尾都走这里)。 */
420
+ #finishTimeline(deviceId) {
421
+ const timeline = this.timelines.get(deviceId)
422
+ if (!timeline) return
423
+ timeline.finish()
424
+ this.timelines.delete(deviceId)
425
+ this._sentBytes.delete(deviceId)
426
+ }
427
+
262
428
  async attachDevice(deviceId, info = {}) {
263
429
  if (!nonEmptyString(deviceId)) return
430
+ this.#startTimeline(deviceId)
264
431
  const existing = this.devices.get(deviceId)
265
432
  if (existing) {
266
433
  // 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
267
434
  if (existing.offlineSince !== undefined) {
435
+ const offlineForMs = this.now() - existing.offlineSince
268
436
  existing.offlineSince = undefined
437
+ this.onEvent('device.reattach', { deviceId, offlineForMs, pending: existing.eventsPending.size })
269
438
  this.logger.info?.(
270
439
  `mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
271
440
  this.#changed()
441
+ } else {
442
+ // **这条是"同一台设备同时又开了一条"**:中转的 `attach_device` 会直接覆盖旧
443
+ // 链路,旧的那条既不会再收到 `deviceDetach`、也不会出现在任何一方的状态里
444
+ // (2026-10-13 01:10 实测:attach 与上一条 detach 之间空了 34 秒)。记下来,
445
+ // 否则事后只看得到"它连上了",看不到"它连了两条"。
446
+ this.onEvent('device.attach-again', { deviceId, connectedForMs: this.now() - existing.connectedAt })
447
+ this.logger.warn?.(`mobile-link: device ${deviceId} attached again while still attached`)
272
448
  }
273
449
  return
274
450
  }
@@ -288,6 +464,7 @@ export class DeviceRouter {
288
464
  offlineSince: undefined,
289
465
  eventsPending: new Map(),
290
466
  })
467
+ this.onEvent('device.attach', { deviceId, name: info?.name, model: info?.model })
291
468
  this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
292
469
  this.#changed()
293
470
  await this.ensureEventsStream(deviceId)
@@ -304,10 +481,12 @@ export class DeviceRouter {
304
481
  * 那段的说明)。这也是「撤销即放下」是唯一出口的原因。
305
482
  */
306
483
  async detachDevice(deviceId, reason) {
484
+ this.#finishTimeline(deviceId)
307
485
  const record = this.devices.get(deviceId)
308
486
  if (!record) return
309
487
  if (reason === 'revoked') {
310
488
  await this.#dropDevice(deviceId)
489
+ this.onEvent('device.revoked', { deviceId })
311
490
  this.logger.info?.(`mobile-link: device ${deviceId} 已被撤销,放下(不再替它值班)`)
312
491
  return
313
492
  }
@@ -326,6 +505,7 @@ export class DeviceRouter {
326
505
  for (const entry of keep) record.dlpIds.add(entry.dlpId)
327
506
 
328
507
  if (record.offlineSince === undefined) record.offlineSince = this.now()
508
+ this.onEvent('device.detach', { deviceId, reason: reason ?? 'closed' })
329
509
  this.logger.info?.(
330
510
  `mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
331
511
  + '$events 流一直替它留着,直到设备被撤销或进程退出')
@@ -366,16 +546,48 @@ export class DeviceRouter {
366
546
  teardownAll() {
367
547
  const muxIds = []
368
548
  for (const deviceId of [...this.devices.keys()]) {
549
+ // 进程收尾/链路重建也要把这次接入的汇总写掉,否则"连上过、发过请求"这件事没有数字。
550
+ this.#finishTimeline(deviceId)
369
551
  muxIds.push(...this.streams.removeDevice(deviceId))
370
552
  this.devices.delete(deviceId)
371
553
  }
372
554
  this.streams = new StreamTable()
555
+ // 不该有残留:上面的循环已经逐个收过;这里兜底,防止 attach 之后没进 devices 的路径留下脏项。
556
+ this.timelines.clear()
557
+ this._sentBytes.clear()
373
558
  return muxIds
374
559
  }
375
560
 
376
561
  // ── device requests ─────────────────────────────────────────────────────
377
562
 
378
563
  async handleRequest(deviceId, frame) {
564
+ // App 推上来的阶段记录:**只落盘,不影响任何连接行为**(契约
565
+ // connector-diagnostics-method.md §4)。放在最前面,免得被后面的方法族顺序影响。
566
+ // 精简版会话列表(spec 021):同一份宿主数据,去掉 App 侧零读取点的字段,可按要求 gzip。
567
+ // 老方法 `session/list` 一个字节都不改——老版本手机继续走它,这是兼容的前提。
568
+ if (frame.method === SESSION_DIGEST_METHOD) {
569
+ const args = frame.args ?? {}
570
+ let result
571
+ try {
572
+ result = await this.dsh.rpc('session/list', { _request: args._request ?? {} })
573
+ } catch (error) {
574
+ result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
575
+ this.lastError = messageOf(error)
576
+ this.#changed()
577
+ }
578
+ const payload = result?.ok === true
579
+ ? { ok: true, value: encodeDigest(digestSessionList(result), args.encoding) }
580
+ : result
581
+ this.send(deviceId, resultFrame(frame.id, payload, deviceId))
582
+ return
583
+ }
584
+
585
+ if (frame.method === DIAGNOSTICS_METHOD) {
586
+ const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
587
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
588
+ return
589
+ }
590
+
379
591
  // Files the phone sends are answered here rather than forwarded: the Host
380
592
  // has no upload endpoint, and carrying them on the existing unary frame
381
593
  // means neither the relay nor the protocol has to change.
@@ -458,6 +670,7 @@ export class DeviceRouter {
458
670
  let result
459
671
  try {
460
672
  result = await this.dsh.rpc(frame.method, frame.args ?? {})
673
+ result = trimHistoryPageForWire(frame.method, result)
461
674
  } catch (error) {
462
675
  result = { ok: false, error: errorObject('host/unavailable', messageOf(error)) }
463
676
  this.lastError = messageOf(error)
package/lib/routes.js CHANGED
@@ -94,40 +94,9 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
94
94
  const guard = makeGuard(ctx, logger)
95
95
  const STATUS_ROUTE = '/mobile-link/status'
96
96
  const PAIR_ROUTE = '/mobile-link/pair-code'
97
- /// SPIKE(spec 007 / T008):只读测量宿主窗口与检索 API 的语义。
98
- /// 默认关闭,只有 `DSH_MOBILE_LINK_HISTORY_PROBE=1` 时注册——它是一次性测量工具,
99
- /// 不是产品面(结论已写回 specs/007-transcript-history/plan.md 的 Phase 0)。
100
- const PROBE_ROUTE = '/mobile-link/history-probe'
97
+ const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
101
98
  const disposers = []
102
99
 
103
- if (history !== undefined && process.env.DSH_MOBILE_LINK_HISTORY_PROBE === '1') {
104
- disposers.push(ctx.webServer.register({
105
- kind: 'exact',
106
- path: PROBE_ROUTE,
107
- handler: async (req, res) => {
108
- if (guard(req, res)) return
109
- if (req.method !== 'GET') {
110
- res.statusCode = 405
111
- res.setHeader('allow', 'GET')
112
- res.end()
113
- return
114
- }
115
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
116
- try {
117
- const result = await history.probe({
118
- sessionId: url.searchParams.get('sessionId') ?? '',
119
- seq: url.searchParams.get('seq') ?? undefined,
120
- n: url.searchParams.get('n') ?? undefined,
121
- text: url.searchParams.get('text') ?? undefined,
122
- })
123
- sendJson(res, 200, result)
124
- } catch (error) {
125
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
126
- }
127
- },
128
- }))
129
- }
130
-
131
100
  disposers.push(ctx.webServer.register({
132
101
  kind: 'exact',
133
102
  path: STATUS_ROUTE,
@@ -163,10 +132,14 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
163
132
  sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
164
133
  return
165
134
  }
166
- const ttlMs = ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000)
135
+ // 032:调用方**没提** ttlMs 就要一张持久码(面板走这条路);显式给了才限时,
136
+ // 那是老行为,留给还在用的调用方。
137
+ const wantsTtl = Object.prototype.hasOwnProperty.call(body, 'ttlMs')
138
+ const ttlMs = wantsTtl ? ttlFrom(body.ttlMs, Number(config.pairTtlMs) || 10 * 60 * 1000) : null
167
139
  try {
168
140
  const minted = await agent.mintPairCode({ ttlMs })
169
- logger.info?.(`mobile-link: minted a pairing code for ${minted.agentId}`)
141
+ logger.info?.(`mobile-link: minted a ${minted.persistent ? 'persistent' : 'time-limited'}`
142
+ + ` pairing code for ${minted.agentId}`)
170
143
  // The picture travels with the code. It used to be a route of its own
171
144
  // (`GET /mobile-link/qr`) that a person opened in a browser; the pairing
172
145
  // entry is now the connector's own panel in the DSH client, so the one
@@ -180,5 +153,32 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
180
153
  },
181
154
  }))
182
155
 
156
+ disposers.push(ctx.webServer.register({
157
+ kind: 'exact',
158
+ path: REGENERATE_ROUTE,
159
+ handler: async (req, res) => {
160
+ if (guard(req, res, { requireFence: true })) return
161
+ if (req.method !== 'POST') {
162
+ res.statusCode = 405
163
+ res.setHeader('allow', 'POST')
164
+ res.end()
165
+ return
166
+ }
167
+ const body = await readJsonBody(req)
168
+ if (body === undefined) {
169
+ sendJson(res, 400, { ok: false, message: 'invalid JSON body' })
170
+ return
171
+ }
172
+ try {
173
+ const minted = await agent.regeneratePairCode()
174
+ logger.info?.(`mobile-link: regenerated the pairing code for ${minted.agentId}`
175
+ + ` (revoked ${minted.revoked})`)
176
+ sendJson(res, 200, { ok: true, ...minted, svg: qrSvg(minted.qrPayload) })
177
+ } catch (error) {
178
+ sendJson(res, 502, { ok: false, code: 'relay/unavailable', message: String(error?.message ?? error) })
179
+ }
180
+ },
181
+ }))
182
+
183
183
  return disposers
184
184
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `_link/sessionDigest`:`session/list` 的精简同形副本(spec 021)。
3
+ *
4
+ * 为什么要有它:`session/list` 占经中转下行字节的 **96%**,一次固定 714 KB,而中转出口实测只有
5
+ * 16–112 KB/s(2026-10-13 现场诊断)。其中 **58.8% 的字节是 App/DSHKit 从来不读的字段**
6
+ * (逐字段扫 79 个源文件核对,清单见 `contracts/session-digest.md` §4)。
7
+ *
8
+ * 三条硬约束:
9
+ * 1. **不动 `session/list`**:老 App 只认它,字段删掉就没有回退路径。
10
+ * 2. **不改入参**:宿主返回什么就基于它生成新对象,方便"同一份数据两条路径"逐字节对比。
11
+ * 3. **只删名单里的**:名单之外的字段(包括以后宿主新增的)一律原样透传,避免"裁剪"变成"猜"。
12
+ */
13
+
14
+ import { deflateRawSync } from 'node:zlib'
15
+
16
+ /** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
17
+ export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
18
+
19
+ /** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
20
+ export const DROPPED_ITEM_KEYS = Object.freeze(['agentAvailable'])
21
+
22
+ /**
23
+ * 投影键里剔掉的字段(合计 56.7%)。
24
+ *
25
+ * 每一条都能在 App/DSHKit 里复查"零读取点":
26
+ * `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
27
+ * `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
28
+ * `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
29
+ * `subagentTiming` 1.5%、`agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
30
+ */
31
+ export const DROPPED_PROJECTION_KEYS = Object.freeze([
32
+ 'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
33
+ 'contextBreakdown', 'sessionListMetadata', 'subagentTiming', 'agentPreset',
34
+ ])
35
+
36
+ /**
37
+ * 只接受的压缩编码;其它一律按不压缩处理(保守,不因为多写一个字段就让调用方失败)。
38
+ *
39
+ * `deflate-raw` = RFC1951 原始 DEFLATE,**不带** gzip/zlib 包装。选它而不是 gzip 的原因是
40
+ * 手机侧:Apple 的 `Compression` 框架(`COMPRESSION_ZLIB`)一行 `compression_decode_buffer`
41
+ * 就能解开原始 DEFLATE,而 gzip 包装它不认,得引 libz 的 `inflateInit2(MAX_WBITS+16)` 做 C 互操作。
42
+ * 两者压缩比实测相同(239 KiB → 36.8 KiB)。
43
+ */
44
+ export const DEFLATE_ENCODING = 'deflate-raw'
45
+ export const IDENTITY_ENCODING = 'identity'
46
+
47
+ /** 按原键序复制并跳过剔除名单:键序稳定才能做"逐字节对比"。 */
48
+ function omit(source, dropped) {
49
+ const out = {}
50
+ for (const key of Object.keys(source)) {
51
+ if (dropped.has(key)) continue
52
+ out[key] = source[key]
53
+ }
54
+ return out
55
+ }
56
+
57
+ /**
58
+ * 裁剪一份会话列表的**值**(`{ items: [...] }`)。
59
+ *
60
+ * 认两种输入(与 `trimHistoryPageForWire` 同款):宿主信封 `{ ok, value: { items } }`,
61
+ * 以及直接给的 `{ items }`。**返回的始终是列表值本身**,调用方不需要再猜信封在哪一层
62
+ * (曾经因为这里保留信封、外面又包一层,响应成了 `{encoding, value:{ok, value:{items}}}`)。
63
+ *
64
+ * 结构不是预期的样子就**原样返回入参**——裁剪不许把"看不懂"变成"删掉"。
65
+ *
66
+ * @param {unknown} result 宿主响应或其 `value`
67
+ * @returns {unknown} 裁剪后的列表值;不合法时原样返回入参
68
+ */
69
+ export function digestSessionList(result) {
70
+ if (result === null || typeof result !== 'object') return result
71
+ const container = Array.isArray(result.items) ? result : result.value
72
+ if (container === null || typeof container !== 'object' || !Array.isArray(container.items)) {
73
+ return result
74
+ }
75
+ return { ...container, items: container.items.map(digestItem) }
76
+ }
77
+
78
+ /** 单条会话:剔顶层字段与投影键;`projections` 缺失或形状不对就原样留着。 */
79
+ function digestItem(item) {
80
+ if (item === null || typeof item !== 'object') return item
81
+ const out = omit(item, new Set(DROPPED_ITEM_KEYS))
82
+ const projections = item.projections
83
+ if (projections === null || typeof projections !== 'object') return out
84
+ // `kind: "sequenced"` 是信封常量,App 侧模型没解码它(1.3%)。
85
+ const envelope = omit(projections, new Set(['kind']))
86
+ const values = projections.values
87
+ if (values !== null && typeof values === 'object') {
88
+ envelope.values = omit(values, new Set(DROPPED_PROJECTION_KEYS))
89
+ }
90
+ out.projections = envelope
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * 按调用方要求编码裁剪后的值。
96
+ *
97
+ * **只在应用层压**:中转的 `WebSocketResponse(compress=False)`,手机侧
98
+ * `URLSessionWebSocketTask` 也不支持 permessage-deflate,所以 WS 层压缩救不了手机那一跳。
99
+ * 压缩后的字节 base64 放进 JSON 文本帧(与 `_link/diagnostics` 同款),中转不需要任何改动。
100
+ *
101
+ * `rawBytes` 是**解压后的字节长度**:Apple 的 `compression_decode_buffer` 必须预先给足输出缓冲区,
102
+ * 带上它调用方就能一次分配到位,不必"猜容量、失败再翻倍重试"。
103
+ *
104
+ * @param {unknown} value 裁剪后的值
105
+ * @param {unknown} encoding 调用方要求;只认 `"deflate-raw"`
106
+ * @returns {{encoding: string, value: unknown, rawBytes?: number}} 带回执,调用方据此决定解不解压
107
+ */
108
+ export function encodeDigest(value, encoding) {
109
+ if (encoding !== DEFLATE_ENCODING) return { encoding: IDENTITY_ENCODING, value }
110
+ const json = JSON.stringify(value ?? null)
111
+ const raw = Buffer.from(json, 'utf8')
112
+ return {
113
+ encoding: DEFLATE_ENCODING,
114
+ value: deflateRawSync(raw).toString('base64'),
115
+ rawBytes: raw.length,
116
+ }
117
+ }
package/lib/status.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
7
7
  */
8
8
 
9
- import { SERVER_CAPABILITIES, SERVER_VERSION } from './hello.js'
9
+ import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
10
10
 
11
11
  const PROTOCOL_VERSION = 1
12
12
 
@@ -33,8 +33,7 @@ export const HOST_HINTS = {
33
33
  unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', docAnchor: '排查:连不上时看这里' },
34
34
  }
35
35
 
36
- /** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */
37
- function dshBlock(agent) {
36
+ /** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */function dshBlock(agent) {
38
37
  const errorKind = agent.dshError ? (agent.dsh?.errorKind ?? 'unreachable') : null
39
38
  const entry = errorKind ? DSH_HINTS[errorKind] : undefined
40
39
  return {
@@ -51,6 +50,31 @@ function dshBlock(agent) {
51
50
  }
52
51
  }
53
52
 
53
+ /**
54
+ * 生命周期流水的读法(只增字段)。
55
+ *
56
+ * 为什么状态接口要给这一块:`lastError` 只有最后一条,而"手机为什么连不上"往往要
57
+ * 看**之前**发生过什么(拆了几次链、每次什么原因、设备是不是同时开了两条)。
58
+ *
59
+ * 读法要说清楚:`recent` / `recorded` / `dropped` 都是**当前这个实例**的(热更会换实例,
60
+ * 换完这几个数从零开始);跨实例的完整历史只在 `path` 指的 JSONL 文件里——两者互补,
61
+ * 别拿 `recorded` 当"这台机器一共连过几次"。没有流水对象(老调用方、单测的假 agent)
62
+ * 时给空块,不是 `undefined`——读者不用做判空。
63
+ */
64
+ function journalBlock(agent) {
65
+ const journal = agent.journal
66
+ return {
67
+ path: journal?.path ?? null,
68
+ recorded: journal?.recorded ?? 0,
69
+ dropped: journal?.dropped ?? 0,
70
+ fileErrors: journal?.fileErrors ?? 0,
71
+ recent: journal?.recent?.(STATUS_JOURNAL_ENTRIES) ?? [],
72
+ }
73
+ }
74
+
75
+ /** `/status` 里随每次轮询带出的流水条数。完整历史读 `journal.path` 那个文件。 */
76
+ const STATUS_JOURNAL_ENTRIES = 20
77
+
54
78
  /**
55
79
  * @param {import('./link.js').MobileLinkAgent} agent
56
80
  * @returns {Record<string, unknown>} 可直接序列化给 `/status` 的对象
@@ -69,6 +93,9 @@ export function statusSnapshot(agent) {
69
93
  // Same facts as `_link/hello`, which is the channel the app actually uses:
70
94
  // this route only exists on the direct path.
71
95
  serverVersion: SERVER_VERSION,
96
+ // 宿主 DSH 的版本(`null` = 这个宿主没把版本放进环境)。与 `serverVersion`
97
+ // 是两个命名空间:前者是连接器,后者是跑它的 DSH。
98
+ hostVersion: hostVersion(),
72
99
  capabilities: SERVER_CAPABILITIES,
73
100
  enroll: {
74
101
  // A phone (or the user's browser) can read this to find out that the
@@ -97,5 +124,7 @@ export function statusSnapshot(agent) {
97
124
  startedAt: agent.startedAt ?? null,
98
125
  connectedAt: agent.connectedAt ?? null,
99
126
  reconnectAttempts: agent.reconnectAttempts,
127
+ // 新增(只增不改):生命周期流水的摘要与最近若干条,见上面的 journalBlock。
128
+ journal: journalBlock(agent),
100
129
  }
101
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.6.0",
3
+ "version": "0.6.6",
4
4
  "description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
5
5
  "keywords": [
6
6
  "dsh",