dsh-plugin-mobile-link 0.6.6 → 0.7.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/logs.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * 设备日志落盘(`_link/logs`)。
3
+ *
4
+ * 与 `journal.js` 的分工:journal 记的是**连接器自己**的生命周期事件(几百条、可以随手丢),
5
+ * 这里记的是**手机端**产生的日志行(量级大、需要按时间窗回看)。两者共用"诊断设施不许抛"
6
+ * 这条硬约束,但容量与保留期完全不同。
7
+ *
8
+ * 保留期 **7 天**,清理不靠定时器、也不新增任何进程:每次 `append` 顺手做一次,
9
+ * 且 60 秒内最多一次。理由是不引入定时任务就不会有"任务没跑起来、盘悄悄满了"这种状态。
10
+ *
11
+ * 目录布局:`<root>/<deviceId>/<YYYYMMDD>.jsonl`。按天分文件有三个好处:
12
+ * 清理只需 unlink 整个文件,不必重写;`tail` 只需盯当天的文件;超限时按天淘汰。
13
+ */
14
+
15
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ import { defaultStatePath } from './state.js'
19
+
20
+ /** 日志保留期:7 天。 */
21
+ export const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
22
+ /** 单台设备磁盘上限;超了按天淘汰最旧的。 */
23
+ export const DEFAULT_MAX_DEVICE_BYTES = 200 * 1024 * 1024
24
+ /** 单条日志正文上限;超长截断而不是丢弃(截断仍能看出是哪一行)。 */
25
+ export const MAX_TEXT_CHARS = 4000
26
+ /** 每批最多收多少条、多少字节;超了如实报 `accepted`,剩下的由手机下次推。 */
27
+ export const MAX_BATCH_ENTRIES = 500
28
+ export const MAX_BATCH_BYTES = 64 * 1024
29
+ /** 清理的最小间隔:清理一次最多 60 秒一次。 */
30
+ const PRUNE_INTERVAL_MS = 60 * 1000
31
+
32
+ /** 默认落在身份文件旁边:`$DSH_HOME/mobile-link/logs`。测试用临时 DSH_HOME 自动隔离。 */
33
+ export function defaultLogsRoot() {
34
+ return join(dirname(defaultStatePath()), 'logs')
35
+ }
36
+
37
+ /** 本地日 `YYYYMMDD`,与 journal 的 `local_day` 口径一致。 */
38
+ export function dayStamp(ms) {
39
+ const date = new Date(ms)
40
+ const pad = (value) => String(value).padStart(2, '0')
41
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
42
+ }
43
+
44
+ /** 设备 id 会直接进路径:只留安全字符,避免 `../` 之类的路径穿越。 */
45
+ export function safeDeviceId(deviceId) {
46
+ const text = String(deviceId ?? '').trim()
47
+ if (!text) return 'unknown'
48
+ return text.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 120)
49
+ }
50
+
51
+ /** 一条上报 → 一行落盘 JSON;非法条目返回 null(调用方计入拒收)。 */
52
+ export function normalizeEntry(raw) {
53
+ if (!raw || typeof raw !== 'object') return null
54
+ const ms = Number(raw.ms)
55
+ const text = typeof raw.text === 'string' ? raw.text : ''
56
+ if (!Number.isFinite(ms) || !text) return null
57
+ const seq = Number(raw.seq)
58
+ return {
59
+ seq: Number.isFinite(seq) ? seq : undefined,
60
+ ms,
61
+ at: typeof raw.at === 'string' && raw.at ? raw.at : new Date(ms).toISOString(),
62
+ level: typeof raw.level === 'string' ? raw.level.slice(0, 16) : 'info',
63
+ source: typeof raw.source === 'string' ? raw.source.slice(0, 32) : 'app',
64
+ text: text.slice(0, MAX_TEXT_CHARS),
65
+ }
66
+ }
67
+
68
+ export class LogSpool {
69
+ /**
70
+ * @param {object} [options]
71
+ * @param {string} [options.root] 目录(默认 `$DSH_HOME/mobile-link/logs`)
72
+ * @param {number} [options.retentionMs] 保留期
73
+ * @param {number} [options.maxDeviceBytes] 单设备上限
74
+ * @param {() => number} [options.now]
75
+ * @param {{ debug?: (message: string) => void }} [options.logger]
76
+ */
77
+ constructor({
78
+ root, retentionMs = DEFAULT_RETENTION_MS, maxDeviceBytes = DEFAULT_MAX_DEVICE_BYTES,
79
+ now = Date.now, logger,
80
+ } = {}) {
81
+ this.root = typeof root === 'string' && root.trim() ? root : defaultLogsRoot()
82
+ this.retentionMs = Math.max(60_000, Number(retentionMs) || DEFAULT_RETENTION_MS)
83
+ this.maxDeviceBytes = Math.max(1024, Number(maxDeviceBytes) || DEFAULT_MAX_DEVICE_BYTES)
84
+ this.now = now
85
+ this.logger = logger
86
+ /** 每台设备已接受的最高序号(幂等:同批重推不会写两遍)。 */
87
+ this.watermarks = new Map()
88
+ this._lastPruneAt = 0
89
+ }
90
+
91
+ /** 一台设备当天的文件路径。 */
92
+ pathFor(deviceId, ms = this.now()) {
93
+ return join(this.root, safeDeviceId(deviceId), `${dayStamp(ms)}.jsonl`)
94
+ }
95
+
96
+ /**
97
+ * 收一批并落盘。
98
+ *
99
+ * @returns {{accepted: number, lastSeq: number, dropped: number}}
100
+ * `accepted` 是真正写下去的条数;被水位挡掉的重复条目不计入。
101
+ */
102
+ append(deviceId, entries, { full = false } = {}) {
103
+ const result = { accepted: 0, lastSeq: this.watermarks.get(deviceId) ?? 0, dropped: 0 }
104
+ if (!Array.isArray(entries) || entries.length === 0) return result
105
+ let bytes = 0
106
+ const byDay = new Map()
107
+ for (const raw of entries) {
108
+ const entry = normalizeEntry(raw)
109
+ if (!entry) { result.dropped += 1; continue }
110
+ const seq = Number(entry.seq)
111
+ const watermark = this.watermarks.get(deviceId) ?? 0
112
+ // `full` 是"整段重推"(App 冷启动回填或 `dsh://diag export`):
113
+ // 水位不参与判断,但仍然照常推进,避免之后的增量重复。
114
+ if (!full && Number.isFinite(seq) && seq <= watermark) continue
115
+ const line = `${JSON.stringify({ ...entry, deviceId, full: full || undefined })}\n`
116
+ if (result.accepted > 0 && bytes + line.length > MAX_BATCH_BYTES) break
117
+ if (result.accepted >= MAX_BATCH_ENTRIES) break
118
+ const day = dayStamp(entry.ms)
119
+ const bucket = byDay.get(day) ?? []
120
+ bucket.push(line)
121
+ byDay.set(day, bucket)
122
+ bytes += line.length
123
+ result.accepted += 1
124
+ result.lastSeq = Math.max(result.lastSeq, Number.isFinite(seq) ? seq : result.lastSeq)
125
+ }
126
+ if (!byDay.size) return result
127
+ for (const [day, lines] of byDay) this.#write(deviceId, day, lines)
128
+ this.watermarks.set(deviceId, result.lastSeq)
129
+ this.#pruneIfDue()
130
+ return result
131
+ }
132
+
133
+ #write(deviceId, day, lines) {
134
+ const path = join(this.root, safeDeviceId(deviceId), `${day}.jsonl`)
135
+ try {
136
+ mkdirSync(dirname(path), { recursive: true })
137
+ appendFileSync(path, lines.join(''))
138
+ } catch (error) {
139
+ // 诊断设施不许把链路带崩:写不进去就只报一声。
140
+ this.logger?.debug?.(`mobile-link: logs spool write failed (${error?.message ?? error})`)
141
+ }
142
+ }
143
+
144
+ /** 60 秒内最多清理一次;清理本身不许抛。 */
145
+ #pruneIfDue() {
146
+ const now = this.now()
147
+ if (now - this._lastPruneAt < PRUNE_INTERVAL_MS) return
148
+ this._lastPruneAt = now
149
+ try {
150
+ this.prune(now)
151
+ } catch (error) {
152
+ this.logger?.debug?.(`mobile-link: logs prune failed (${error?.message ?? error})`)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 清理:先按保留期删整天文件,再按单设备上限删最旧的。
158
+ * 返回删掉的字节数(测试用;生产只看副作用)。
159
+ */
160
+ prune(now = this.now()) {
161
+ let freed = 0
162
+ let devices = []
163
+ try {
164
+ devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
165
+ } catch {
166
+ return 0
167
+ }
168
+ for (const device of devices) {
169
+ const dir = join(this.root, device.name)
170
+ let files = []
171
+ try {
172
+ files = readdirSync(dir).filter((name) => name.endsWith('.jsonl'))
173
+ } catch {
174
+ continue
175
+ }
176
+ if (!files.length) {
177
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 目录删不掉无所谓 */ }
178
+ continue
179
+ }
180
+ const stats = []
181
+ for (const name of files.sort()) {
182
+ const path = join(dir, name)
183
+ try {
184
+ const info = statSync(path)
185
+ stats.push({ path, day: name.slice(0, 8), size: info.size, mtimeMs: info.mtimeMs })
186
+ } catch { /* 文件刚被删掉 */ }
187
+ }
188
+ // 保留期按**文件名里的日**判定,不看 mtime:mtime 会被备份、拷贝、恢复改动,
189
+ // 而"这是哪一天的日志"只写在名字里,判定必须与名字一致。
190
+ const cutoff = dayStamp(now - this.retentionMs)
191
+ for (const file of stats) {
192
+ if (file.day >= cutoff) continue
193
+ freed += this.#unlink(file)
194
+ }
195
+ let kept = stats.filter((file) => file.day >= cutoff)
196
+ let total = kept.reduce((sum, file) => sum + file.size, 0)
197
+ for (const file of kept) {
198
+ if (total <= this.maxDeviceBytes) break
199
+ total -= file.size
200
+ freed += this.#unlink(file)
201
+ }
202
+ }
203
+ return freed
204
+ }
205
+
206
+ #unlink(file) {
207
+ try {
208
+ unlinkSync(file.path)
209
+ return file.size
210
+ } catch {
211
+ return 0
212
+ }
213
+ }
214
+
215
+ /** 某台设备(或全部)的磁盘占用,给人看的。 */
216
+ usage() {
217
+ const rows = []
218
+ let devices = []
219
+ try {
220
+ devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
221
+ } catch {
222
+ return rows
223
+ }
224
+ for (const device of devices) {
225
+ const dir = join(this.root, device.name)
226
+ let bytes = 0
227
+ let files = 0
228
+ let newest = 0
229
+ try {
230
+ for (const name of readdirSync(dir)) {
231
+ if (!name.endsWith('.jsonl')) continue
232
+ const info = statSync(join(dir, name))
233
+ bytes += info.size
234
+ files += 1
235
+ newest = Math.max(newest, info.mtimeMs)
236
+ }
237
+ } catch { /* 忽略单台设备的读失败 */ }
238
+ rows.push({ deviceId: device.name, bytes, files, newestMs: newest })
239
+ }
240
+ return rows.sort((a, b) => b.bytes - a.bytes)
241
+ }
242
+ }
package/lib/pairing.js CHANGED
@@ -17,7 +17,7 @@ import { pairCodeEndpoint, pairCodeRegenerateEndpoint, qrPayload } from './dlp.j
17
17
  */
