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 +9 -0
- package/lib/link.js +20 -1
- package/lib/router.js +129 -3
- package/package.json +1 -1
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 {
|
|
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({
|
|
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)
|
|
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?.(
|
|
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.
|
|
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",
|