dsh-plugin-mobile-link 0.6.6 → 0.8.0

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
@@ -18,6 +18,7 @@ import { GitBridge, isGitMethod } from './git.js'
18
18
  import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
19
19
  import { isHistoryMethod } from './history.js'
20
20
  import { trimRecordsForWire } from './history/records.js'
21
+ import { isJobsMethod } from './jobs-roster.js'
21
22
 
22
23
  /**
23
24
  * 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
@@ -26,6 +27,16 @@ import { trimRecordsForWire } from './history/records.js'
26
27
  export const FSPUT_BEGIN = 'fsPutBegin'
27
28
  export const FSPUT_CHUNK = 'fsPutChunk'
28
29
  export const FSPUT_END = 'fsPutEnd'
30
+ /**
31
+ * relay 叫停一次后台上传(占位型前向兼容)。
32
+ *
33
+ * 本仓库两个中转**目前都不发这一帧**:手机掐掉 `PUT /files/up` 时,它们在
34
+ * `finally` 里只摘掉本地的桥接,既不发取消帧、也不断开 agent 的 WebSocket。
35
+ * 因此真正兜住句柄的是 `FileInbox` 的空闲清扫与"重传接管暂存路径";这一帧
36
+ * 让将来的/第三方中转能提前释放。按 bid 寻址,可以不带 `deviceId`
37
+ * (与 `fsGetCancel` 同口径,未知 bid 是空操作)。
38
+ */
39
+ export const FSPUT_CANCEL = 'fsPutCancel'
29
40
 
30
41
  /**
31
42
  * 后台大文件下载的桥接帧(relay → 连接器),同一族的另一半:relay 终结
@@ -96,13 +107,16 @@ export function trimHistoryPageForWire(method, result) {
96
107
  }
97
108
 
98
109
  import { DeviceTimeline } from './diagnostics.js'
99
- import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest } from './session-digest.js'
110
+ import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest, digestDelta } from './session-digest.js'
111
+ import { DIR_LISTING_METHOD, dirListing } from './dir-listing.js'
100
112
 
101
113
  /** 慢请求阈值默认值(毫秒);0 = 关闭逐条慢请求记录。 */
102
114
  export const DEFAULT_SLOW_REQUEST_MS = 2000
103
115
 
104
116
  /** App 把自己那份阶段记录推上来的方法(契约 connector-diagnostics-method.md)。 */
105
117
  export const DIAGNOSTICS_METHOD = '_link/diagnostics'
118
+ /** 手机把运行日志推上来的方法;落盘在 `logs.js`,与 journal 分开。 */
119
+ export const LOGS_METHOD = '_link/logs'
106
120
  /** 单批上限:超过就截断并如实报 `accepted`,剩下的由 App 下次再推(水位不推进)。 */
107
121
  export const DIAGNOSTICS_BATCH_BYTES = 32 * 1024
108
122
  /** 单条事件里字符串字段的保留长度。 */