18
18
  export async function mintPairCode({ identity, ttlMs = null, fetchImpl = fetch }) {
19
19
  // 032:**持久码那条路不能把 `ttlMs` 放进请求体**。中转的判据是 `"ttlMs" not in body`
20
- // (`relay/api.py` 的 `pair_code`):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
20
+ // (`relay-go/internal/httpapi` 的 pair-code 处理):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
21
21
  // 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
22
22
  const payload = ttlMs == null
23
23
  ? { agentId: identity.agentId }
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,66 @@ 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
+ const incremental = Boolean(previous && previous.version === since)
663
+ const delta = digestDelta(incremental ? previous.fingerprints : null, items)
664
+ this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
665
+ if (this.digestCursors.size > 32) {
666
+ const oldest = this.digestCursors.keys().next().value
667
+ this.digestCursors.delete(oldest)
668
+ }
669
+ const value = {
670
+ ...trimmed,
671
+ items: incremental ? delta.changed : items,
672
+ removed: incremental ? delta.removed : [],
673
+ version: delta.version,
674
+ full: !incremental,
675
+ }
676
+ payload = { ok: true, value: encodeDigest(value, args.encoding) }
677
+ }
581
678
  this.send(deviceId, resultFrame(frame.id, payload, deviceId))
582
679
  return
