dsh-plugin-mobile-link 0.3.1 → 0.4.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/router.js CHANGED
@@ -12,25 +12,45 @@
12
12
  import {
13
13
  StreamTable, WaterfallDedupe, deviceFrameError, deviceIdOf, nonEmptyString, resultFrame,
14
14
  } from './dlp.js'
15
- import { FileInbox, isFileMethod } from './files.js'
15
+ import { FileInbox, FILE_BEGIN, FILE_CHUNK, FILE_END, isFileMethod } from './files.js'
16
+ import { FileFetcher, FSGET_CANCEL } from './files-out.js'
16
17
  import { GitBridge, isGitMethod } from './git.js'
17
18
  import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
18
19
 
20
+ /**
21
+ * 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
22
+ * 的 `agentControl`。转义成 `FileInbox` 的三件套,见 `handleFsPutBegin` 那段注释。
23
+ */
24
+ export const FSPUT_BEGIN = 'fsPutBegin'
25
+ export const FSPUT_CHUNK = 'fsPutChunk'
26
+ export const FSPUT_END = 'fsPutEnd'
27
+
28
+ /**
29
+ * 后台大文件下载的桥接帧(relay → 连接器),同一族的另一半:relay 终结
30
+ * `GET /files/down` 并慢慢读,这里驱动 host 的 `workspaceFiles/readBytes`
31
+ * 一片一片回。实现体在 `lib/files-out.js`。
32
+ */
33
+ export const FSGET_BEGIN = 'fsGetBegin'
34
+
19
35
  export const EVENTS_ENDPOINT = '$events'
20
36
  export const EVENTS_RESULT = '$events/result'
21
37
 
22
38
  /**
23
- * 手机断了之后,这条 `$events` 流还替它留多久(毫秒)。
39
+ * 手机不在时一直替它留 `$events`,直到设备被撤销或进程退出。
24
40
  *
25
41
  * 为什么需要:手机是唯一客户端时,它一断,DSH 那边就没人持有这条流了——
26
- * 提问(waterfall)会随 Agent Context 释放被撤掉,用户回到手机再也看不到
27
- * (根因见 maintainers/PENDING-WATERFALL-LOSS.md)。留着流就等于"代手机值班":
28
- * 这期间来的提问先攒着,手机回来补发。
42
+ * 提问(waterfall)会随 Agent Context 释放被撤掉,用户回到手机再也看不到——
43
+ * 这正是留流的原因。留着流就等于"代手机值班":这期间来的提问先攒着,手机回来补发。
44
+ *
45
+ * **没有时限**:这里曾经有一个 15 分钟的宽限期(`DEFAULT_EVENTS_GRACE_MS`),
46
+ * 到点撤流;2026-09-28 的 R-1 把它拆掉了——手机离线超过 15 分钟提问就永远丢了,
47
+ * 而那正是"值班"最该起作用的时候。唯一的出口是 `reason === 'revoked'`(见
48
+ * `detachDevice`),以及进程退出(`teardownAll`)。
29
49
  *
30
50
  * 代价是这段时间里 DSH 认为"设备还在",会话的 Agent Context 不会因此释放;
31
- * 所以必须有上限,不能无限留。
51
+ * 也就是"挂着的提问所在会话的 Agent Context 长期不释放"。owner 已裁定这不是
52
+ * 风险(电脑一直运行),所以不加上限、也不为此写额外的清理机制。
32
53
  */
33
- export const DEFAULT_EVENTS_GRACE_MS = 15 * 60 * 1000
34
54
 
35
55
  /** 离线期间最多替一台设备攒多少条 `$events` 项(只攒 waterfall/cancel)。 */
36
56
  export const DEFAULT_EVENTS_BACKLOG = 50