@@ -142,9 +156,11 @@ export class DeviceRouter {
142
156
  constructor({
143
157
  dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
144
158
  eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {}, onEvent = () => {},
145
- slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true,
159
+ slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true, logs = null,
146
160
  } = {}) {
147
161
  this.dsh = dsh
162
+ /** 设备日志落盘(见 logs.js);null = 不接日志(老配置或单测)。 */
163
+ this.logs = logs
148
164
  this.eventsBacklog = eventsBacklog
149
165
  this.slowRequestMs = Number(slowRequestMs) || 0
150
166
  this.diagnosticsEnabled = diagnostics !== false
@@ -185,6 +201,21 @@ export class DeviceRouter {
185
201
  // 历史窗口/检索(spec 007):由 index.js 注入宿主 `sessionQuery` 后交给它。
186
202
  // 没绑上就回 `link/unsupported`,手机端退回 `session/page`。
187
203
  this.history = undefined
204
+ // 活动名单与 `link/jobs` 推帧(spec 018)。订阅与去抖都在 roster 里,router 只负责
205
+ // 转交设备生命周期与把帧广播出去。
206
+ this.jobsRoster = undefined
207
+ }
208
+
209
+ /**
210
+ * 挂上活动名单(spec 018)。
211
+ *
212
+ * 注入可能晚于第一台设备连接(`ctx.inject` 的时序),所以这里要对**已经挂着**的
213
+ * 设备补报一遍:只在 attach 时通知的话,这台连接会一直到下一次 attach 才开始推帧。
214
+ */
215
+ bindJobsRoster(roster) {
216
+ this.jobsRoster = roster
217
+ if (roster === undefined) return
218
+ for (const deviceId of this.devices.keys()) roster.addDevice(deviceId)
188
219
  }
189
220
 
190
221
  #changed() {
@@ -221,6 +252,11 @@ export class DeviceRouter {
221
252
  this.handleFsGetCancel(frame)
222
253
  return
223
254
  }
255
+ // 上传的取消帧同样是 bid 寻址,可能不带 `deviceId`;放在守卫之前,理由同上。
256
+ if (frame.t === FSPUT_CANCEL) {
257
+ this.handleFsPutCancel(frame)
258
+ return
259
+ }
224
260
  if (!deviceId) return
225
261
  const problem = deviceFrameError(frame)
226
262
  if (problem) {
@@ -305,6 +341,17 @@ export class DeviceRouter {
305
341
  }
306
342
  }
307
343
 
344
+ /**
345
+ * relay 叫停一次上传:立刻关句柄、删 `.part`。
346
+ *
347
+ * 不回帧:取消本身不是一次请求,也没有可报的错(与 `handleFsGetCancel` 同)。
348
+ */
349
+ handleFsPutCancel(frame) {
350
+ const bid = String(frame?.bid ?? '')
351
+ if (!bid) return
352
+ this.fileInbox.cancel(bid)
353
+ }
354
+
308
355
  /**
309
356
  * 开一次下载。**不 await**:分片要在 relay 读响应体时陆续发出去,一个 await
310
357
  * 会把这条 socket 上其它帧全挡住(与上传那条流水线的理由相同)。
@@ -404,6 +451,22 @@ export class DeviceRouter {
404
451
  return { ok: true, value: { accepted, lastSeq } }
405
452
  }
406
453
 
454
+ /**
455
+ * 收下手机推来的日志行。
456
+ *
457
+ * 与 `acceptDiagnostics` 的区别:这里不产生任何生命周期事件(日志是数据,不是状态),
458
+ * 也不做批量解释;`logs.js` 只负责按天落盘与 7 天清理。失败一律当无事:
459
+ * 手机侧把未确认的条目留在自己的环里,下次再推。
460
+ */
461
+ acceptLogs(deviceId, args = {}) {
462
+ if (!this.logs) {
463
+ return { ok: false, error: errorObject('logs/disabled', 'log intake is disabled on this connector') }
464
+ }
465
+ const entries = Array.isArray(args.entries) ? args.entries : []
466
+ const result = this.logs.append(deviceId, entries, { full: args.full === true })
467
+ return { ok: true, value: result }
468
+ }
469
+
407
470
  /** 起一次新的接入计时(回头客也重起:那确实是一次新连接)。 */
408
471
  #startTimeline(deviceId) {
409
472
  this.timelines.get(deviceId)?.finish()
@@ -428,6 +491,9 @@ export class DeviceRouter {
428
491
  async attachDevice(deviceId, info = {}) {
429
492
  if (!nonEmptyString(deviceId)) return
430
493
  this.#startTimeline(deviceId)
494
+ // 活动名单(018):第一台设备连上才订阅宿主任务事件;回头客这里也要补一次
495
+ // (detach 时它从名单里撤下过)。
496
+ this.jobsRoster?.addDevice(deviceId)
431
497
  const existing = this.devices.get(deviceId)
432
498
  if (existing) {
433
499
  // 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
@@ -484,6 +550,9 @@ export class DeviceRouter {
484
550
  this.#finishTimeline(deviceId)
485
551
  const record = this.devices.get(deviceId)
486
552
  if (!record) return
553
+ // 活动名单(018):断了就不再往它那儿推任务帧(离线补发旧任务状态没有意义),
554
+ // 最后一台设备离开时 roster 自己会释放宿主订阅。
555
+ this.jobsRoster?.removeDevice(deviceId)
487
556
  if (reason === 'revoked') {
488
557
  await this.#dropDevice(deviceId)
489
558
  this.onEvent('device.revoked', { deviceId })
@@ -516,6 +585,7 @@ export class DeviceRouter {
516
585
  async #dropDevice(deviceId) {
517
586
  const record = this.devices.get(deviceId)
518
587
  if (!record) return
588
+ this.jobsRoster?.removeDevice(deviceId)
519
589
  for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
520
590
  this.devices.delete(deviceId)
521
591
  this.logger.info?.(
@@ -555,6 +625,12 @@ export class DeviceRouter {
555
625
  // 不该有残留:上面的循环已经逐个收过;这里兜底,防止 attach 之后没进 devices 的路径留下脏项。
556
626
  this.timelines.clear()
557
627
  this._sentBytes.clear()
628
+ // 链路断了:在传的上传与下载都不可能再完成。上传要关句柄、删 `.part`;
629
+ // 下载要停掉还没读完的窗口。
630
+ this.fileInbox.clear()
631
+ this.fileFetcher.clear()
632
+ // 活动名单(018):设备都没了,宿主任务订阅与排队的帧一起放掉。
633
+ this.jobsRoster?.releaseAll()
558
634
  return muxIds
559
635
  }
560
636
 
@@ -575,19 +651,67 @@ export class DeviceRouter {
575
651
  this.lastError = messageOf(error)
576
652
  this.#changed()
577
653
  }
578
- const payload = result?.ok === true
579
- ? { ok: true, value: encodeDigest(digestSessionList(result), args.encoding) }
580
- : result
654
+ let payload = result
655
+ if (result?.ok === true) {
656
+ const trimmed = digestSessionList(result)
657
+ const items = Array.isArray(trimmed?.items) ? trimmed.items : []
658
+ const since = typeof args.since === 'string' ? args.since : null
659
+ this.digestCursors ??= new Map()
660
+ const previous = since ? this.digestCursors.get(deviceId) : null
661
+ // 版本对得上才认那份指纹表;对不上(首次、连接器重启、设备被清理)就当没有历史。
662
+ // 能不能当增量发由 digestDelta 决定:它持有指纹,也只有它能判断身份是否可识别。
663
+ const usable = previous && previous.version === since ? previous : null
664
+ const delta = digestDelta(usable ? usable.fingerprints : null, items)
665
+ this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
666
+ if (this.digestCursors.size > 32) {
667
+ const oldest = this.digestCursors.keys().next().value
668
+ this.digestCursors.delete(oldest)
669
+ }
670
+ const value = {
671
+ ...trimmed,
672
+ items: delta.changed,
673
+ removed: delta.removed,
674
+ version: delta.version,
675
+ full: delta.full,
676
+ }
677
+ payload = { ok: true, value: encodeDigest(value, args.encoding) }
678
+ }
581
679
  this.send(deviceId, resultFrame(frame.id, payload, deviceId))
582
680
  return
583
681
  }
584
682
 
683
+ // 目录清单的本机增强(设计 20261024):连接器读宿主的 `workspaceFiles/list`,
684
+ // 在电脑本机对每个条目 `fs.stat` 补一个 `mtimeMs`——宿主没有时间字段,而
685
+ // `workspaceFiles/stat` 的 `version` 是 opaque token(推不出时间)。
686
+ // 与 sessionDigest 同款:清单的内容仍是宿主的,连接器只加字段;任何一步出问题
687
+ // 都退回未增强的原始清单(装饰永不弄坏清单)。
688
+ if (frame.method === DIR_LISTING_METHOD) {
689
+ let result
690
+ try {
691
+ // `dirListing` 认宿主信封本身(`{ ok, value: { entries } }`)并原样回同形状的值:
692
+ // 宿主报错时**信封逐字透传**,`resultFrame` 照常把它翻成 `ok:false` + 原错误码。
693
+ // 这里不能再包一层 `{ ok: true, value }`——那会把宿主的失败塞进 `value` 里,
694
+ // 帧上反而成了 `ok:true`(手机就会把"目录不存在"读成"空目录")。
695
+ result = await dirListing({ dsh: this.dsh, args: frame.args ?? {}, logger: this.logger })
696
+ } catch (error) {
697
+ result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
698
+ }
699
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
700
+ return
701
+ }
702
+
585
703
  if (frame.method === DIAGNOSTICS_METHOD) {
586
704
  const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
587
705
  this.send(deviceId, resultFrame(frame.id, result, deviceId))
588
706
  return
589
707
  }
590
708
 
709
+ if (frame.method === LOGS_METHOD) {
710
+ const result = this.acceptLogs(deviceId, frame.args ?? {})
711
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
712
+ return
713
+ }
714
+
591
715
  // Files the phone sends are answered here rather than forwarded: the Host
592
716
  // has no upload endpoint, and carrying them on the existing unary frame
593
717
  // means neither the relay nor the protocol has to change.
@@ -667,6 +791,22 @@ export class DeviceRouter {
667
791
  return
668
792
  }
669
793
 
794
+ // 活动名单(spec 018)与 history 同一条短路:连接器自己回答,不转发宿主。
795
+ // 没挂上宿主 `ctx.jobs` 时回 `link/unsupported`——**不**回空名单,否则手机
796
+ // 会把"这台连接器不支持"显示成"没有任务在跑"。
797
+ if (isJobsMethod(frame.method)) {
798
+ let result
799
+ try {
800
+ result = this.jobsRoster === undefined
801
+ ? { ok: false, error: errorObject('link/unsupported', 'job roster is unavailable on this connector') }
802
+ : await this.jobsRoster.handle(frame.args ?? {})
803
+ } catch (error) {
804
+ result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
805
+ }
806
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
807
+ return
808
+ }
809
+
670
810
  let result
671
811
  try {
672
812
  result = await this.dsh.rpc(frame.method, frame.args ?? {})
@@ -831,12 +971,38 @@ export class DeviceRouter {
831
971
  record.readyValue = value
832
972
  this.#changed()
833
973
  }
974
+ this.#sendEventItem(deviceId, record, value)
975
+ }
976
+
977
+ /**
978
+ * 向一台设备投递一条 `$events` 形态的项(item,或未开流时的 event 帧)。
979
+ *
980
+ * 抽出来是为了让连接器自造的帧(`link/jobs`,见 `publishEvent`)与宿主事件走**同一段**
981
+ * 投递口径,帧语义不变:开了 `$events` 的按 dlpId 走 item,没开的按 event 广播。
982
+ */
983
+ #sendEventItem(deviceId, record, value) {
834
984
  for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
835
985
  // Spec §4.1.5: every $events item is broadcast as an `event` frame until the
836
986
  // device opens the stream explicitly, so nothing is delivered twice.
837
987
  if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
838
988
  }
839
989
 
990
+ /**
991
+ * 把一条连接器自造的项广播给**每台在线设备**(活动名单的 `link/jobs`,spec 018)。
992
+ *
993
+ * 与 `forwardEvent` 的区别是来源:那条项来自某台设备自己的 `$events` 流,只回给它;
994
+ * 这条是宿主任务事件的产物,没有归属设备,所以每台在线设备各收一份。
995
+ *
996
+ * **离线设备不推**:`$events` 还替它留着(见 `detachDevice`),但旧任务状态没有补发
997
+ * 意义——重连后手机重新问一次 `_link/jobRoster` 即可(契约 §3)。
998
+ */
999
+ publishEvent(value) {
1000
+ for (const record of this.devices.values()) {
1001
+ if (record.offlineSince !== undefined) continue
1002
+ this.#sendEventItem(record.deviceId, record, value)
1003
+ }
1004
+ }
1005
+
840
1006
  /**
841
1007
  * 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
842
1008
  *
package/lib/routes.js CHANGED
@@ -86,6 +86,31 @@ function ttlFrom(value, fallbackMs) {
86
86
  : fallbackMs
87
87
  }
88
88
 
89
+ /** Map an enrollment error code onto the HTTP status the panel sees. */
90
+ export function enrollStatus(code) {
91
+ switch (code) {
92
+ case 'enroll/already-registered':
93
+ case 'enroll/disabled':
94
+ return 409
95
+ case 'enroll/timeout':
96
+ return 504
97
+ case 'enroll/rate-limited':
98
+ return 429
99
+ case 'enroll/state-write':
100
+ case 'enroll/internal':
101
+ return 500
102
+ case 'enroll/bad-request':
103
+ case 'enroll/invalid-relay':
104
+ case 'enroll/placeholder-relay':
105
+ case 'enroll/unknown':
106
+ case 'enroll/used':
107
+ case 'enroll/expired':
108
+ return 400
109
+ default:
110
+ return 502
111
+ }
112
+ }
113
+
89
114
  /**
90
115
  * Register the routes.
91
116
  * @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
@@ -93,6 +118,9 @@ function ttlFrom(value, fallbackMs) {
93
118
  export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, history }) {
94
119
  const guard = makeGuard(ctx, logger)
95
120
  const STATUS_ROUTE = '/mobile-link/status'
121
+ const ENROLL_ROUTE = '/mobile-link/enroll'
122
+ const REVOKE_ROUTE = '/mobile-link/devices/revoke'
123
+ const DEVICES_ROUTE = '/mobile-link/devices'
96
124
  const PAIR_ROUTE = '/mobile-link/pair-code'
97
125
  const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
98
126
  const disposers = []
@@ -116,6 +144,125 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
116
144
  },
117
145
  }))
118
146
 
147
+ disposers.push(ctx.webServer.register({
148
+ kind: 'exact',
149
+ path: ENROLL_ROUTE,
150
+ handler: async (req, res) => {
151
+ if (guard(req, res, { requireFence: true })) return
152
+ if (req.method !== 'POST') {
153
+ res.statusCode = 405
154
+ res.setHeader('allow', 'POST')
155
+ res.end()
156
+ return
157
+ }
158
+ const body = await readJsonBody(req)
159
+ if (body === undefined) {
160
+ sendJson(res, 400, { ok: false, code: 'enroll/bad-request', message: 'invalid JSON body' })
161
+ return
162
+ }
163
+ const relayUrl = typeof body.relayUrl === 'string' ? body.relayUrl.trim() : ''
164
+ const inviteCode = typeof body.inviteCode === 'string' ? body.inviteCode.trim() : ''
165
+ const agentName = typeof body.agentName === 'string' ? body.agentName.trim() : ''
166
+ if (!relayUrl) {
167
+ sendJson(res, 400, {
168
+ ok: false,
169
+ code: 'enroll/bad-request',
170
+ message: 'relayUrl is required',
171
+ })
172
+ return
173
+ }
174
+ try {
175
+ // 开放登记:不需要邀请码;旧调用方仍可带(历史兼容)。
176
+ const settled = await agent.enroll({
177
+ inviteCode: inviteCode || undefined,
178
+ relayUrl,
179
+ agentName: agentName || undefined,
180
+ })
181
+ logger.info?.(`mobile-link: panel enrollment succeeded for ${settled.agentId}`)
182
+ // Never return `agentSecret`: the browser has no use for it, and one
183
+ // XSS in the panel would otherwise hand over the machine's credential.
184
+ sendJson(res, 200, {
185
+ ok: true,
186
+ agentId: settled.agentId,
187
+ agentName: settled.agentName,
188
+ relayUrl: settled.relayUrl,
189
+ })
190
+ } catch (error) {
191
+ // Read the code off the error rather than using `instanceof`: a hot
192
+ // reload can leave the route and the link on different module
193
+ // generations, and an identity check would then downgrade a precise
194
+ // "already registered" into a generic 500.
195
+ const code = typeof error?.code === 'string' && error.code.startsWith('enroll/')
196
+ ? error.code
197
+ : 'enroll/internal'
198
+ const status = enrollStatus(code)
199
+ if (code === 'enroll/internal') {
200
+ logger.warn?.(`mobile-link: panel enrollment failed: ${String(error?.message ?? error)}`)
201
+ }
202
+ sendJson(res, status, { ok: false, code, message: String(error?.message ?? error) })
203
+ }
204
+ },
205
+ }))
206
+
207
+ disposers.push(ctx.webServer.register({
208
+ kind: 'exact',
209
+ path: DEVICES_ROUTE,
210
+ handler: async (req, res) => {
211
+ if (guard(req, res, { requireFence: true })) return
212
+ if (req.method !== 'GET') {
213
+ res.statusCode = 405
214
+ res.setHeader('allow', 'GET')
215
+ res.end()
216
+ return
217
+ }
218
+ try {
219
+ const result = await agent.listPairedDevices()
220
+ sendJson(res, 200, { ok: true, ...result })
221
+ } catch (error) {
222
+ sendJson(res, 502, {
223
+ ok: false,
224
+ code: 'devices/list-failed',
225
+ message: String(error?.message ?? error),
226
+ })
227
+ }
228
+ },
229
+ }))
230
+
231
+ disposers.push(ctx.webServer.register({
232
+ kind: 'exact',
233
+ path: REVOKE_ROUTE,
234
+ handler: async (req, res) => {
235
+ if (guard(req, res, { requireFence: true })) return
236
+ if (req.method !== 'POST') {
237
+ res.statusCode = 405
238
+ res.setHeader('allow', 'POST')
239
+ res.end()
240
+ return
241
+ }
242
+ const body = await readJsonBody(req)
243
+ if (body === undefined) {
244
+ sendJson(res, 400, { ok: false, code: 'request/bad-request', message: 'invalid JSON body' })
245
+ return
246
+ }
247
+ const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''
248
+ if (!deviceId) {
249
+ sendJson(res, 400, { ok: false, code: 'request/device-id', message: 'deviceId is required' })
250
+ return
251
+ }
252
+ try {
253
+ const result = await agent.revokeDevice(deviceId)
254
+ logger.info?.(`mobile-link: revoked device ${result.deviceId}`)
255
+ sendJson(res, 200, { ok: true, deviceId: result.deviceId })
256
+ } catch (error) {
257
+ sendJson(res, 502, {
258
+ ok: false,
259
+ code: 'devices/revoke-failed',
260
+ message: String(error?.message ?? error),
261
+ })
262
+ }
263
+ },
264
+ }))
265
+
119
266
  disposers.push(ctx.webServer.register({
120
267
  kind: 'exact',
121
268
  path: PAIR_ROUTE,
@@ -12,25 +12,33 @@
12
12
  */
13
13
 
14
14
  import { deflateRawSync } from 'node:zlib'
15
+ import { createHash } from 'node:crypto'
15
16
 
16
17
  /** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
17
18
  export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
18
19
 
19
20
  /** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
20
- export const DROPPED_ITEM_KEYS = Object.freeze(['agentAvailable'])
21
+ // `agentAvailable` 曾是"零读取点",018 之后列表行要用它区分"会话是否还有活实例"
22
+ // (`ActivityStore` 与 `session.activity.*`),因此**不再剔除**。留空数组是为了保住
23
+ // 「剔除机制 + 逐字节稳定」这两条不变式;将来要剔字段仍从这里加。
24
+ export const DROPPED_ITEM_KEYS = Object.freeze([])
21
25
 
22
26
  /**
23
- * 投影键里剔掉的字段(合计 56.7%)。
27
+ * 投影键里剔掉的字段(合计约 55%)。
24
28
  *
25
29
  * 每一条都能在 App/DSHKit 里复查"零读取点":
26
30
  * `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
27
31
  * `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
28
32
  * `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
29
- * `subagentTiming` 1.5%、`agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
33
+ * `agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
34
+ *
35
+ * **2026-10-20 起不再剔除 `subagentTiming`**(原 1.5%):018 的活动可见性靠它判断
36
+ * "某子会话当前是否在跑"与已跑时长;剔除后手机永远看不到「N 智能体」。
37
+ * 同理 `agentAvailable` 也不再剔除。代价是摘要通道多约 1.5% 字节,换来功能可用。
30
38
  */
31
39
  export const DROPPED_PROJECTION_KEYS = Object.freeze([
32
40
  'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
33
- 'contextBreakdown', 'sessionListMetadata', 'subagentTiming', 'agentPreset',
41
+ 'contextBreakdown', 'sessionListMetadata', 'agentPreset',
34
42
  ])
35
43
 
36
44
  /**
@@ -115,3 +123,64 @@ export function encodeDigest(value, encoding) {
115
123
  rawBytes: raw.length,
116
124
  }
117
125
  }
126
+
127
+ /** 单条会话的稳定指纹:内容不变则指纹不变(用于增量比较)。 */
128
+ export function digestFingerprint(item) {
129
+ try {
130
+ return createHash('sha1').update(JSON.stringify(item)).digest('hex').slice(0, 16)
131
+ } catch {
132
+ return 'unhashable'
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 一批会话的指纹表(sessionId → 指纹)。
138
+ *
139
+ * 键是宿主 `SessionSummary.sessionId`——会话身份在宿主只有这一个名字。
140
+ * 这里不认 `id`:多认一个名字就是第二份身份,正是它让指纹表恒为空(2026-10-27)。
141
+ */
142
+ export function digestFingerprints(items) {
143
+ const map = new Map()
144
+ for (const item of items ?? []) {
145
+ if (item && typeof item.sessionId === 'string') map.set(item.sessionId, digestFingerprint(item))
146
+ }
147
+ return map
148
+ }
149
+
150
+ /** 指纹表的版本:指纹集合不变则版本不变。 */
151
+ export function fingerprintsVersion(map) {
152
+ const keys = [...map.keys()].sort()
153
+ const digest = createHash('sha1')
154
+ for (const key of keys) digest.update(key).update('\u0000').update(map.get(key)).update('\u0001')
155
+ return digest.digest('hex').slice(0, 16)
156
+ }
157
+
158
+ /**
159
+ * 相对上一次指纹表的增量。
160
+ *
161
+ * 入参 `previous` 为 Map 或 null。返回:
162
+ * - `full`:能不能把这份结果当增量发;
163
+ * - `changed`:新增或内容变化的项(`full` 时为整份 items);
164
+ * - `removed`:消失的 sessionId(`full` 时为空);
165
+ * - `version` / `fingerprints`:下一次比较用的版本与指纹表。
166
+ *
167
+ * **身份不可识别时退化为全量**:摘要是优化,不是事实来源。只要有一项没有 `sessionId`,
168
+ * 就没法证明其余项没变;此时宁可多发一份全量,也不能回空增量——后者不报错、不更新,
169
+ * 界面看起来正常却永远冻结(2026-10-27 的现场)。
170
+ */
171
+ export function digestDelta(previous, items) {
172
+ const list = Array.isArray(items) ? items : []
173
+ const identifiable = list.every((item) => item && typeof item.sessionId === 'string')
174
+ const next = digestFingerprints(list)
175
+ const version = fingerprintsVersion(next)
176
+ if (previous === null || !identifiable) {
177
+ return { full: true, changed: list, removed: [], version, fingerprints: next }
178
+ }
179
+ const changed = []
180
+ for (const item of list) {
181
+ if (previous.get(item.sessionId) !== next.get(item.sessionId)) changed.push(item)
182
+ }
183
+ const removed = []
184
+ for (const sessionId of previous.keys()) if (!next.has(sessionId)) removed.push(sessionId)
185
+ return { full: false, changed, removed, version, fingerprints: next }
186
+ }
package/lib/state.js CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * The file holds the long-lived `agentId` / `agentSecret` for this computer.
5
5
  * There are two ways it gets there, and they are deliberately indistinguishable
6
- * afterwards: the relay operator provisions it by hand (relay/admin.py
7
- * `agent-register --write-config`), or the person running the computer redeems
6
+ * afterwards: the relay operator provisions it by hand (`dsh-relay admin
7
+ * agent-register --write-config`), or the person running the computer redeems
8
8
  * an invite code themselves (`dsh-mobile-link enroll`, see enroll.js).
9
9
  * Environment variables and the plugin config block override the file, which is
10
10
  * how the integration test injects a throwaway identity.
@@ -106,10 +106,10 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
106
106
  }
107
107
  if (!resolved.agentId || !resolved.agentSecret) {
108
108
  throw new MissingIdentity(
109
- `mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
110
- + ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
111
- + ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
112
- + ` "python3 relay/admin.py agent-register --account <acc> --name <name>`
109
+ `mobile-link: this computer is not registered yet. Open the "移动端连接" panel`
110
+ + ` and click 登记, or run "dsh-mobile-link enroll --relay <relay-url>".`
111
+ + ` (Provisioning by hand is still possible:`
112
+ + ` "dsh-relay admin agent-register --account <acc> --name <name>`
113
113
  + ` --write-config ${path}".)`,
114
114
  { stateFile: path },
115
115
  )
package/lib/status.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
7
7
  */
8
8
 
9
+ import { isPlaceholderRelayUrl } from './dlp.js'
9
10
  import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
10
11
 
11
12
  const PROTOCOL_VERSION = 1
@@ -81,6 +82,26 @@ const STATUS_JOURNAL_ENTRIES = 20
81
82
  */
82
83
  export function statusSnapshot(agent) {
83
84
  const hostEntry = HOST_HINTS[agent.hostForm] ?? HOST_HINTS.unknown
85
+ // One resolved view of the relay address, shared by the form and the panel:
86
+ // a configured address outranks the identity, but the repository placeholder
87
+ // is "unset", never a value to prefill. `relayLocked` says the address came
88
+ // from config/env, so the form must be read-only — otherwise the panel would
89
+ // enroll with one address and the next boot would dial another.
90
+ const configuredRelay = typeof agent.config?.relayUrl === 'string' ? agent.config.relayUrl : ''
91
+ const realConfiguredRelay = configuredRelay && !isPlaceholderRelayUrl(configuredRelay)
92
+ ? configuredRelay
93
+ : null
94
+ // `defaultRelayUrl` is a *form default*, not a dialing override: it only
95
+ // prefills the input, so the user can replace it and the identity file still
96
+ // wins on the next boot. A real `relayUrl` is the operator pin and locks it.
97
+ const configuredDefault = typeof agent.config?.defaultRelayUrl === 'string'
98
+ ? agent.config.defaultRelayUrl
99
+ : ''
100
+ const defaultRelay = configuredDefault && !isPlaceholderRelayUrl(configuredDefault)
101
+ ? configuredDefault
102
+ : null
103
+ const effectiveRelay = agent.identity?.relayUrl ?? realConfiguredRelay ?? null
104
+ const relayPrefill = realConfiguredRelay ?? defaultRelay ?? agent.identity?.relayUrl ?? null
84
105
  return {
85
106
  ok: true,
86
107
  enabled: agent.enabled !== false,
@@ -102,11 +123,18 @@ export function statusSnapshot(agent) {
102
123
  // computer half is installed but not yet registered, and what to run.
103
124
  registered: !agent.needsEnroll,
104
125
  needsEnroll: agent.needsEnroll,
126
+ // `panel` is a host-UI capability, not a phone capability: an older
127
+ // connector omits it, and the panel falls back to the command. It is
128
+ // deliberately not in SERVER_CAPABILITIES (that list is what the app asks
129
+ // for over `_link/hello`; the phone has no use for a panel form).
130
+ panel: true,
105
131
  stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
106
- relayUrl: agent.config.relayUrl ?? null,
132
+ relayUrl: effectiveRelay,
133
+ relayPrefill,
134
+ relayLocked: Boolean(realConfiguredRelay),
107
135
  command: agent.needsEnroll ? agent.enrollHint() : null,
108
136
  hint: agent.needsEnroll
109
- ? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
137
+ ? '这台电脑还没有登记到中转。在「移动端连接」面板里确认中转地址,然后点「登记」。'
110
138
  : null,
111
139
  },
112
140
  state: agent.state,