583
680
  }
584
681
 
682
+ // 目录清单的本机增强(设计 20261024):连接器读宿主的 `workspaceFiles/list`,
683
+ // 在电脑本机对每个条目 `fs.stat` 补一个 `mtimeMs`——宿主没有时间字段,而
684
+ // `workspaceFiles/stat` 的 `version` 是 opaque token(推不出时间)。
685
+ // 与 sessionDigest 同款:清单的内容仍是宿主的,连接器只加字段;任何一步出问题
686
+ // 都退回未增强的原始清单(装饰永不弄坏清单)。
687
+ if (frame.method === DIR_LISTING_METHOD) {
688
+ let result
689
+ try {
690
+ // `dirListing` 认宿主信封本身(`{ ok, value: { entries } }`)并原样回同形状的值:
691
+ // 宿主报错时**信封逐字透传**,`resultFrame` 照常把它翻成 `ok:false` + 原错误码。
692
+ // 这里不能再包一层 `{ ok: true, value }`——那会把宿主的失败塞进 `value` 里,
693
+ // 帧上反而成了 `ok:true`(手机就会把"目录不存在"读成"空目录")。
694
+ result = await dirListing({ dsh: this.dsh, args: frame.args ?? {}, logger: this.logger })
695
+ } catch (error) {
696
+ result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
697
+ }
698
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
699
+ return
700
+ }
701
+
585
702
  if (frame.method === DIAGNOSTICS_METHOD) {
586
703
  const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
587
704
  this.send(deviceId, resultFrame(frame.id, result, deviceId))
588
705
  return
589
706
  }
