dsh-mobile-plugin 0.1.71 → 0.1.73

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)。地址只在这里
@@ -28,6 +28,7 @@ const stream = require('../mobile/stream.js');
28
28
  const events = require('../mobile/events.js');
29
29
  const questions = require('../mobile/questions.js');
30
30
  const workspaces = require('../mobile/workspaces.js');
31
+ const archiveClean = require('../mobile/archive-clean.js');
31
32
  const sessionLog = require('../mobile/session-log.js');
32
33
  const hostSettings = require('../mobile/host-settings.js');
33
34
  const skills = require('../mobile/skills.js');
@@ -161,6 +162,7 @@ function registerMobileRoutes(deps) {
161
162
  route('/api/mobile/v1/directory/create', workspaces.handleCreateDirectory)
162
163
  route('/api/mobile/v1/workspace/create', workspaces.handleCreateWorkspace)
163
164
  route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
165
+ route('/api/mobile/v1/workspace/move', workspaces.handleMoveWorkspace)
164
166
  route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
165
167
  // App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
166
168
  // (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
@@ -172,6 +174,9 @@ function registerMobileRoutes(deps) {
172
174
  route('/api/mobile/v1/workspace/unarchive-session', workspaces.handleUnarchiveSession)
173
175
  route('/api/mobile/v1/workspace/pin-session', workspaces.handlePinSession)
174
176
  route('/api/mobile/v1/workspace/unpin-session', workspaces.handleUnpinSession)
177
+ // 「清理所有已归档的对话」(侧栏归档视图的文字按钮):按 workspace.list 的归档集合全量清理,
178
+ // 且 body 必须带 confirm: true(磁盘删除不可恢复)。
179
+ route('/api/mobile/v1/workspace/clear-archived', archiveClean.handleClearArchived)
175
180
  // 桌面端 `/export` 其实是两步:命令本身只留一行 transcript 标记,真正的下载由
176
181
  // @deepseek-ai/dsh-session-log-export 在浏览器侧监听 command/executed 后拉
177
182
  // `GET /api/session.export` 完成。手机端没有浏览器下载管理器,而那个端点绑在宿主
@@ -1,13 +1,36 @@
1
+ /**
2
+ * 会话数据清理:按 sessionId 删除 dshHome 下的 sessions/** 目录与 project cache。
3
+ *
4
+ * 两个入口共用 deleteSessionData:DSH agent 工具 registerSessionCleanupTool(action=delete
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。
17
+ */
1
18
  'use strict'
2
19
 
3
20
  const fs = require('node:fs')
4
21
  const path = require('node:path')
5
- const { execFileSync } = require('node:child_process')
22
+ const zlib = require('node:zlib')
6
23
 
7
24
  const DEFAULT_OLDER_THAN_MINUTES = 30
8
25
  const MINUTES = 60 * 1000
9
26
 
10
- 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')
11
34
 
12
35
  function asItems(response) {
13
36
  const value = envelopeValue(response)
@@ -42,13 +65,28 @@ function projectCount(items, cwd) {
42
65
 
43
66
  const { dshHome } = require('./shared/paths')
44
67
 
45
- 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
46
74
  try {
47
- const firstLine = execFileSync('zstd', ['-dc', file], { encoding: 'utf8', maxBuffer: 16384 }).split('\n', 1)[0]
48
- 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
+ }
49
83
  } catch {
50
84
  return undefined
85
+ } finally {
86
+ stream.destroy()
87
+ source.destroy()
51
88
  }
89
+ return undefined
52
90
  }
53
91
 
54
92
  function sessionFiles(root) {
@@ -59,19 +97,19 @@ function sessionFiles(root) {
59
97
  for (const entry of entries) {
60
98
  const file = path.join(directory, entry.name)
61
99
  if (entry.isDirectory()) visit(file)
62
- else if (entry.name === 'session.jsonl.zstd') result.push(file)
100
+ else if (CANONICAL_LOG_FILENAME.test(entry.name)) result.push(file)
63
101
  }
64
102
  }
65
103
  visit(root)
66
104
  return result
67
105
  }
68
106
 
69
- function deleteSessionData(sessionIds) {
107
+ async function deleteSessionData(sessionIds) {
70
108
  const ids = new Set(sessionIds)
71
109
  const removed = []
72
110
  const root = path.join(dshHome(), 'sessions')
73
111
  for (const file of sessionFiles(root)) {
74
- const header = readHeader(file)
112
+ const header = await readHeader(file)
75
113
  if (!header || !ids.has(header.id)) continue
76
114
  try {
77
115
  fs.rmSync(path.dirname(file), { recursive: true, force: true })
@@ -89,6 +127,23 @@ function deleteSessionData(sessionIds) {
89
127
  return removed
90
128
  }
91
129
 
130
+ /**
131
+ * 把马上要从磁盘上删掉的 id 从宿主归档集合里摘掉。
132
+ *
133
+ * 宿主不会自己修剪这份集合:日志删了它照样留着那些 id,于是「已归档」里是一排点不开的
134
+ * 死行,而且每清一次就多几条、只增不减。摘的动作放在删**之前**:摘失败就什么都没丢,
135
+ * 删失败则退化成「已取消归档但没删掉」(会话重新可见、还能再清一次)——比「删干净了却
136
+ * 还挂在归档集合里」好收场。宿主对本来就未归档的 id 是 no-op。
137
+ */
138
+ async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
139
+ const unarchive = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.unarchiveSession : undefined
140
+ if (typeof unarchive !== 'function') throw new Error('会话清理能力不可用:workspace.unarchiveSession')
141
+ for (const sessionId of sessionIds) {
142
+ const { error } = unwrap(await unarchive({ rpcId, method: 'workspace.unarchiveSession', payload: { sessionId } }))
143
+ if (error) throw new Error(`取消归档 ${sessionId} 失败:${error.message || error.code || '未知错误'}`)
144
+ }
145
+ }
146
+
92
147
  async function cleanupSessions(ctx, args) {
93
148
  const list = ctx.apiProxy?.sessions?.list
94
149
  const workspaceList = ctx.apiProxy?.workspace?.list
@@ -123,7 +178,8 @@ async function cleanupSessions(ctx, args) {
123
178
  }
124
179
  if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
125
180
  const ids = selected.map(item => item.sessionId)
126
- const deletedIds = deleteSessionData(ids)
181
+ await forgetArchivedSessions(ctx, ids, 'dsh-mobile-plugin-session-cleanup')
182
+ const deletedIds = await deleteSessionData(ids)
127
183
  return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
128
184
  }
129
185
 
@@ -150,4 +206,4 @@ function registerSessionCleanupTool(ctx) {
150
206
  }))
151
207
  }
152
208
 
153
- module.exports = { cleanupSessions, registerSessionCleanupTool }
209
+ 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.71",
3
+ "version": "0.1.73",
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",