dsh-plugin-mobile-link 0.3.0 → 0.3.1

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/dlp.js CHANGED
@@ -190,6 +190,15 @@ export class StreamTable {
190
190
  return this.byDlp.get(StreamTable.key(deviceId, dlpId))
191
191
  }
192
192
 
193
+ /** Every open entry for one device, in insertion order. */
194
+ byDevice(deviceId) {
195
+ const entries = []
196
+ for (const entry of this.byMux.values()) {
197
+ if (entry.deviceId === deviceId) entries.push(entry)
198
+ }
199
+ return entries
200
+ }
201
+
193
202
  entryOfMux(muxId) {
194
203
  return this.byMux.get(muxId)
195
204
  }
package/lib/link.js CHANGED
@@ -10,7 +10,10 @@ import { DshClient } from './dsh-client.js'
10
10
  import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
11
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
12
12
  import { mintPairCode } from './pairing.js'
13
- import { EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf } from './router.js'
13
+ import {
14
+ DEFAULT_EVENTS_BACKLOG, DEFAULT_EVENTS_GRACE_MS,
15
+ EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
16
+ } from './router.js'
14
17
  import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
15
18
  import { MissingIdentity, resolveIdentity } from './state.js'
16
19
  import { connect as wsConnect } from './ws.js'
@@ -28,12 +31,24 @@ function sleep(ms, signal) {
28
31
  })
29
32
  }
30
33
 
34
+ /** Read a positive number from the environment; anything else falls back. */
35
+ function numberFromEnv(raw, fallback) {
36
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback
37
+ const value = Number(raw)
38
+ return Number.isFinite(value) && value >= 0 ? value : fallback
39
+ }
40
+
31
41
  export class MobileLinkAgent extends EventEmitter {
32
42
  constructor({
33
43
  relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile,
34
44
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
35
45
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
36
46
  inviteCode, fetchImpl = fetch, env = process.env,
47
+ // 「手机不在时替它留着 $events 流」的宽限期与缓冲上限(见 router.js 的说明);
48
+ // 环境变量给部署用,构造参数给测试用。
49
+ eventsGraceMs = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_GRACE_MS, DEFAULT_EVENTS_GRACE_MS),
50
+ eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
51
+ setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
37
52
  } = {}) {
38
53
  super()
39
54
  this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile, inviteCode }
@@ -56,6 +71,10 @@ export class MobileLinkAgent extends EventEmitter {
56
71
  logger,
57
72
  now,
58
73
  protocolVersion: PROTOCOL_VERSION,
74
+ eventsGraceMs,
75
+ eventsBacklog,
76
+ setTimeoutFn,
77
+ clearTimeoutFn,
59
78
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
60
79
  onChange: () => this.emit('status'),
61
80
  })
package/lib/router.js CHANGED
@@ -19,6 +19,33 @@ import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
19
19
  export const EVENTS_ENDPOINT = '$events'
20
20
  export const EVENTS_RESULT = '$events/result'
21
21
 
22
+ /**
23
+ * 手机断了之后,这条 `$events` 流还替它留多久(毫秒)。
24
+ *
25
+ * 为什么需要:手机是唯一客户端时,它一断,DSH 那边就没人持有这条流了——
26
+ * 提问(waterfall)会随 Agent Context 释放被撤掉,用户回到手机再也看不到
27
+ * (根因见 maintainers/PENDING-WATERFALL-LOSS.md)。留着流就等于"代手机值班":
28
+ * 这期间来的提问先攒着,手机回来补发。
29
+ *
30
+ * 代价是这段时间里 DSH 认为"设备还在",会话的 Agent Context 不会因此释放;
31
+ * 所以必须有上限,不能无限留。
32
+ */
33
+ export const DEFAULT_EVENTS_GRACE_MS = 15 * 60 * 1000
34
+
35
+ /** 离线期间最多替一台设备攒多少条 `$events` 项(只攒 waterfall/cancel)。 */
36
+ export const DEFAULT_EVENTS_BACKLOG = 50
37
+
38
+ /**
39
+ * 手机不在时要攒下来的 `$events` 项:只有"待回答的提问"和"提问作废"两种。
40
+ *
41
+ * 别的一律不攒——emit 类的都是"某事刚发生"的通知,几分钟后补发只会让 App
42
+ * 对着旧事件发通知(例如一条早就结束的 turn)。
43
+ */
44
+ export function isBufferedEvent(value) {
45
+ if (!value || typeof value !== 'object') return false
46
+ return value.type === 'waterfall' || value.type === 'cancel'
47
+ }
48
+
22
49
  export function messageOf(error) {
23
50
  return error instanceof Error ? error.message : String(error)
24
51
  }