@@ -63,14 +83,13 @@ export class DeviceRouter {
63
83
  */
64
84
  constructor({
65
85
  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,
86
+ eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {},
68
87
  } = {}) {
69
88
  this.dsh = dsh
70
- this.eventsGraceMs = eventsGraceMs
71
89
  this.eventsBacklog = eventsBacklog
72
- this.setTimeoutFn = setTimeoutFn
73
- this.clearTimeoutFn = clearTimeoutFn
90
+ // 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
91
+ // 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
92
+ this.notify = notify
74
93
  // Reported to the app so it can tell which wire revision it is talking to.
75
94
  this.protocolVersion = protocolVersion
76
95
  this.send = send
@@ -82,6 +101,13 @@ export class DeviceRouter {
82
101
  this.dedupe = new WaterfallDedupe({ now })
83
102
  this.lastError = undefined
84
103
  this.fileInbox = new FileInbox(logger)
104
+ // 下载桥(`fsGetBegin`):它自己经 `this.dsh.rpc` 读 host,回复只带 bid。
105
+ // `send(undefined, frame)` 与 `fsPut*` 同路——relay 靠 bid 关联回 HTTP 请求。
106
+ this.fileFetcher = new FileFetcher({
107
+ send: (frame) => this.send(undefined, frame),
108
+ rpc: (method, args) => dsh.rpc(method, args),
109
+ logger,
110
+ })
85
111
  this.git = new GitBridge(logger)
86
112
  }
87
113
 
@@ -107,6 +133,18 @@ export class DeviceRouter {
107
133
  await this.detachDevice(deviceId, frame.reason)
108
134
  return
109
135
  }
136
+ // **Before the `deviceId` guard, not after it.** The download bridge family
137
+ // is addressed by `bid` alone — the relay is the one that terminates the
138
+ // HTTP request and it knows which device it is for; putting a `deviceId` on
139
+ // these frames would break the correlation the whole family rests on. So a
140
+ // cancel legitimately arrives with no `deviceId`, and the guard below would
141
+ // have dropped it silently: the connector would read the whole file for a
142
+ // reader that left. Found by probe (the run kept pumping past the cancel),
143
+ // which is why this is a test and not a comment.
144
+ if (frame.t === FSGET_CANCEL) {
145
+ this.handleFsGetCancel(frame)
146
+ return
147
+ }
110
148
  if (!deviceId) return
111
149
  const problem = deviceFrameError(frame)
112
150
  if (problem) {
@@ -126,22 +164,103 @@ export class DeviceRouter {
126
164
  case 'eventResult':
127
165
  await this.handleEventResult(deviceId, frame)
128
166
  return
167
+ case FSPUT_BEGIN:
168
+ this.handleFsPutBegin(deviceId, frame)
169
+ return
170
+ case FSPUT_CHUNK:
171
+ this.handleFsPutChunk(frame)
172
+ return
173
+ case FSPUT_END:
174
+ this.handleFsPutEnd(frame)
175
+ return
176
+ case FSGET_BEGIN:
177
+ this.handleFsGetBegin(frame)
178
+ return
129
179
  default:
130
180
  /* Unknown frame types are ignored by design (spec §3). */
131
181
  return
132
182
  }
133
183
  }
134
184
 
185
+ // ── background-transfer bridge (relay → connector) ──────────────────────
186
+ //
187
+ // 一族 agent 侧控制帧(`docs/relay-contract.json` 的 `agentControl`),由 relay 的
188
+ // `PUT /files/up` 驱动。它们**不跨到设备**——手机走的是普通 HTTPS,relay 终结它、
189
+ // 再把字节泵到这条 agent WSS 上,落盘仍然是连接器的活(中转不落盘)。
190
+ //
191
+ // 实现体复用 `FileInbox`:它的 begin/chunk/end 语义正好是这三帧要的。
192
+ // **上行回复只带 `bid`,不带 `deviceId`**——relay 靠 `bid` 关联回那个 HTTP 请求。
193
+
194
+ handleFsPutBegin(deviceId, frame) {
195
+ // `deviceId` 只用来在出错时记账;**回复一律不带 deviceId**——relay 是靠
196
+ // `bid` 把它对回那个 HTTP 请求的(见 `handleRelayFrame` 那段注释)。
197
+ void deviceId
198
+ try {
199
+ this.fileInbox.handle(FILE_BEGIN, {
200
+ transferId: frame.bid, sessionId: frame.sessionId, name: frame.name, bytes: frame.bytes,
201
+ })
202
+ this.send(undefined, { t: 'fsPutAck', bid: frame.bid, received: 0 })
203
+ } catch (error) {
204
+ this.sendFsError(frame.bid, error)
205
+ }
206
+ }
207
+
208
+ handleFsPutChunk(frame) {
209
+ // **不回 ack**:整条链是流水线,一片一个 RTT 会把吞吐毁掉(roof 见
210
+ // `02-design.md` §4.4 第 3 条)。出错用 `fsErr` 中断。
211
+ try {
212
+ this.fileInbox.handle(FILE_CHUNK, {
213
+ transferId: frame.bid, seq: frame.seq, data: frame.data,
214
+ })
215
+ } catch (error) {
216
+ this.sendFsError(frame.bid, error)
217
+ }
218
+ }
219
+
220
+ handleFsPutEnd(frame) {
221
+ try {
222
+ const done = this.fileInbox.handle(FILE_END, { transferId: frame.bid })
223
+ this.send(undefined, { t: 'fsPutDone', bid: frame.bid, path: done.path, bytes: done.bytes })
224
+ } catch (error) {
225
+ this.sendFsError(frame.bid, error)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * 开一次下载。**不 await**:分片要在 relay 读响应体时陆续发出去,一个 await
231
+ * 会把这条 socket 上其它帧全挡住(与上传那条流水线的理由相同)。
232
+ */
233
+ handleFsGetBegin(frame) {
234
+ this.fileFetcher.start(frame)
235
+ }
236
+
237
+ /**
238
+ * relay 叫停一次下载读取(手机挂断 / 额度截断)。
239
+ *
240
+ * 只置 `cancelled`,**不回任何帧**:这不是一次请求,取消一个已经没人要的
241
+ * 传输也没有可报的错。`#drive` 在每次窗口返回后检查这个标志,所以最多再
242
+ * 多读一个窗口就停——不是立刻,但也不是读完整个文件。
243
+ */
244
+ handleFsGetCancel(frame) {
245
+ const bid = String(frame?.bid ?? '')
246
+ if (!bid) return
247
+ this.fileFetcher.cancel(bid)
248
+ }
249
+
250
+ /** One failure shape for the whole family; the relay turns it into an HTTP error. */
251
+ sendFsError(bid, error) {
252
+ this.logger.warn?.(`mobile-link: file bridge ${bid} failed: ${messageOf(error)}`)
253
+ this.send(undefined, { t: 'fsErr', bid, ...errorObject('file/rejected', messageOf(error)) })
254
+ }
255
+
135
256
  // ── device lifecycle ────────────────────────────────────────────────────
136
257
 
137
258
  async attachDevice(deviceId, info = {}) {
138
259
  if (!nonEmptyString(deviceId)) return
139
260
  const existing = this.devices.get(deviceId)
140
261
  if (existing) {
141
- // 回头客:宽限期内又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
262
+ // 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
142
263
  if (existing.offlineSince !== undefined) {
143
- this.clearTimeoutFn(existing.offlineTimer)
144
- existing.offlineTimer = undefined
145
264
  existing.offlineSince = undefined
146
265
  this.logger.info?.(
147
266
  `mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
@@ -163,7 +282,6 @@ export class DeviceRouter {
163
282
  // 注意它不是一个"送一次就清空"的队列:送过也要留着,否则 App 再重启一次
164
283
  // (或崩一次)这条提问就永远丢了——2026-09-22 验收时正是这么发现的。
165
284
  offlineSince: undefined,
166
- offlineTimer: undefined,
167
285
  eventsPending: new Map(),
168
286
  })
169
287
  this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
@@ -171,11 +289,26 @@ export class DeviceRouter {
171
289
  await this.ensureEventsStream(deviceId)
172
290
  }
173
291
 
292
+ /**
293
+ * 手机断了。
294
+ *
295
+ * `reason === 'revoked'`(中转发来的"这台设备被撤销了")是**放下**:撤掉它全部的流、
296
+ * 删掉记录——不再有人会回来拿那些提问了,继续值班只是白占一条流。
297
+ *
298
+ * 别的 reason(socket closed、backpressure…)一律是"手机暂时不在":普通流(文件、
299
+ * 会话流)立刻撤,`$events` 留着替它值班——**一直留**,没有时限(见 `$events`
300
+ * 那段的说明)。这也是「撤销即放下」是唯一出口的原因。
301
+ */
174
302
  async detachDevice(deviceId, reason) {
175
303
  const record = this.devices.get(deviceId)
176
304
  if (!record) return
305
+ if (reason === 'revoked') {
306
+ await this.#dropDevice(deviceId)
307
+ this.logger.info?.(`mobile-link: device ${deviceId} 已被撤销,放下(不再替它值班)`)
308
+ return
309
+ }
177
310
  // 除了 `$events`,其它流(文件、会话流)立刻撤掉:它们没有"值班"的意义,
178
- // 留着只会占资源。`$events` 留着,见 DEFAULT_EVENTS_GRACE_MS 的说明。
311
+ // 留着只会占资源。`$events` 留着,见上面那段说明。
179
312
  const keep = []
180
313
  for (const entry of this.streams.byDevice(deviceId)) {
181
314
  if (entry.endpoint === EVENTS_ENDPOINT) {
@@ -188,28 +321,21 @@ export class DeviceRouter {
188
321
  record.dlpIds.clear()
189
322
  for (const entry of keep) record.dlpIds.add(entry.dlpId)
190
323
 
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
- }
324
+ if (record.offlineSince === undefined) record.offlineSince = this.now()
199
325
  this.logger.info?.(
200
326
  `mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
201
- + `$events 流替它留 ${Math.round(this.eventsGraceMs / 1000)} 秒,期间来的提问会攒着`)
327
+ + '$events 流一直替它留着,直到设备被撤销或进程退出')
202
328
  this.#changed()
203
329
  }
204
330
 
205
- /** 宽限期到点:真的把这台设备放下(撤流、清缓冲)。 */
206
- async #expireOfflineDevice(deviceId) {
331
+ /** 真的把这台设备放下:撤全部流、删记录。唯一调用点是撤销与进程收尾。 */
332
+ async #dropDevice(deviceId) {
207
333
  const record = this.devices.get(deviceId)
208
- if (!record || record.offlineSince === undefined) return
334
+ if (!record) return
209
335
  for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
210
336
  this.devices.delete(deviceId)
211
337
  this.logger.info?.(
212
- `mobile-link: device ${deviceId} 宽限期结束,已放下(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
338
+ `mobile-link: 放下设备 ${deviceId}(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
213
339
  this.#changed()
214
340
  }
215
341
 
@@ -236,8 +362,6 @@ export class DeviceRouter {
236
362
  teardownAll() {
237
363
  const muxIds = []
238
364
  for (const deviceId of [...this.devices.keys()]) {
239
- const record = this.devices.get(deviceId)
240
- this.clearTimeoutFn(record?.offlineTimer)
241
365
  muxIds.push(...this.streams.removeDevice(deviceId))
242
366
  this.devices.delete(deviceId)
243
367
  }
@@ -458,6 +582,10 @@ export class DeviceRouter {
458
582
  }
459
583
  }
460
584
  }
585
+ // 「报一声」与「要不要攒」是两件事:攒只对 waterfall/cancel,报的是"跑完了"与
586
+ // "待你回应"两类。这里放在离线早退**之前**,所以在不在线都会报——
587
+ // 手机在线时它自己会弹本地通知,这条 notify 到了中转会被判"在线不推"(不然重复)。
588
+ this.notifyFor(value)
461
589
  if (record.offlineSince !== undefined && isBufferedEvent(value)) {
462
590
  this.logger.debug?.(
463
591
  `mobile-link: kept ${value.type} ${value.event ?? value.eventId ?? ''} for offline ${deviceId}`)
@@ -474,6 +602,36 @@ export class DeviceRouter {
474
602
  if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
475
603
  }
476
604
 
605
+ /**
606
+ * 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
607
+ *
608
+ * 只有两类,判据都是现成的:
609
+ *
610
+ * - **跑完了**:`emit: api-session/status(sid, false)`。只能从这条 `emit` 认——
611
+ * `turn/end` 不在 `$events` 的转发白名单里(`docs/DSH-PROTOCOL.md:3141-3165`;
612
+ * `turn/end` 在 `:3229`,那是 `session/follow` 的事件),在这条流上永远看不到。
613
+ * - **待你回应**:两种 `waterfall` 都算——`user-questions/request`(`:3164`)与
614
+ * `approval/request`(`:3145`),两者都让 host 停在那儿等人。App 侧本来就两种都认
615
+ * (`Support/HostEventHub.swift:191-198`)。
616
+ *
617
+ * `api-session/error`(`:3150`)**有意不推**:它不一定是"跑完了"(也可能是一轮中途
618
+ * 报错后继续),拿它当"跑完了"会发出一堆噪音提醒。这是决定,不是漏做。
619
+ *
620
+ * 不做去抖、不做队列、不做重试:一轮才一条,发失败(中转断了)就丢。`turnEnd` 丢一条
621
+ * 不值得为它做重试;`attention` 丢不了,它同时被记账,手机回来会补发。
622
+ */
623
+ notifyFor(value) {
624
+ if (!value || typeof value !== 'object') return
625
+ if (value.type === 'emit' && value.event === 'api-session/status' && value.args?.[1] === false) {
626
+ const sid = String(value.args[0] ?? '')
627
+ if (sid) this.notify({ kind: 'turnEnd', sid })
628
+ return
629
+ }
630
+ if (value.type === 'waterfall') {
631
+ this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
632
+ }
633
+ }
634
+
477
635
  onStreamError(muxId, error) {
478
636
  const entry = this.streams.entryOfMux(muxId)
479
637
  if (!entry) return
package/lib/routes.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * POST /mobile-link/pair-code mint a pairing code (and its QR payload)
6
6
  * GET /mobile-link/qr mint a pairing code and render it as a QR SVG
7
7
  *
8
- * All sit behind DSH's own request fence, exactly like dsh-plugin-desktop-shell:
8
+ * All sit behind DSH's own request fence, like every other plugin route:
9
9
  * `ctx.get('connection')?.requestRejection?.(req)` performs the Host/Origin check
10
10
  * and browser authentication. `/status` stays readable when the fence service is
11
11
  * missing (it is diagnostic); the other two mint a credential, so they fail
package/lib/state.js CHANGED
@@ -45,23 +45,14 @@ export function defaultStatePath() {
45
45
  return join(dshHome(), 'mobile-link', 'agent.json')
46
46
  }
47
47
 
48
- export function defaultEndpointFile() {
49
- return join(dshHome(), 'desktop-shell', 'endpoint.json')
50
- }
51
-
52
48
  /**
53
- * The desktop shell's log, which is the *second* place the live endpoint can be
54
- * read from.
49
+ * 连接器**自己写出**的交接文件:给跑在宿主进程之外的脚本用(它们没有进程内上下文,
50
+ * 拿不到注入的服务)。消费者与格式见 `contracts/out-of-process-handoff.md`。
55
51
  *
56
- * The shell writes `endpoint.json` as a handoff, but it also removes it again
57
- * (observed after the shell quit and re-attached to an already running `dsh
58
- * web`), and a machine whose host was started as a bare `dsh web --port 0` only
59
- * ever sees the URL printed into this log. Both spellings appear here, so the
60
- * log is a usable last resort — without it the connector sits at the default
61
- * port with no launch token and every phone request fails.
52
+ * 注意:这不是连接器自己的发现来源——发现走宿主进程内的注入(见 `dsh-client.js`)。
62
53
  */
63
- export function defaultShellLogFile() {
64
- return join(dshHome(), 'desktop-shell', 'dsh-shell.log')
54
+ export function defaultHandoffFile() {
55
+ return join(dshHome(), 'mobile-link', 'endpoint.json')
65
56
  }
66
57
 
67
58
  /** @returns {Promise<Record<string, unknown> | undefined>} */
package/lib/status.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `GET /mobile-link/status` 的快照。
3
+ *
4
+ * 单独成文件的目的:这份载荷是**对外契约**(`contracts/status-endpoint.md` 规定了
5
+ * "只增不改"),把它和传输/生命周期代码分开,改动时更容易看出"是不是又动了老字段"。
6
+ * 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
7
+ */
8
+
9
+ import { SERVER_CAPABILITIES, SERVER_VERSION } from './hello.js'
10
+
11
+ const PROTOCOL_VERSION = 1
12
+
13
+ /**
14
+ * 失败分类 → 用户可读的一句话 + 文档锚点(FR-012)。
15
+ *
16
+ * 锚点标题与 `docs/ONBOARDING.md` 里的小节标题**逐字一致**,这样"锚点存在"可以机械核对。
17
+ */
18
+ export const DSH_HINTS = {
19
+ config: { hint: '你手工配置的本机地址不对或已失效', docAnchor: '手工指定本机地址' },
20
+ 'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', docAnchor: '装在哪里:档案与插件入口' },
21
+ DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', docAnchor: '手工指定本机地址' },
22
+ unreachable: { hint: '本机 DSH 不可达;请确认宿主正在运行', docAnchor: '排查:连不上时看这里' },
23
+ }
24
+
25
+ /**
26
+ * 宿主形态 → 用户可读的一句话 + 文档锚点(FR-012:分类必须可行动,不能只给分类名)。
27
+ *
28
+ * 锚点标题与 `docs/ONBOARDING.md` 的小节标题逐字一致,`test/status.test.js` 会核对。
29
+ */
30
+ export const HOST_HINTS = {
31
+ desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', docAnchor: '安装:官方桌面版' },
32
+ web: { hint: '你正在用命令行 web 版', docAnchor: '安装:命令行 web 版' },
33
+ unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', docAnchor: '排查:连不上时看这里' },
34
+ }
35
+
36
+ /** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */
37
+ function dshBlock(agent) {
38
+ const errorKind = agent.dshError ? (agent.dsh?.errorKind ?? 'unreachable') : null
39
+ const entry = errorKind ? DSH_HINTS[errorKind] : undefined
40
+ return {
41
+ endpoint: agent.dsh?.endpoint?.base ?? null,
42
+ source: agent.dsh?.endpoint?.source ?? null,
43
+ port: agent.dsh?.endpoint?.port ?? null,
44
+ authenticated: Boolean(agent.dsh?.cookie),
45
+ muxUp: Boolean(agent.dsh?.muxReady),
46
+ error: agent.dshError ?? null,
47
+ // 新增(只增不改):失败分类 + 用户可读的一句话 + 文档锚点。
48
+ errorKind,
49
+ hint: entry?.hint ?? null,
50
+ docAnchor: entry?.docAnchor ?? null,
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {import('./link.js').MobileLinkAgent} agent
56
+ * @returns {Record<string, unknown>} 可直接序列化给 `/status` 的对象
57
+ */
58
+ export function statusSnapshot(agent) {
59
+ const hostEntry = HOST_HINTS[agent.hostForm] ?? HOST_HINTS.unknown
60
+ return {
61
+ ok: true,
62
+ enabled: agent.enabled !== false,
63
+ // 新增(只增不改):宿主形态 + 它对应的一句话与文档锚点,
64
+ // 决定用户该按哪条说明安装/排障(FR-012)。
65
+ host: agent.hostForm,
66
+ hostHint: hostEntry.hint,
67
+ hostDocAnchor: hostEntry.docAnchor,
68
+ protocolVersion: PROTOCOL_VERSION,
69
+ // Same facts as `_link/hello`, which is the channel the app actually uses:
70
+ // this route only exists on the direct path.
71
+ serverVersion: SERVER_VERSION,
72
+ capabilities: SERVER_CAPABILITIES,
73
+ enroll: {
74
+ // A phone (or the user's browser) can read this to find out that the
75
+ // computer half is installed but not yet registered, and what to run.
76
+ registered: !agent.needsEnroll,
77
+ needsEnroll: agent.needsEnroll,
78
+ stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
79
+ relayUrl: agent.config.relayUrl ?? null,
80
+ command: agent.needsEnroll ? agent.enrollHint() : null,
81
+ hint: agent.needsEnroll
82
+ ? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
83
+ : null,
84
+ },
85
+ state: agent.state,
86
+ connected: agent.state === 'connected',
87
+ relayUrl: agent.identity?.relayUrl ?? agent.config.relayUrl ?? null,
88
+ agentId: agent.identity?.agentId ?? agent.config.agentId ?? null,
89
+ agentName: agent.identity?.agentName ?? null,
90
+ stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
91
+ dsh: dshBlock(agent),
92
+ devices: agent.router.snapshot(),
93
+ deviceCount: agent.devices.size,
94
+ openStreams: agent.streams.size,
95
+ pendingWaterfalls: agent.dedupe.size,
96
+ lastError: agent.failure ?? null,
97
+ startedAt: agent.startedAt ?? null,
98
+ connectedAt: agent.connectedAt ?? null,
99
+ reconnectAttempts: agent.reconnectAttempts,
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-link",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",