dsh-plugin-mobile-link 0.6.0 → 0.6.5

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/link.js CHANGED
@@ -20,10 +20,11 @@ import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl
20
20
  const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
21
21
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
22
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
+ import { LinkJournal, defaultJournalPath } from './journal.js'
23
24
  import { statusSnapshot } from './status.js'
24
25
  import { mintPairCode } from './pairing.js'
25
26
  import {
26
- DEFAULT_EVENTS_BACKLOG,
27
+ DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
27
28
  EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
28
29
  } from './router.js'
29
30
  import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
@@ -57,7 +58,14 @@ export class MobileLinkAgent extends EventEmitter {
57
58
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
58
59
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
59
60
  watchdogMs = 180000,
61
+ // 慢请求阈值(毫秒);0 = 关闭逐条慢请求记录。见 router.js 的 DEFAULT_SLOW_REQUEST_MS。
62
+ slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
63
+ // 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
64
+ diagnostics = true,
60
65
  inviteCode, fetchImpl = fetch, env = process.env,
66
+ // 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
67
+ // 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
68
+ journal, journalPath,
61
69
  // 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
62
70
  // 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
63
71
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
@@ -69,6 +77,8 @@ export class MobileLinkAgent extends EventEmitter {
69
77
  this.pongTimeoutMs = pongTimeoutMs
70
78
  this.maxBackoffMs = maxBackoffMs
71
79
  this.watchdogMs = watchdogMs
80
+ this.slowRequestMs = Number(slowRequestMs) || 0
81
+ this.diagnostics = diagnostics !== false
72
82
  this.random = random
73
83
  this.now = now
74
84
  this.connectImpl = connectImpl
@@ -79,13 +89,29 @@ export class MobileLinkAgent extends EventEmitter {
79
89
  //: to fix, not a transport problem to retry.
80
90
  this.needsEnroll = false
81
91
  this.dsh = dshClient ?? new DshClient({ explicitUrl: dshUrl, logger })
92
+ /** 生命周期流水:内存环 +(默认)一份 JSONL,见 journal.js 的文件头。 */
93
+ this.journal = journal ?? new LinkJournal({
94
+ path: journalPath === false
95
+ ? undefined
96
+ : (typeof journalPath === 'string' && journalPath ? journalPath : defaultJournalPath()),
97
+ now,
98
+ logger,
99
+ })
82
100
  this.router = new DeviceRouter({
83
101
  dsh: this.dsh,
84
102
  logger,
85
103
  now,
86
104
  protocolVersion: PROTOCOL_VERSION,
87
105
  eventsBacklog,
106
+ slowRequestMs: this.slowRequestMs,
107
+ diagnostics: this.diagnostics,
88
108
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
109
+ // 设备侧的 attach/detach 与链路侧同源记进流水:事后想把"手机断了"和
110
+ // "连接器拆链"对上时间,两边必须写在同一份文件里。
111
+ // `options.foreign` 表示这条事件用的是**别人的钟**(App 推上来的),必须原样保留时刻。
112
+ onEvent: (kind, detail, options) => (options?.foreign
113
+ ? this.journal.recordForeign(kind, detail)
114
+ : this.journal.record(kind, detail)),
89
115
  // 「跑完了 / 待你回应」到了就报一声(`{t:'notify'}`)。这是 agent 侧控制帧,
90
116
  // **不带 deviceId**:连接器不知道、也不需要知道这台电脑配了哪些手机,
91
117
  // 「该不该推给某台手机」由中转判(它手里才有"这台设备此刻在不在线")。
@@ -108,6 +134,16 @@ export class MobileLinkAgent extends EventEmitter {
108
134
  this.startedAt = undefined
109
135
  this.connectedAt = undefined
110
136
  this.reconnectAttempts = 0
137
+ /** 最近一次拆链的时刻(用来在 `link.open` 里报"距上次断开多久")。 */
138
+ this._lastClosedAt = undefined
139
+ /**
140
+ * socket 上最后一次系统错误(`onError` 的文本)。
141
+ *
142
+ * 为什么要专门留住它:链路被异常关闭时,`close` 回调只给一个代码(实测常见 1006,
143
+ * 含义是"没有关闭帧"),据此无法判断是网络、代理还是对端。系统错误文本是唯一线索,
144
+ * 而它以前只进了 debug 日志、不落流水(2026-10-13 现场诊断时卡在这里)。
145
+ */
146
+ this._socketError = undefined
111
147
 
112
148
  this._stopping = false
113
149
  this._sessionClosed = undefined
@@ -233,6 +269,15 @@ export class MobileLinkAgent extends EventEmitter {
233
269
  this._abort = new AbortController()
234
270
  this.startedAt = this.now()
235
271
  this.state = 'connecting'
272
+ this.journal.record('link.start', {
273
+ // 这里只能报**配置值**:真身份(含实际中转地址)要等 `#session()` 里的
274
+ // `resolveIdentity()` 才读到,那条记在 `link.open` 上。名字带 configured,
275
+ // 免得读者把占位符 `wss://relay.example.com/dsh-link` 当成真的在连的地址。
276
+ configuredRelay: this.config.relayUrl,
277
+ agentId: this.config.agentId || undefined,
278
+ attempt: this.reconnectAttempts,
279
+ host: this.hostForm,
280
+ })
236
281
  // 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
237
282
  void this.refreshLocal()
238
283
  this._lastProgressAt = this.now()
@@ -262,6 +307,7 @@ export class MobileLinkAgent extends EventEmitter {
262
307
  this._loop = undefined
263
308
  this.dsh.close()
264
309
  this.state = 'stopped'
310
+ this.journal.record('link.stop', {})
265
311
  this.emit('status')
266
312
  }
267
313
 
@@ -278,6 +324,7 @@ export class MobileLinkAgent extends EventEmitter {
278
324
  this.needsEnroll = true
279
325
  this.state = 'unregistered'
280
326
  this.lastError = messageOf(error)
327
+ this.journal.record('link.unregistered', { error: this.lastError })
281
328
  this.logger.error?.(this.lastError)
282
329
  this.emit('status')
283
330
  return
@@ -295,11 +342,13 @@ export class MobileLinkAgent extends EventEmitter {
295
342
  this.needsEnroll = true
296
343
  this.state = 'unregistered'
297
344
  this.lastError = messageOf(error)
345
+ this.journal.record('link.unregistered', { error: this.lastError })
298
346
  this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
299
347
  this.emit('status')
300
348
  return
301
349
  }
302
350
  this.lastError = messageOf(error)
351
+ this.journal.record('link.failed', { error: this.lastError, attempt: this.reconnectAttempts })
303
352
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
304
353
  }
305
354
  if (this._stopping) break
@@ -307,6 +356,7 @@ export class MobileLinkAgent extends EventEmitter {
307
356
  this.emit('status')
308
357
  const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
309
358
  this.reconnectAttempts += 1
359
+ this.journal.record('link.backoff', { attempt: this.reconnectAttempts, delayMs: Math.round(delay) })
310
360
  await sleep(delay, this._abort.signal)
311
361
  }
312
362
  this.state = 'stopped'
@@ -365,6 +415,12 @@ export class MobileLinkAgent extends EventEmitter {
365
415
  this.router.lastError = undefined
366
416
  this._lastPong = this.now()
367
417
  this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
418
+ this.journal.record('link.open', {
419
+ relay: this.relay?.wsBase,
420
+ agentId: identity.agentId,
421
+ attempt: this.reconnectAttempts,
422
+ sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
423
+ })
368
424
  this.emit('status')
369
425
 
370
426
  socket.onMessage((text) => {
@@ -373,7 +429,10 @@ export class MobileLinkAgent extends EventEmitter {
373
429
  .catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
374
430
  return this._inbox
375
431
  })
376
- socket.onError((error) => this.logger.debug?.(`mobile-link: relay socket error: ${messageOf(error)}`))
432
+ socket.onError((error) => {
433
+ this._socketError = messageOf(error)
434
+ this.logger.debug?.(`mobile-link: relay socket error: ${this._socketError}`)
435
+ })
377
436
  socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
378
437
 
379
438
  this.#startHeartbeat()
@@ -386,9 +445,13 @@ export class MobileLinkAgent extends EventEmitter {
386
445
  // 进程外工具(本机脚本、iOS 集成测试辅助)靠这份交接文件拿带凭据地址;
387
446
  // 写入失败不影响链路(`#writeHandoff` 自己吞异常)。
388
447
  await this.#writeHandoff()
448
+ this.journal.record('dsh.ok', { endpoint: this.dsh?.endpoint?.base })
389
449
  } catch (error) {
390
450
  this.dshError = messageOf(error)
391
451
  this.lastError = this.dshError
452
+ // 这一条很关键:本机 DSH 不可达时,手机那句 `_link/hello`/`sessions` 探针
453
+ // 必然超时,App 会据此重建整条链路——于是"连接器在、手机却连不上"。
454
+ this.journal.record('dsh.unreachable', { error: this.dshError, kind: this.dsh?.errorKind })
392
455
  this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
393
456
  }
394
457
  this.emit('status')
@@ -419,6 +482,7 @@ export class MobileLinkAgent extends EventEmitter {
419
482
  const idle = this.now() - this._lastProgressAt
420
483
  if (!this._loopActive) {
421
484
  this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
485
+ this.journal.record('watchdog.repull', { idleMs: Math.round(idle) })
422
486
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
423
487
  this._loop = undefined
424
488
  this._lastProgressAt = this.now()
@@ -427,6 +491,7 @@ export class MobileLinkAgent extends EventEmitter {
427
491
  }
428
492
  if (this.state === 'connected' && idle > this.pongTimeoutMs) {
429
493
  this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
494
+ this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
430
495
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
431
496
  this.#teardownSocket(this.lastError)
432
497
  this._lastProgressAt = this.now()
@@ -434,6 +499,7 @@ export class MobileLinkAgent extends EventEmitter {
434
499
  }
435
500
  if (this.state !== 'connected' && idle > this.watchdogMs) {
436
501
  this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
502
+ this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
437
503
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
438
504
  this.#teardownSocket(this.lastError)
439
505
  this._lastProgressAt = this.now()
@@ -469,6 +535,7 @@ export class MobileLinkAgent extends EventEmitter {
469
535
  #teardownSocket(reason) {
470
536
  this.#stopHeartbeat()
471
537
  const socket = this.socket
538
+ const from = this.state
472
539
  this.socket = undefined
473
540
  if (socket) {
474
541
  try {
@@ -483,6 +550,18 @@ export class MobileLinkAgent extends EventEmitter {
483
550
  this.dsh.close()
484
551
  if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
485
552
  this.lastError = this.lastError ?? reason
553
+ this._lastClosedAt = this.now()
554
+ // 「为什么拆的」是这一族事件唯一要回答的问题:reason 来自调用点,
555
+ // from 是拆之前的状态(connected / backoff / connecting),
556
+ // connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
557
+ this.journal.record('link.close', {
558
+ reason,
559
+ from,
560
+ connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
561
+ // 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
562
+ socketError: this._socketError,
563
+ })
564
+ this._socketError = undefined
486
565
  const resolve = this._sessionClosed
487
566
  this._sessionClosed = undefined
488
567
  this.emit('status')
@@ -492,10 +571,15 @@ export class MobileLinkAgent extends EventEmitter {
492
571
  // ── relay I/O ───────────────────────────────────────────────────────────
493
572
 
494
573
  #rawSend(frame) {
574
+ return this.#sendText(encodeFrame(frame))
575
+ }
576
+
577
+ /** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
578
+ #sendText(text) {
495
579
  const socket = this.socket
496
580
  if (!socket || socket.closed) return false
497
581
  try {
498
- socket.send(encodeFrame(frame))
582
+ socket.send(text)
499
583
  return true
500
584
  } catch (error) {
501
585
  this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
@@ -507,7 +591,10 @@ export class MobileLinkAgent extends EventEmitter {
507
591
  // `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
508
592
  // relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
509
593
  if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
510
- return this.#rawSend({ ...frame, deviceId })
594
+ const text = encodeFrame({ ...frame, deviceId })
595
+ // 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
596
+ this.router.noteSent?.(deviceId, Buffer.byteLength(text))
597
+ return this.#sendText(text)
511
598
  }
512
599
 
513
600
  sendHostStatus() {
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,8 @@ 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'
101
97
  const disposers = []
102
98
 
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
99
  disposers.push(ctx.webServer.register({
132
100
  kind: 'exact',
133
101
  path: STATUS_ROUTE,