dsh-remote-plugin 0.6.23 → 0.6.25

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.
Binary file
package/gateway-stats.cjs CHANGED
@@ -22,6 +22,17 @@ const path = require('node:path')
22
22
  const os = require('node:os')
23
23
  const { spawn } = require('node:child_process')
24
24
  const readline = require('node:readline')
25
+ const zlib = require('node:zlib')
26
+
27
+ function logGeneration(file) {
28
+ const match = /^session(?:\.v([1-9]\d*))?\.jsonl(?:\.zstd)?$/.exec(path.basename(file))
29
+ return match && Number.isSafeInteger(Number(match[1] || 0)) ? Number(match[1] || 0) : null
30
+ }
31
+
32
+ // V3 preserves message IDs, times and usage while renumbering durable seqs.
33
+ function usageIdentity(event) {
34
+ return JSON.stringify([event.data?.message?.id || '', event.time, normalizeUsage(event)])
35
+ }
25
36
 
26
37
  // ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
27
38
  // 时段判定: 工作日北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点),周末全天谷时
@@ -238,9 +249,9 @@ class StatsStore {
238
249
  return null
239
250
  }
240
251
 
241
- _setCursor(sessionId, lastSeq) {
252
+ _setCursor(sessionId, lastSeq, generation) {
242
253
  const c = this._loadCursors()
243
- c[sessionId] = { lastSeq, updatedAt: Date.now() }
254
+ c[sessionId] = { ...c[sessionId], lastSeq, updatedAt: Date.now(), ...(generation == null ? {} : { generation }) }
244
255
  this._saveCursors()
245
256
  }
246
257
 
@@ -296,102 +307,111 @@ class StatsStore {
296
307
  return this._enqueue(() => this._scanFile(file, onProgress))
297
308
  }
298
309
 
299
- _scanFile(file, onProgress) {
300
- return new Promise((resolvePromise) => {
301
- const sessionId = path.basename(path.dirname(file))
302
- const cur = this._cursor(sessionId)
303
- let lastSeq = cur ? cur.lastSeq : -1
304
- let processed = 0
305
- let currentModel = ''
306
- let headerParsed = false
307
- let lineCount = 0
308
- let yielding = false
309
- const dirtyDays = new Map()
310
- let scanError = ''
311
- const flush = () => {
312
- for (const day of dirtyDays.values()) this._saveDay(day)
313
- dirtyDays.clear()
314
- if (lastSeq >= 0) this._setCursor(sessionId, lastSeq)
315
- }
316
-
317
- const zstd = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
318
- const rl = readline.createInterface({ input: zstd.stdout })
319
-
320
- zstd.on('error', (err) => {
321
- if (err.code === 'ENOENT') {
322
- console.warn(`[stats] 未找到 zstd 命令, 跳过历史回填: ${file}`)
323
- } else {
324
- console.warn(`[stats] zstd 解压失败 ${file}: ${err.message}`)
325
- }
326
- scanError = err.code || err.message
327
- rl.close()
310
+ async _readLog(file, visit) {
311
+ let input, source, child, childResult
312
+ if (!file.endsWith('.zstd')) input = fs.createReadStream(file)
313
+ else if (this.spawn === spawn && typeof zlib.createZstdDecompress === 'function') {
314
+ source = fs.createReadStream(file)
315
+ input = zlib.createZstdDecompress()
316
+ source.on('error', error => input.destroy(error))
317
+ source.pipe(input)
318
+ } else {
319
+ child = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
320
+ input = child.stdout
321
+ childResult = new Promise(resolve => {
322
+ child.once('close', code => resolve({ code }))
323
+ child.once('error', error => resolve({ error }))
328
324
  })
325
+ child.on('error', error => input.destroy(error))
326
+ }
327
+ const rl = readline.createInterface({ input })
328
+ let lines = 0
329
+ try {
330
+ for await (const line of rl) {
331
+ if (!line.trim()) continue
332
+ const event = JSON.parse(line)
333
+ if (Number.isSafeInteger(event.seq)) visit(event)
334
+ if (++lines % 500 === 0) await new Promise(resolve => setImmediate(resolve))
335
+ }
336
+ if (childResult) {
337
+ const result = await childResult
338
+ if (result.error) throw result.error
339
+ if (result.code !== 0) throw new Error('zstd-decompression-failed')
340
+ }
341
+ } finally {
342
+ rl.close()
343
+ input.destroy()
344
+ source?.destroy()
345
+ child?.kill?.()
346
+ }
347
+ }
329
348
 
330
- rl.on('line', (line) => {
331
- lineCount++
332
- let event
333
- try {
334
- event = JSON.parse(line)
335
- } catch {
336
- return
337
- }
338
- // 首行是 session header, 没有 seq
339
- if (!headerParsed) {
340
- headerParsed = true
341
- return
342
- }
343
- if (typeof event.seq !== 'number') return
344
- if (event.seq <= lastSeq) return
345
- if (event.seq > lastSeq + 1) {
346
- // 日志理论上是连续 seq; 出现空洞时以文件为准继续顺序推进(seq 游标按文件顺序)
347
- }
348
- // 跟踪当前模型配置: request/header request/context 都可能带模型
349
- try {
350
- if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
351
- if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
352
- } catch {}
353
- if (event.type === 'assistant/message') {
354
- const usage = normalizeUsage(event)
355
- if (usage) {
356
- const model = eventModel(event) || currentModel || 'unknown'
357
- const { date, hour, period } = eventKey(event.time)
358
- if (date >= PRICING_START_DATE) {
359
- const day = dirtyDays.get(date) || this._loadDay(date)
360
- dirtyDays.set(date, day)
361
- const hourBucket = day.hours[hour] || (day.hours[hour] = {})
362
- const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
363
- addUsage(modelBucket, model, period, usage)
364
- processed++
365
- }
349
+ async _scanFile(file, onProgress) {
350
+ const sessionId = path.basename(path.dirname(file))
351
+ const cur = this._cursor(sessionId)
352
+ const generation = logGeneration(file) ?? 0
353
+ const migrated = cur && generation !== (cur.generation ?? 0)
354
+ let lastSeq = migrated ? -1 : (cur?.lastSeq ?? -1)
355
+ let processed = 0
356
+ let currentModel = ''
357
+ const dirtyDays = new Map()
358
+ const counted = new Map()
359
+ try {
360
+ if (migrated) {
361
+ // Read the preserved predecessor before advancing to a renumbered log.
362
+ // Missing predecessor is an explicit error, never a blind double count.
363
+ const names = await fs.promises.readdir(path.dirname(file))
364
+ const previous = names.find(name => logGeneration(name) === (cur.generation ?? 0))
365
+ if (!previous) throw new Error('stats-migration-source-missing')
366
+ await this._readLog(path.join(path.dirname(file), previous), event => {
367
+ if (event.seq <= cur.lastSeq && event.type === 'assistant/message' && normalizeUsage(event)) {
368
+ const key = usageIdentity(event)
369
+ counted.set(key, (counted.get(key) || 0) + 1)
366
370
  }
367
- }
371
+ })
372
+ }
373
+ await this._readLog(file, event => {
374
+ if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
375
+ if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
376
+ if (event.seq <= lastSeq) return
368
377
  lastSeq = event.seq
369
- // 大历史文件按批次让出事件循环,避免回填长期占住实时 HTTP/WS 处理。
370
- if (lineCount % 500 === 0 && !yielding) {
371
- yielding = true
372
- rl.pause()
373
- setImmediate(() => { yielding = false; rl.resume() })
374
- }
378
+ if (event.type !== 'assistant/message') return
379
+ const usage = normalizeUsage(event)
380
+ if (!usage) return
381
+ const key = usageIdentity(event)
382
+ if (counted.get(key)) { counted.set(key, counted.get(key) - 1); return }
383
+ const model = eventModel(event) || currentModel || 'unknown'
384
+ const { date, hour, period } = eventKey(event.time)
385
+ if (date < PRICING_START_DATE) return
386
+ // Stage detached buckets: a failed read must not mutate cached totals.
387
+ const day = dirtyDays.get(date) || JSON.parse(JSON.stringify(this._loadDay(date)))
388
+ dirtyDays.set(date, day)
389
+ const hourBucket = day.hours[hour] || (day.hours[hour] = {})
390
+ addUsage(hourBucket[model] || (hourBucket[model] = emptyBucket()), model, period, usage)
391
+ processed++
375
392
  })
376
-
377
- rl.on('close', () => {
378
- flush()
379
- if (onProgress) onProgress({ sessionId, processed })
380
- resolvePromise({ sessionId, processed, ...(scanError ? { error: scanError } : {}) })
381
- })
382
- })
393
+ if ([...counted.values()].some(count => count > 0)) throw new Error('stats-migration-usage-mismatch')
394
+ for (const day of dirtyDays.values()) this._saveDay(day)
395
+ if (lastSeq >= 0) this._setCursor(sessionId, lastSeq, generation)
396
+ if (onProgress) onProgress({ sessionId, processed })
397
+ return { sessionId, processed }
398
+ } catch (error) {
399
+ return { sessionId, processed: 0, error: error.code || error.message }
400
+ }
383
401
  }
384
402
 
385
403
  /** 扫描 ~/.dsh/sessions 下全部 session.jsonl.zstd。 */
386
404
  async scanAll(sessionsRoot, onProgress) {
387
- const root = sessionsRoot || path.join(os.homedir(), '.dsh', 'sessions')
405
+ const root = sessionsRoot || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'sessions')
388
406
  let files = []
389
407
  try {
390
408
  const walk = async (dir) => {
391
409
  const entries = await fs.promises.readdir(dir, { withFileTypes: true })
410
+ const logs = entries.filter(ent => ent.isFile() && logGeneration(ent.name) !== null)
411
+ .sort((a, b) => logGeneration(b.name) - logGeneration(a.name) || a.name.localeCompare(b.name))
412
+ if (logs.length) files.push(path.join(dir, logs[0].name))
392
413
  for (const ent of entries) {
393
414
  if (ent.isDirectory()) await walk(path.join(dir, ent.name))
394
- else if (ent.name === 'session.jsonl.zstd') files.push(path.join(dir, ent.name))
395
415
  }
396
416
  }
397
417
  await walk(root)
@@ -400,12 +420,14 @@ class StatsStore {
400
420
  return { files: 0, processed: 0 }
401
421
  }
402
422
  let processed = 0
423
+ const errors = []
403
424
  // 串行扫描, 避免大量并发 zstd 子进程
404
425
  for (const file of files) {
405
426
  const out = await this.scanFile(file, onProgress)
406
427
  processed += out.processed || 0
428
+ if (out.error) errors.push({ sessionId: out.sessionId, error: out.error })
407
429
  }
408
- return { files: files.length, processed }
430
+ return { files: files.length, processed, ...(errors.length ? { errors } : {}) }
409
431
  }
410
432
 
411
433
  summary(days) {
package/gateway.cjs CHANGED
@@ -208,7 +208,7 @@ const MIME = {
208
208
 
209
209
  // ---------- /fs 文件传输 ----------
210
210
  // 允许访问的根目录: DSH_REMOTE_FS_ROOT 使用系统路径分隔符分隔多个根,
211
- // POSIX 为 ':'、Windows 为 ';';默认仅 ~。
211
+ // POSIX 为 ':'、Windows 为 ';';Windows 默认用户目录及 C 盘以外的可用盘符。
212
212
  // 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
213
213
  // 复核一次, 防止 ../ 穿越与符号链接逃逸。
214
214
  const FS_DEFAULT_ROOT = path.resolve(os.homedir())
@@ -218,10 +218,20 @@ function fsConfiguredRoot(value) {
218
218
  if (/^~[\\/]/.test(raw)) return path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
219
219
  return path.resolve(raw)
220
220
  }
221
- const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
222
- .split(path.delimiter)
223
- .map(fsConfiguredRoot)
224
- .filter(Boolean)
221
+ function fsDefaultRoots(platform, home, isDirectory) {
222
+ const roots = [home]
223
+ if (platform === 'win32') {
224
+ for (const letter of 'ABDEFGHIJKLMNOPQRSTUVWXYZ') {
225
+ const root = `${letter}:\\`
226
+ try { if (isDirectory(root)) roots.push(root) } catch {}
227
+ }
228
+ }
229
+ return roots
230
+ }
231
+ const FS_ROOTS = process.env.DSH_REMOTE_FS_ROOT
232
+ ? process.env.DSH_REMOTE_FS_ROOT.split(path.delimiter).map(fsConfiguredRoot).filter(Boolean)
233
+ : fsDefaultRoots(process.platform, FS_DEFAULT_ROOT, root => fs.statSync(root).isDirectory())
234
+ const FS_WINDOWS_DEFAULT = process.platform === 'win32' && !process.env.DSH_REMOTE_FS_ROOT
225
235
  const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
226
236
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
227
237
  const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
@@ -235,6 +245,8 @@ function fsRootReals() {
235
245
  return FS_ROOT_REALS
236
246
  }
237
247
  function fsInsideReal(real) {
248
+ // 默认 Windows 授权不能被已登记工作区或盘符的 junction / SUBST 目标扩大。
249
+ if (FS_WINDOWS_DEFAULT) return FS_ROOTS.some(root => fsInsideRoot(real, root))
238
250
  for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
239
251
  if (fsInsideRoot(real, root)) return true
240
252
  }
@@ -1401,6 +1413,7 @@ const modernState = {
1401
1413
  sessionCursors: new Map(),
1402
1414
  workspaces: { items: [], archivedSessionIds: [] },
1403
1415
  pendingEvents: new Map(),
1416
+ assistantStreams: new Map(),
1404
1417
  }
1405
1418
 
1406
1419
  async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
@@ -1536,7 +1549,7 @@ async function translateModernRpc(method, payload, rpcId) {
1536
1549
  ...(payload.beforeSeq === undefined ? {} : { beforeSeq: payload.beforeSeq }),
1537
1550
  ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
1538
1551
  } }
1539
- transform = value => legacyHistoryValue(value, summary)
1552
+ transform = value => ({ ...legacyHistoryValue(value, summary), ...modernReasoningValue(payload.sessionId) })
1540
1553
  } else if (method === 'session.models') {
1541
1554
  endpoint = 'session/modelCatalog'
1542
1555
  args = {}
@@ -1670,6 +1683,7 @@ function rememberCollectorReplay(kind, full, raw) {
1670
1683
  const replay = collectorReplay[kind]
1671
1684
  let key = ''
1672
1685
  if (payload.type === 'session/subscribed' && payload.sessionId) key = `session:${payload.sessionId}`
1686
+ else if (payload.type === 'session/reasoning' && payload.sessionId) key = `reasoning:${payload.sessionId}`
1673
1687
  else if (payload.type === 'approval/requested' && payload.approvalId) key = `approval:${payload.approvalId}`
1674
1688
  else if (payload.type === 'question/requested' && full.rpcId) key = `question:${full.rpcId}`
1675
1689
  else if (payload.type === 'approval/resolved' && payload.approvalId) replay.delete(`approval:${payload.approvalId}`)
@@ -1940,7 +1954,7 @@ function openModernSessionStream(ws, sessionId) {
1940
1954
  const streamId = 'session:' + sessionId
1941
1955
  ws.send(JSON.stringify({
1942
1956
  type: 'open', streamId, endpoint: 'session/follow',
1943
- payload: { args: { request: { address: { kind: 'session', sessionId } } } },
1957
+ payload: { args: { request: { address: { kind: 'session', sessionId }, assistantStream: true } } },
1944
1958
  }))
1945
1959
  }
1946
1960
 
@@ -1997,8 +2011,59 @@ function applyModernWorkspaceFrame(value) {
1997
2011
  }
1998
2012
  }
1999
2013
 
2014
+ // 0.1.5 streams are process-local and have no durable seq. Keep a bounded,
2015
+ // replaceable reasoning baseline for HTTP history, polling and late WS clients.
2016
+ function modernReasoningValue(sessionId) {
2017
+ const stream = modernState.assistantStreams.get(sessionId)
2018
+ return stream ? { partialReasoning: [...stream.blocks.values()] } : {}
2019
+ }
2020
+
2021
+ function applyModernAssistantStream(sessionId, value, baseline = false) {
2022
+ let stream = modernState.assistantStreams.get(sessionId)
2023
+ const publish = () => legacyPush('mux', { type: 'session/reasoning', sessionId, ...modernReasoningValue(sessionId) })
2024
+ const fold = chunk => {
2025
+ if (!chunk || !Number.isSafeInteger(chunk.index) || chunk.index < 0 || chunk.index >= 64) return false
2026
+ const item = stream.blocks.get(chunk.index) || { turn: stream.turn, step: stream.step, index: chunk.index, text: '' }
2027
+ if (chunk.type === 'reasoning-delta') item.text = (item.text + String(chunk.text || '')).slice(0, 12000)
2028
+ else if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') item.text = ''
2029
+ else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') item.text = String(chunk.block.text || '').slice(0, 12000)
2030
+ else return false
2031
+ stream.blocks.set(chunk.index, item)
2032
+ return true
2033
+ }
2034
+ if (baseline || value?.type === 'start') {
2035
+ const attempt = baseline ? value?.activeAttempt : value
2036
+ stream = { revision: value?.revision || 0, attemptId: attempt?.attemptId, turn: attempt?.turn, step: attempt?.step, nextIndex: attempt?.nextIndex || 0, blocks: new Map() }
2037
+ modernState.assistantStreams.set(sessionId, stream)
2038
+ for (const record of attempt?.stream || []) {
2039
+ if (record.type === 'chunk') fold(record.chunk)
2040
+ else if (record.type === 'reasoning-chunks') fold({ type: 'reasoning-delta', index: record.index, text: (record.texts || []).join('') })
2041
+ }
2042
+ publish()
2043
+ return
2044
+ }
2045
+ if (!stream || value?.revision <= stream.revision) return
2046
+ if (value?.revision !== stream.revision + 1 || value.attemptId !== stream.attemptId || value.index !== stream.nextIndex) {
2047
+ stream.blocks.clear()
2048
+ stream.attemptId = undefined
2049
+ stream.revision = value?.revision || stream.revision
2050
+ publish()
2051
+ return
2052
+ }
2053
+ stream.revision = value.revision
2054
+ if (value.type === 'end') {
2055
+ stream.blocks.clear()
2056
+ stream.attemptId = undefined
2057
+ publish()
2058
+ } else if (value.type === 'chunk') {
2059
+ stream.nextIndex++
2060
+ if (fold(value.chunk)) publish()
2061
+ }
2062
+ }
2063
+
2000
2064
  function applyModernSessionFrame(sessionId, value) {
2001
2065
  if (value?.type === 'snapshot') {
2066
+ if (value.assistantStream) applyModernAssistantStream(sessionId, value.assistantStream, true)
2002
2067
  modernState.sessionCursors.set(sessionId, value.cursor)
2003
2068
  legacyPush('mux', { type: 'session/subscribed', sessionId, lastSeq: value.cursor })
2004
2069
  for (const record of value.records || []) {
@@ -2012,6 +2077,8 @@ function applyModernSessionFrame(sessionId, value) {
2012
2077
  if (value?.type === 'event' && value.event) {
2013
2078
  modernState.sessionCursors.set(sessionId, value.event.seq)
2014
2079
  legacyPush('mux', { type: 'session/event', sessionId, event: value.event })
2080
+ } else if (value?.type === 'assistant-stream') {
2081
+ applyModernAssistantStream(sessionId, value.frame)
2015
2082
  }
2016
2083
  }
2017
2084
 
@@ -2074,6 +2141,8 @@ function applyModernRemoteEvent(ws, value) {
2074
2141
  } else if (value.event === 'api-session/removed') {
2075
2142
  modernState.sessions.delete(args[0])
2076
2143
  modernState.sessionCursors.delete(args[0])
2144
+ modernState.assistantStreams.delete(args[0])
2145
+ collectorReplay.mux.delete(`reasoning:${args[0]}`)
2077
2146
  try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
2078
2147
  legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
2079
2148
  } else if (value.event === 'api-session/status') {
@@ -2229,6 +2298,7 @@ async function scanStatsOnce(delay) {
2229
2298
  try {
2230
2299
  const out = await statsStore.scanAll()
2231
2300
  if (out.files) console.log(`[stats] 历史回填扫描完成: ${out.processed} 个新事件 (${out.files} 个会话文件)`)
2301
+ if (out.errors?.length) console.warn(`[stats] ${out.errors.length} 个会话回填失败,保留原统计游标: ${out.errors.map(item => item.error).join(', ')}`)
2232
2302
  } catch (err) {
2233
2303
  console.warn('[stats] 历史回填扫描失败: ' + (err?.message || err))
2234
2304
  } finally {
@@ -3035,6 +3105,7 @@ async function fsResolve(input) {
3035
3105
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
3036
3106
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
3037
3107
  if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
3108
+ if (FS_WINDOWS_DEFAULT) return { error: 'forbidden' }
3038
3109
  let workspaces = await loadFsWorkspaceRoots(false)
3039
3110
  if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
3040
3111
  // 新建/刚加入的工作区可能还没进入 15s 缓存,未命中时强制刷新一次。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.23",
3
+ "version": "0.6.25",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",