590
707
 
708
+ if (frame.method === LOGS_METHOD) {
709
+ const result = this.acceptLogs(deviceId, frame.args ?? {})
710
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
711
+ return
712
+ }
713
+
591
714
  // Files the phone sends are answered here rather than forwarded: the Host
592
715
  // has no upload endpoint, and carrying them on the existing unary frame
593
716
  // means neither the relay nor the protocol has to change.
@@ -667,6 +790,22 @@ export class DeviceRouter {
667
790
  return
668
791
  }
669
792
 
793
+ // 活动名单(spec 018)与 history 同一条短路:连接器自己回答,不转发宿主。
794
+ // 没挂上宿主 `ctx.jobs` 时回 `link/unsupported`——**不**回空名单,否则手机
795
+ // 会把"这台连接器不支持"显示成"没有任务在跑"。
796
+ if (isJobsMethod(frame.method)) {
797
+ let result
798
+ try {
799
+ result = this.jobsRoster === undefined
800
+ ? { ok: false, error: errorObject('link/unsupported', 'job roster is unavailable on this connector') }
801
+ : await this.jobsRoster.handle(frame.args ?? {})
802
+ } catch (error) {
803
+ result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
804
+ }
805
+ this.send(deviceId, resultFrame(frame.id, result, deviceId))
806
+ return
807
+ }
808
+
670
809
  let result
