dsh-mobile-plugin 0.1.72 → 0.1.74

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.
@@ -459,7 +459,7 @@ document.getElementById('f').addEventListener('submit', async (e) => {
459
459
  ip: suppliedDevice.ip || ip,
460
460
  }
461
461
  onApprove(device)
462
- .then(({ approved, token }) => {
462
+ .then(({ approved, token, platform }) => {
463
463
  if (!approved) {
464
464
  // 与「配对码错误」返回同一响应,配对码不再可通过状态码探测。
465
465
  pairDenied(res);
@@ -479,6 +479,9 @@ document.getElementById('f').addEventListener('submit', async (e) => {
479
479
  deviceId: issued.deviceId,
480
480
  deviceCredential: issued.credential,
481
481
  credentialExpiresAt: issued.expiresAt,
482
+ // 宿主自己的系统标识,不是手机的。手机把它存进设备记录,
483
+ // 好让设备行在第一次 status 轮询之前就画得出图标。
484
+ ...(platform ? { hostPlatform: platform } : {}),
482
485
  }));
483
486
  })
484
487
  .catch((err) => {
@@ -42,4 +42,18 @@ function evaluate(value, device) {
42
42
  }
43
43
  }
44
44
 
45
- module.exports = { POLICIES, DEFAULT_POLICY, normalize, evaluate }
45
+ /**
46
+ * 宿主的系统标识(`process.platform` 的取值:darwin / win32 / linux …)。
47
+ *
48
+ * 与 status 那条路同源,但**送达时机完全不同**:status 要走轮询,手机刚绑好、
49
+ * 还没轮到第一次探测时读不到;图标却要在设备行出现的那一刻就画得出来。
50
+ * 配对是一台设备「是什么」这个事实唯一的确定时刻,所以在这里就把它交给手机,
51
+ * 由手机存进设备记录。轮询回来的平台值只当补充(老设备补登记用),不再承担首次绘制。
52
+ *
53
+ * @returns {{ platform: string }}
54
+ */
55
+ function hostPlatform() {
56
+ return { platform: process.platform }
57
+ }
58
+
59
+ module.exports = { POLICIES, DEFAULT_POLICY, normalize, evaluate, hostPlatform }
@@ -381,12 +381,17 @@ class RelayTunnel {
381
381
  if (this.connected) this.sendJson(T.updatePairing, 0, { pairingCode: code });
382
382
  }
383
383
 
384
- /** 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。 */
385
- sendPairConfirm(requestId, approved, token) {
384
+ /**
385
+ * 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。
386
+ * `hostPlatform` 是**宿主自己的** `process.platform`,经中转原样回给手机,
387
+ * 让配对那一刻就把「这台是什么系统」落盘(设备行不等 status 轮询)。
388
+ */
389
+ sendPairConfirm(requestId, approved, token, hostPlatform) {
386
390
  this.sendJson(T.pairConfirm, 0, {
387
391
  requestId,
388
392
  approved,
389
393
  ...(approved && token ? { token } : {}),
394
+ ...(approved && typeof hostPlatform === 'string' && hostPlatform ? { hostPlatform } : {}),
390
395
  });
391
396
  }
392
397
 
@@ -260,7 +260,9 @@ function registerControlPlaneRoutes(deps) {
260
260
  const decision = pairingApproval.evaluate(state.pairingPolicy, body.device)
261
261
  // Audit trail: the removed prompt showed the device before letting it in.
262
262
  process.stderr.write(`[dsh-mobile-plugin] pairing ${decision.approved ? 'approved' : 'denied'} policy=${decision.policy} device=${decision.device}${decision.ip ? ` ip=${decision.ip}` : ''}\n`)
263
- return sendJson(res, 200, { data: { approved: decision.approved, reason: decision.reason } })
263
+ // `hostPlatform` 一并回给中转:手机要靠它把「这台宿主是什么系统」在配对那一刻就落盘,
264
+ // 不能等到第一次 status 轮询(那之前设备行画不出图标)。被拒时不给,没有意义。
265
+ return sendJson(res, 200, { data: { approved: decision.approved, reason: decision.reason, ...(decision.approved ? pairingApproval.hostPlatform() : {}) } })
264
266
  } })
265
267
  // 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
266
268
  // 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
@@ -162,6 +162,7 @@ function registerMobileRoutes(deps) {
162
162
  route('/api/mobile/v1/directory/create', workspaces.handleCreateDirectory)
163
163
  route('/api/mobile/v1/workspace/create', workspaces.handleCreateWorkspace)
164
164
  route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
165
+ route('/api/mobile/v1/workspace/move', workspaces.handleMoveWorkspace)
165
166
  route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
166
167
  // App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
167
168
  // (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
@@ -173,8 +174,8 @@ function registerMobileRoutes(deps) {
173
174
  route('/api/mobile/v1/workspace/unarchive-session', workspaces.handleUnarchiveSession)
174
175
  route('/api/mobile/v1/workspace/pin-session', workspaces.handlePinSession)
175
176
  route('/api/mobile/v1/workspace/unpin-session', workspaces.handleUnpinSession)
176
- // 「清理所有已归档的对话」(侧栏归档视图的文字按钮)。与 session_cleanup 工具同一口径:
177
- // 只删已归档且不活跃的会话,且 body 必须带 confirm: true(磁盘删除不可恢复)。
177
+ // 「清理所有已归档的对话」(侧栏归档视图的文字按钮):按 workspace.list 的归档集合全量清理,
178
+ // 且 body 必须带 confirm: true(磁盘删除不可恢复)。
178
179
  route('/api/mobile/v1/workspace/clear-archived', archiveClean.handleClearArchived)
179
180
  // 桌面端 `/export` 其实是两步:命令本身只留一行 transcript 标记,真正的下载由
180
181
  // @deepseek-ai/dsh-session-log-export 在浏览器侧监听 command/executed 后拉
@@ -3,17 +3,34 @@
3
3
  *
4
4
  * 两个入口共用 deleteSessionData:DSH agent 工具 registerSessionCleanupTool(action=delete
5
5
  * 必须 confirm:true 且只针对已归档)与移动 API /workspace/clear-archived(archive-clean.js)。
6
+ *
7
+ * 认哪份文件、怎么读 id 都以宿主自己的规则为准,不按目录名猜:
8
+ * - 文件名 = `session[.vN].jsonl[.zstd]`(宿主 `dsh-session-format` 的
9
+ * CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
10
+ * 曾经只认 `session.jsonl.zstd`,于是 0.2.0 真在用的 `session.v4.jsonl.zstd`
11
+ * 一个都认不到 —— 表现是「清理已归档」恒回 `deleted: 0`,静默不删。
12
+ * - 删之前先从宿主归档集合里摘掉这些 id:宿主不会自己修剪,删完还留着就是「已归档」
13
+ * 里一排点不开的死行,且每清一次只增不减。
14
+ * - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
15
+ * 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
16
+ * undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
6
17
  */
7
18
  'use strict'
8
19
 
9
20
  const fs = require('node:fs')
10
21
  const path = require('node:path')
11
- const { execFileSync } = require('node:child_process')
22
+ const zlib = require('node:zlib')
12
23
 
13
24
  const DEFAULT_OLDER_THAN_MINUTES = 30
14
25
  const MINUTES = 60 * 1000
15
26
 
16
- const { envelopeValue } = require('./mobile/envelope.js')
27
+ // 宿主认可的会话日志名。非规范名(迁移中途的临时文件、大写、前导零、带 v0)宿主自己
28
+ // 也不认,这里同样不认 —— 认多了会把没提交的代次当会话删掉。
29
+ const CANONICAL_LOG_FILENAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/
30
+ // 只需要第一行那条 header;给个上限,别为了读一行把整份日志解进内存。
31
+ const HEADER_MAX_BYTES = 64 * 1024
32
+
33
+ const { envelopeValue, unwrap } = require('./mobile/envelope.js')
17
34
 
18
35
  function asItems(response) {
19
36
  const value = envelopeValue(response)
@@ -48,13 +65,28 @@ function projectCount(items, cwd) {
48
65
 
49
66
  const { dshHome } = require('./shared/paths')
50
67
 
51
- function readHeader(file) {
68
+ /** 读日志第一行那条 header(id 的真源)。压缩件用 Node 自带的 zstd 流式解,读到换行就停。 */
69
+ async function readHeader(file) {
70
+ const source = fs.createReadStream(file)
71
+ const stream = file.endsWith('.zstd') ? source.pipe(zlib.createZstdDecompress()) : source
72
+ const chunks = []
73
+ let size = 0
52
74
  try {
53
- const firstLine = execFileSync('zstd', ['-dc', file], { encoding: 'utf8', maxBuffer: 16384 }).split('\n', 1)[0]
54
- return JSON.parse(firstLine)
75
+ for await (const chunk of stream) {
76
+ chunks.push(chunk)
77
+ size += chunk.length
78
+ const joined = Buffer.concat(chunks, size)
79
+ const end = joined.indexOf(0x0a)
80
+ if (end >= 0) return JSON.parse(joined.subarray(0, end).toString('utf8'))
81
+ if (size >= HEADER_MAX_BYTES) return undefined
82
+ }
55
83
  } catch {
56
84
  return undefined
85
+ } finally {
86
+ stream.destroy()
87
+ source.destroy()
57
88
  }
89
+ return undefined
58
90
  }
59
91
 
60
92
  function sessionFiles(root) {
@@ -65,19 +97,19 @@ function sessionFiles(root) {
65
97
  for (const entry of entries) {
66
98
  const file = path.join(directory, entry.name)
67
99
  if (entry.isDirectory()) visit(file)
68
- else if (entry.name === 'session.jsonl.zstd') result.push(file)
100
+ else if (CANONICAL_LOG_FILENAME.test(entry.name)) result.push(file)
69
101
  }
70
102
  }
71
103
  visit(root)
72
104
  return result
73
105
  }
74
106
 
75
- function deleteSessionData(sessionIds) {
107
+ async function deleteSessionData(sessionIds) {
76
108
  const ids = new Set(sessionIds)
77
109
  const removed = []
78
110
  const root = path.join(dshHome(), 'sessions')
79
111
  for (const file of sessionFiles(root)) {
80
- const header = readHeader(file)
112
+ const header = await readHeader(file)
81
113
  if (!header || !ids.has(header.id)) continue
82
114
  try {
83
115
  fs.rmSync(path.dirname(file), { recursive: true, force: true })
@@ -95,6 +127,33 @@ function deleteSessionData(sessionIds) {
95
127
  return removed
96
128
  }
97
129
 
130
+ /**
131
+ * 把已经从磁盘删掉的 id 从宿主归档集合里擦掉(删除路径的最后一步,best-effort)。
132
+ *
133
+ * 宿主不会自己修剪这份集合:日志删了它照样留着那些 id,于是「已归档」里是一排点不开的
134
+ * 死行,而且每清一次就多几条、只增不减。它排在 `deleteSessionData` **之后**:日志没删成
135
+ * 前先擦集合,中途失败就会留下「已取消归档、日志还在」的会话——用户看到的是清理只把会话
136
+ * 移出了归档,还要重启宿主才恢复。先删后擦则日志一没就是真的删掉了,立刻生效;擦失败最多
137
+ * 留一行死行,重试即可。宿主对本来就未归档的 id 是 no-op。
138
+ *
139
+ * 只传**真删掉**的 id:没匹配到日志的还留着日志,擦掉它的名字只会让那一行消失而没有东西被删。
140
+ *
141
+ * 返回擦失败的 id(`failed`),**不抛异常**:日志已经删成功,擦集合坏了不该让整条清理
142
+ * 报错——那等于告诉用户「没删掉」,而事实上已经删掉了。
143
+ */
144
+ async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
145
+ const failed = []
146
+ const unarchive = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.unarchiveSession : undefined
147
+ if (typeof unarchive !== 'function') return failed
148
+ for (const sessionId of sessionIds) {
149
+ try {
150
+ const { error } = unwrap(await unarchive({ rpcId, method: 'workspace.unarchiveSession', payload: { sessionId } }))
151
+ if (error) failed.push(sessionId)
152
+ } catch { failed.push(sessionId) }
153
+ }
154
+ return failed
155
+ }
156
+
98
157
  async function cleanupSessions(ctx, args) {
99
158
  const list = ctx.apiProxy?.sessions?.list
100
159
  const workspaceList = ctx.apiProxy?.workspace?.list
@@ -129,7 +188,11 @@ async function cleanupSessions(ctx, args) {
129
188
  }
130
189
  if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
131
190
  const ids = selected.map(item => item.sessionId)
132
- const deletedIds = deleteSessionData(ids)
191
+ // 与移动端那条路由同一口径:**先删日志、再擦归档集合**。反过来会在链路中途失败时
192
+ // 留下「名字已从归档集合消失、日志还在」的会话(看着像只是被移出了归档,得重启宿主
193
+ // 才恢复);先删则日志一没就是真的删掉了,立刻生效。擦集合是收尾,失败不改变结果。
194
+ const deletedIds = await deleteSessionData(ids)
195
+ await forgetArchivedSessions(ctx, deletedIds, 'dsh-mobile-plugin-session-cleanup')
133
196
  return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
134
197
  }
135
198
 
@@ -156,4 +219,4 @@ function registerSessionCleanupTool(ctx) {
156
219
  }))
157
220
  }
158
221
 
159
- module.exports = { cleanupSessions, registerSessionCleanupTool, deleteSessionData }
222
+ module.exports = { cleanupSessions, registerSessionCleanupTool, deleteSessionData, forgetArchivedSessions }
@@ -211,7 +211,7 @@ function startTunnel() {
211
211
  try {
212
212
  const result = await parentJson('/api/relay/internal/approval', { device: message.device || {} })
213
213
  if (tunnel !== current) return
214
- if (result.approved) current.sendPairConfirm(message.requestId, true, core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC))
214
+ if (result.approved) current.sendPairConfirm(message.requestId, true, core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC), result.platform)
215
215
  else current.sendPairConfirm(message.requestId, false)
216
216
  } catch { if (tunnel === current) current.sendPairConfirm(message.requestId, false) }
217
217
  },
@@ -234,7 +234,11 @@ function startLan() {
234
234
  onApprove: async (device) => {
235
235
  try {
236
236
  const result = await parentJson('/api/relay/internal/approval', { device: device || {} })
237
- return { approved: Boolean(result.approved), token: result.approved ? core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC) : null }
237
+ return {
238
+ approved: Boolean(result.approved),
239
+ token: result.approved ? core.signLanToken(config.lanSecret, config.instanceId, core.MOBILE_BEARER_TTL_SEC) : null,
240
+ platform: typeof result.platform === 'string' ? result.platform : null,
241
+ }
238
242
  } catch { return { approved: false, token: null } }
239
243
  },
240
244
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",