@@ -34,8 +61,16 @@ export class DeviceRouter {
34
61
  * @param {(deviceId: string, frame: object) => boolean} options.send deliver one frame to one device
35
62
  * @param {() => void} [options.onChange] called whenever the status snapshot changes
36
63
  */
37
- constructor({ dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1 } = {}) {
64
+ constructor({
65
+ dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
66
+ eventsGraceMs = DEFAULT_EVENTS_GRACE_MS, eventsBacklog = DEFAULT_EVENTS_BACKLOG,
67
+ setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
68
+ } = {}) {
38
69
  this.dsh = dsh
70
+ this.eventsGraceMs = eventsGraceMs
71
+ this.eventsBacklog = eventsBacklog
72
+ this.setTimeoutFn = setTimeoutFn
73
+ this.clearTimeoutFn = clearTimeoutFn
39
74
  // Reported to the app so it can tell which wire revision it is talking to.
40
75
  this.protocolVersion = protocolVersion
41
76
  this.send = send
@@ -100,7 +135,20 @@ export class DeviceRouter {
100
135
  // ── device lifecycle ────────────────────────────────────────────────────
101
136
 
102
137
  async attachDevice(deviceId, info = {}) {
103
- if (!nonEmptyString(deviceId) || this.devices.has(deviceId)) return
138
+ if (!nonEmptyString(deviceId)) return
139
+ const existing = this.devices.get(deviceId)
140
+ if (existing) {
141
+ // 回头客:宽限期内又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
142
+ if (existing.offlineSince !== undefined) {
143
+ this.clearTimeoutFn(existing.offlineTimer)
144
+ existing.offlineTimer = undefined
145
+ existing.offlineSince = undefined
146
+ this.logger.info?.(
147
+ `mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
148
+ this.#changed()
149
+ }
150
+ return
151
+ }
104
152
  this.devices.set(deviceId, {
105
153
  deviceId,
106
154
  name: info?.name,
@@ -110,6 +158,13 @@ export class DeviceRouter {
110
158
  readyValue: undefined,
111
159
  eventsMuxId: undefined,
112
160
  dlpIds: new Set(),
161
+ // 手机不在时的"值班"状态:offlineSince 有值 = 这条流在替它留着;
162
+ // eventsPending 是**还没被回答**的提问(按 eventId 存)。
163
+ // 注意它不是一个"送一次就清空"的队列:送过也要留着,否则 App 再重启一次
164
+ // (或崩一次)这条提问就永远丢了——2026-09-22 验收时正是这么发现的。
165
+ offlineSince: undefined,
166
+ offlineTimer: undefined,
167
+ eventsPending: new Map(),
113
168
  })
114
169
  this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
115
170
  this.#changed()
@@ -119,9 +174,42 @@ export class DeviceRouter {
119
174
  async detachDevice(deviceId, reason) {
120
175
  const record = this.devices.get(deviceId)
121
176
  if (!record) return
177
+ // 除了 `$events`,其它流(文件、会话流)立刻撤掉:它们没有"值班"的意义,
178
+ // 留着只会占资源。`$events` 留着,见 DEFAULT_EVENTS_GRACE_MS 的说明。
179
+ const keep = []
180
+ for (const entry of this.streams.byDevice(deviceId)) {
181
+ if (entry.endpoint === EVENTS_ENDPOINT) {
182
+ keep.push(entry)
183
+ continue
184
+ }
185
+ this.streams.removeByMux(entry.muxId)
186
+ this.dsh.cancelStream(entry.muxId)
187
+ }
188
+ record.dlpIds.clear()
189
+ for (const entry of keep) record.dlpIds.add(entry.dlpId)
190
+
191
+ if (record.offlineSince === undefined) {
192
+ record.offlineSince = this.now()
193
+ record.offlineTimer = this.setTimeoutFn(() => {
194
+ void this.#expireOfflineDevice(deviceId)
195
+ }, this.eventsGraceMs)
196
+ // 定时器不该把进程钉住(CLI 与测试里尤其明显)。
197
+ record.offlineTimer?.unref?.()
198
+ }
199
+ this.logger.info?.(
200
+ `mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
201
+ + `$events 流替它留 ${Math.round(this.eventsGraceMs / 1000)} 秒,期间来的提问会攒着`)
202
+ this.#changed()
203
+ }
204
+
205
+ /** 宽限期到点:真的把这台设备放下(撤流、清缓冲)。 */
206
+ async #expireOfflineDevice(deviceId) {
207
+ const record = this.devices.get(deviceId)
208
+ if (!record || record.offlineSince === undefined) return
122
209
  for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
123
210
  this.devices.delete(deviceId)
124
- this.logger.info?.(`mobile-link: device ${deviceId} detached (${reason ?? 'closed'})`)
211
+ this.logger.info?.(
212
+ `mobile-link: device ${deviceId} 宽限期结束,已放下(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
125
213
  this.#changed()
126
214
  }
127
215
 
@@ -148,6 +236,8 @@ export class DeviceRouter {
148
236
  teardownAll() {
149
237
  const muxIds = []
150
238
  for (const deviceId of [...this.devices.keys()]) {
239
+ const record = this.devices.get(deviceId)
240
+ this.clearTimeoutFn(record?.offlineTimer)
151
241
  muxIds.push(...this.streams.removeDevice(deviceId))
152
242
  this.devices.delete(deviceId)
153
243
  }
@@ -253,6 +343,17 @@ export class DeviceRouter {
253
343
  if (record.readyValue !== undefined) {
254
344
  this.send(deviceId, { t: 'item', id: frame.id, value: record.readyValue })
255
345
  }
346
+ // 还没被回答的提问在这里补发:ready 已经发过,所以 App 拿得到 clientId,
347
+ // 补发的提问是可以被回答的。**送出去不清空**——App 可能又崩/又重启,
348
+ // 只要还没人回答,下次连上还得再给一遍(App 侧按 eventId 去重)。
349
+ if (record.eventsPending.size > 0) {
350
+ const pending = [...record.eventsPending.values()]
351
+ this.logger.info?.(
352
+ `mobile-link: replaying ${pending.length} unanswered question(s) to ${deviceId}`)
353
+ for (const value of pending) {
354
+ this.send(deviceId, { t: 'item', id: frame.id, value })
355
+ }
356
+ }
256
357
  return
257
358
  }
258
359
  const muxId = `s:${deviceId}:${frame.id}`
@@ -302,6 +403,8 @@ export class DeviceRouter {
302
403
  })
303
404
  return
304
405
  }
406
+ // 答过了就不再是"待答":删掉它,免得下次重连又把已经回答过的问题推给 App。
407
+ record.eventsPending.delete(eventId)
305
408
  // The clientId is always the one from *this device's* $events ready frame
306
409
  // (spec §4.1.5); any clientId the device echoed is ignored on purpose.
307
410
  if (!this.dedupe.claim(eventId)) return
@@ -339,6 +442,27 @@ export class DeviceRouter {
339
442
  forwardEvent(deviceId, value) {
340
443
  const record = this.devices.get(deviceId)
341
444
  if (!record) return
445
+ // 只有"待回答的提问"值得留:别的都是"某事刚发生"的通知,补发只会让 App
446
+ // 对着几分钟前的事件发通知(例如一条早就结束的 turn)。
447
+ // 待答表:不管手机在不在都记下来,直到**有人回答**或 host 作废它。
448
+ // 只记在线时的转发是不够的——App 收下提问后崩一次/重启一次,那条提问就再也没人
449
+ // 记得了(host 认为已经投给这条流了,不会重发)。连接器记着,重连时再给一遍。
450
+ if (nonEmptyString(value?.eventId)) {
451
+ if (value.type === 'cancel') record.eventsPending.delete(value.eventId)
452
+ else if (value.type === 'waterfall') {
453
+ record.eventsPending.set(value.eventId, value)
454
+ if (record.eventsPending.size > this.eventsBacklog) {
455
+ // 超上限时丢最老的(Map 保持插入顺序)。
456
+ const oldest = record.eventsPending.keys().next().value
457
+ record.eventsPending.delete(oldest)
458
+ }
459
+ }
460
+ }
461
+ if (record.offlineSince !== undefined && isBufferedEvent(value)) {
462
+ this.logger.debug?.(
463
+ `mobile-link: kept ${value.type} ${value.event ?? value.eventId ?? ''} for offline ${deviceId}`)
464
+ return
465
+ }
342
466
  if (value && value.type === 'ready' && nonEmptyString(value.clientId)) {
343
467
  record.clientId = value.clientId
344
468
  record.readyValue = value
@@ -411,6 +535,8 @@ export class DeviceRouter {
411
535
  // Which build that phone reported on its last handshake.
412
536
  client: record.client ?? null,
413
537
  connectedAt: record.connectedAt,
538
+ offlineSince: record.offlineSince ?? null,
539
+ unansweredEvents: record.eventsPending.size,
414
540
  eventsReady: Boolean(record.clientId),
415
541
  openStreams: record.dlpIds.size,
416
542
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",