671
810
  try {
672
811
  result = await this.dsh.rpc(frame.method, frame.args ?? {})
@@ -831,12 +970,38 @@ export class DeviceRouter {
831
970
  record.readyValue = value
832
971
  this.#changed()
833
972
  }
973
+ this.#sendEventItem(deviceId, record, value)
974
+ }
975
+
976
+ /**
977
+ * 向一台设备投递一条 `$events` 形态的项(item,或未开流时的 event 帧)。
978
+ *
979
+ * 抽出来是为了让连接器自造的帧(`link/jobs`,见 `publishEvent`)与宿主事件走**同一段**
980
+ * 投递口径,帧语义不变:开了 `$events` 的按 dlpId 走 item,没开的按 event 广播。
981
+ */
982
+ #sendEventItem(deviceId, record, value) {
834
983
  for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
835
984
  // Spec §4.1.5: every $events item is broadcast as an `event` frame until the
836
985
  // device opens the stream explicitly, so nothing is delivered twice.
837
986
  if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
838
987
  }
839
988
 
989
+ /**
990
+ * 把一条连接器自造的项广播给**每台在线设备**(活动名单的 `link/jobs`,spec 018)。
991
+ *
992
+ * 与 `forwardEvent` 的区别是来源:那条项来自某台设备自己的 `$events` 流,只回给它;
993
+ * 这条是宿主任务事件的产物,没有归属设备,所以每台在线设备各收一份。
994
+ *
995
+ * **离线设备不推**:`$events` 还替它留着(见 `detachDevice`),但旧任务状态没有补发
996
+ * 意义——重连后手机重新问一次 `_link/jobRoster` 即可(契约 §3)。
997
+ */
998
+ publishEvent(value) {
999
+ for (const record of this.devices.values()) {
1000
+ if (record.offlineSince !== undefined) continue
1001
+ this.#sendEventItem(record.deviceId, record, value)
1002
+ }
1003
+ }
1004
+
840
1005
  /**
841
1006
  * 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
842
1007
  *
@@ -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,50 @@ 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
+ /** 一批会话的指纹表(id → 指纹)。 */
137
+ export function digestFingerprints(items) {
138
+ const map = new Map()
139
+ for (const item of items ?? []) {
140
+ if (item && typeof item.id === 'string') map.set(item.id, digestFingerprint(item))
141
+ }
142
+ return map
143
+ }
144
+
145
+ /** 指纹表的版本:指纹集合不变则版本不变。 */
146
+ export function fingerprintsVersion(map) {
147
+ const keys = [...map.keys()].sort()
148
+ const digest = createHash('sha1')
149
+ for (const key of keys) digest.update(key).update('\u0000').update(map.get(key)).update('\u0001')
150
+ return digest.digest('hex').slice(0, 16)
151
+ }
152
+
153
+ /**
154
+ * 相对上一次指纹表的增量。
155
+ *
156
+ * 入参 `previous` 为 Map 或 null;返回 changed(新增或内容变化的项)、
157
+ * removed(消失的 id)与新版本。`previous` 为空时调用方应按全量处理。
158
+ */
159
+ export function digestDelta(previous, items) {
160
+ const list = Array.isArray(items) ? items : []
161
+ const next = digestFingerprints(list)
162
+ const changed = []
163
+ for (const item of list) {
164
+ if (!item || typeof item.id !== 'string') continue
165
+ if (!previous || previous.get(item.id) !== next.get(item.id)) changed.push(item)
166
+ }
167
+ const removed = []
168
+ if (previous) {
169
+ for (const id of previous.keys()) if (!next.has(id)) removed.push(id)
170
+ }
171
+ return { changed, removed, version: fingerprintsVersion(next), fingerprints: next }
172
+ }
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.
@@ -109,7 +109,7 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
109
109
  `mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
110
110
  + ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
111
111
  + ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
112
- + ` "python3 relay/admin.py agent-register --account <acc> --name <name>`
112
+ + ` "dsh-relay admin agent-register --account <acc> --name <name>`
113
113
  + ` --write-config ${path}".)`,
114
114
  { stateFile: path },
115
115
  )