@gehennawu/dsh-service 0.11.1

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/index.js ADDED
@@ -0,0 +1,1128 @@
1
+ // Host half of @gehennawu/dsh-service
2
+ // 「服务控制」:版本信息 + 检查更新 + 一键重启 dsh web
3
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto'
4
+ import { Buffer } from 'node:buffer'
5
+ import { constants as fsConstants } from 'node:fs'
6
+ import { access, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import { basename, join, relative, resolve, sep } from 'node:path'
9
+ import { createRequire } from 'node:module'
10
+ import https from 'node:https'
11
+
12
+ const require = createRequire(import.meta.url)
13
+ const name = 'dsh-service'
14
+ const inject = ['connection']
15
+ const DSH_PACKAGE = '@deepseek-ai/dsh'
16
+ const PLUGIN_PACKAGE = '@gehennawu/dsh-service'
17
+ const NPM_REGISTRY = 'https://registry.npmjs.org/'
18
+ const MAX_NPM_RESPONSE_BYTES = 256 * 1024
19
+ const MAX_BACKUP_TRANSFER_BYTES = 128 * 1024 * 1024
20
+ const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
21
+ const backupIdSecret = randomBytes(32)
22
+ const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
23
+ const USAGE_INDEX_VERSION = 4
24
+ const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
25
+
26
+ // 读取当前 dsh 版本。DSH 包由宿主安装,不作为插件依赖打包进来。
27
+ let dshVersion = 'unknown'
28
+ let pluginVersion = 'unknown'
29
+ try { pluginVersion = require('./package.json').version } catch (_) {}
30
+ try {
31
+ dshVersion = require(`${DSH_PACKAGE}/package.json`).version
32
+ } catch (_) {
33
+ try {
34
+ dshVersion = require('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json').version
35
+ } catch (__) {}
36
+ }
37
+
38
+ function isSemverIdentifier(value) {
39
+ if (value.length === 0) return false
40
+ return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
41
+ }
42
+
43
+ function isNumericSemverIdentifier(value) {
44
+ return value.length > 0 && [...value].every((char) => char >= '0' && char <= '9')
45
+ }
46
+
47
+ function parseSemver(value) {
48
+ if (typeof value !== 'string') return null
49
+ const trimmed = value.trim()
50
+ const buildParts = trimmed.split('+')
51
+ if (buildParts.length > 2 || (buildParts.length === 2 && !buildParts[1].split('.').every((part) => isSemverIdentifier(part)))) return null
52
+ const versionPart = buildParts[0]
53
+ const dashIndex = versionPart.indexOf('-')
54
+ const corePart = dashIndex === -1 ? versionPart : versionPart.slice(0, dashIndex)
55
+ const prereleasePart = dashIndex === -1 ? '' : versionPart.slice(dashIndex + 1)
56
+ if (dashIndex !== -1 && prereleasePart.length === 0) return null
57
+ const core = corePart.split('.')
58
+ if (core.length !== 3 || !core.every((part) => isNumericSemverIdentifier(part) && (part.length === 1 || !part.startsWith('0')))) return null
59
+ const prerelease = prereleasePart === '' ? [] : prereleasePart.split('.')
60
+ if (!prerelease.every((part) => isSemverIdentifier(part) && !(isNumericSemverIdentifier(part) && part.length > 1 && part.startsWith('0')))) return null
61
+ return { major: Number(core[0]), minor: Number(core[1]), patch: Number(core[2]), prerelease }
62
+ }
63
+
64
+ function compareSemver(left, right) {
65
+ const a = parseSemver(left)
66
+ const b = parseSemver(right)
67
+ if (!a || !b) return 0
68
+ for (const key of ['major', 'minor', 'patch']) {
69
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1
70
+ }
71
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
72
+ if (a.prerelease.length === 0) return 1
73
+ if (b.prerelease.length === 0) return -1
74
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
75
+ for (let index = 0; index < length; index += 1) {
76
+ if (index >= a.prerelease.length) return -1
77
+ if (index >= b.prerelease.length) return 1
78
+ const leftPart = a.prerelease[index]
79
+ const rightPart = b.prerelease[index]
80
+ if (leftPart === rightPart) continue
81
+ const leftNumeric = /^[0-9]+$/.test(leftPart)
82
+ const rightNumeric = /^[0-9]+$/.test(rightPart)
83
+ if (leftNumeric && rightNumeric) return Number(leftPart) > Number(rightPart) ? 1 : -1
84
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
85
+ return leftPart > rightPart ? 1 : -1
86
+ }
87
+ return 0
88
+ }
89
+
90
+ function atLeastSemver(current, target) {
91
+ if (parseSemver(current) && parseSemver(target)) return compareSemver(current, target) >= 0
92
+ return current === target
93
+ }
94
+
95
+ // 只请求固定的 npm registry 包元数据:不接受来自浏览器的 URL 或包名,避免 SSRF。
96
+ function fetchPublishedVersions(packageName) {
97
+ return new Promise((resolve, reject) => {
98
+ let settled = false
99
+ const fail = (error) => {
100
+ if (settled) return
101
+ settled = true
102
+ reject(error)
103
+ }
104
+ const succeed = (value) => {
105
+ if (settled) return
106
+ settled = true
107
+ resolve(value)
108
+ }
109
+
110
+ const url = NPM_REGISTRY + encodeURIComponent(packageName)
111
+ const request = https.get(url, {
112
+ timeout: 10000,
113
+ headers: {
114
+ Accept: 'application/json',
115
+ 'User-Agent': 'dsh-service',
116
+ },
117
+ }, (response) => {
118
+ const status = response.statusCode || 0
119
+ if (status < 200 || status >= 300) {
120
+ response.resume()
121
+ fail(new Error(`npm registry 返回 HTTP ${status}`))
122
+ return
123
+ }
124
+
125
+ let body = ''
126
+ let bytes = 0
127
+ response.setEncoding('utf8')
128
+ response.on('data', (chunk) => {
129
+ bytes += Buffer.byteLength(chunk)
130
+ if (bytes > MAX_NPM_RESPONSE_BYTES) {
131
+ fail(new Error('npm registry 响应过大'))
132
+ request.destroy()
133
+ return
134
+ }
135
+ body += chunk
136
+ })
137
+ response.on('error', fail)
138
+ response.on('end', () => {
139
+ if (settled) return
140
+ try {
141
+ const data = JSON.parse(body)
142
+ const distTags = data?.['dist-tags'] || {}
143
+ const normalizeTag = (value) => {
144
+ const version = typeof value === 'string' ? value.trim() : ''
145
+ return parseSemver(version) === null ? null : version
146
+ }
147
+ const tags = {
148
+ latest: normalizeTag(distTags.latest),
149
+ next: normalizeTag(distTags.next),
150
+ }
151
+ const versions = [tags.latest, tags.next].filter((version) => parseSemver(version) !== null)
152
+ if (versions.length === 0) {
153
+ fail(new Error('npm 响应中没有有效的 latest 或 next 版本'))
154
+ return
155
+ }
156
+ const latest = versions.reduce((selected, version) => compareSemver(version, selected) > 0 ? version : selected)
157
+ succeed({ latest, tags })
158
+ } catch (_) {
159
+ fail(new Error('解析 npm 响应失败'))
160
+ }
161
+ })
162
+ })
163
+ request.on('error', fail)
164
+ request.on('timeout', () => {
165
+ request.destroy()
166
+ fail(new Error('请求 npm registry 超时'))
167
+ })
168
+ })
169
+ }
170
+
171
+ function resolveDshHome() {
172
+ const configured = process.env.DSH_HOME?.trim()
173
+ return configured ? configured : join(homedir(), '.dsh')
174
+ }
175
+
176
+ function formatBackupTimestamp(date) {
177
+ const digits = (value) => String(value).padStart(2, '0')
178
+ return `${date.getFullYear()}${digits(date.getMonth() + 1)}${digits(date.getDate())}-${digits(date.getHours())}${digits(date.getMinutes())}${digits(date.getSeconds())}`
179
+ }
180
+
181
+ function backupId(name) {
182
+ return createHmac('sha256', backupIdSecret).update(name).digest('base64url')
183
+ }
184
+
185
+ async function pathExists(path) {
186
+ try {
187
+ await stat(path)
188
+ return true
189
+ } catch (error) {
190
+ if (error?.code === 'ENOENT') return false
191
+ throw error
192
+ }
193
+ }
194
+
195
+ async function listBackups(dshHome) {
196
+ const backupDir = join(dshHome, 'backups')
197
+ await mkdir(backupDir, { recursive: true, mode: 0o700 })
198
+ const entries = await readdir(backupDir, { withFileTypes: true })
199
+ const items = []
200
+ for (const entry of entries) {
201
+ if (!entry.isFile() || !BACKUP_NAME.test(entry.name)) continue
202
+ const info = await stat(join(backupDir, entry.name))
203
+ items.push({
204
+ id: backupId(entry.name),
205
+ name: entry.name,
206
+ sizeBytes: info.size,
207
+ createdAt: info.mtime.toISOString(),
208
+ })
209
+ }
210
+ items.sort((a, b) => b.name.localeCompare(a.name))
211
+ return { items, totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0) }
212
+ }
213
+
214
+ async function runTar(ctx, cwd, argv) {
215
+ const subprocess = ctx.get('subprocess')
216
+ if (subprocess === undefined) throw new Error('subprocess-unavailable')
217
+ const executable = await subprocess.resolveExecutable('tar')
218
+ const handle = subprocess.spawn({
219
+ argv: [executable, ...argv],
220
+ cwd,
221
+ stdio: {
222
+ stdin: 'ignore',
223
+ stdout: { maxBytes: 16 * 1024 },
224
+ stderr: { maxBytes: 64 * 1024 },
225
+ },
226
+ graceMs: 5000,
227
+ })
228
+ const outcome = await handle.done
229
+ const stderr = handle.collected.stderr?.readFrom(0).text || ''
230
+ if (outcome.exitCode !== 0 || outcome.signal !== null) {
231
+ throw new Error(`tar-failed: ${stderr.trim() || outcome.signal || outcome.exitCode}`)
232
+ }
233
+ }
234
+
235
+ async function createBackup(ctx, dshHome) {
236
+ const backupDir = join(dshHome, 'backups')
237
+ await mkdir(backupDir, { recursive: true, mode: 0o700 })
238
+ const workspace = join(backupDir, `.staging-${randomUUID()}`)
239
+ await mkdir(workspace, { recursive: true, mode: 0o700 })
240
+ try {
241
+ const sessions = join(dshHome, 'sessions')
242
+ if (await pathExists(sessions)) await cp(sessions, join(workspace, 'sessions'), { recursive: true })
243
+ else await mkdir(join(workspace, 'sessions'), { recursive: true })
244
+
245
+ const configDir = join(workspace, 'config')
246
+ await mkdir(configDir, { recursive: true })
247
+ for (const file of ['settings.yaml', 'cordis.patch.yml', 'AGENTS.md']) {
248
+ const source = join(dshHome, file)
249
+ if (await pathExists(source)) await cp(source, join(configDir, file))
250
+ }
251
+
252
+ const profilesSource = join(dshHome, 'profiles')
253
+ const profilesTarget = join(workspace, 'profiles')
254
+ await mkdir(profilesTarget, { recursive: true })
255
+ if (await pathExists(profilesSource)) {
256
+ for (const entry of await readdir(profilesSource, { withFileTypes: true })) {
257
+ if (!entry.isDirectory()) continue
258
+ const manifest = join(profilesSource, entry.name, 'package.json')
259
+ if (!(await pathExists(manifest))) continue
260
+ const target = join(profilesTarget, entry.name)
261
+ await mkdir(target, { recursive: true })
262
+ await cp(manifest, join(target, 'package.json'))
263
+ }
264
+ }
265
+
266
+ const name = `dsh-backup-${formatBackupTimestamp(new Date())}.tar.gz`
267
+ if (await pathExists(join(backupDir, name))) throw new Error('backup-name-collision')
268
+ const temporary = join(backupDir, `.${name}.${randomUUID()}.tmp`)
269
+ try {
270
+ await runTar(ctx, workspace, ['-czf', temporary, 'sessions', 'config', 'profiles'])
271
+ await rename(temporary, join(backupDir, name))
272
+ } finally {
273
+ await rm(temporary, { force: true })
274
+ }
275
+ const snapshot = await listBackups(dshHome)
276
+ return { item: snapshot.items.find((item) => item.name === name), ...snapshot }
277
+ } finally {
278
+ await rm(workspace, { recursive: true, force: true })
279
+ }
280
+ }
281
+
282
+ async function deleteBackup(dshHome, id) {
283
+ if (typeof id !== 'string' || id.length === 0) return undefined
284
+ const snapshot = await listBackups(dshHome)
285
+ const item = snapshot.items.find((candidate) => candidate.id === id)
286
+ if (item === undefined) return undefined
287
+ await unlink(join(dshHome, 'backups', basename(item.name)))
288
+ return listBackups(dshHome)
289
+ }
290
+
291
+ async function importBackup(dshHome, name, encoded) {
292
+ if (typeof name !== 'string' || !BACKUP_NAME.test(name) || typeof encoded !== 'string' || encoded.length === 0) return undefined
293
+ const data = Buffer.from(encoded, 'base64')
294
+ if (data.length === 0 || data.length > MAX_BACKUP_TRANSFER_BYTES) return undefined
295
+ const backupDir = join(dshHome, 'backups')
296
+ await mkdir(backupDir, { recursive: true, mode: 0o700 })
297
+ const target = join(backupDir, basename(name))
298
+ if (basename(name) !== name || await pathExists(target)) return undefined
299
+ const temporary = join(backupDir, `.${name}.${randomUUID()}.import`)
300
+ try {
301
+ await writeFile(temporary, data, { mode: 0o600 })
302
+ await rename(temporary, target)
303
+ } finally {
304
+ await rm(temporary, { force: true })
305
+ }
306
+ return listBackups(dshHome)
307
+ }
308
+
309
+ function usageHour(time) {
310
+ return new Date(time).toISOString().slice(0, 13)
311
+ }
312
+
313
+ function localDayForHour(hour, timezoneOffsetMinutes = 0) {
314
+ const offset = Number.isFinite(Number(timezoneOffsetMinutes)) ? Math.max(-840, Math.min(840, Number(timezoneOffsetMinutes))) : 0
315
+ const utcTime = Date.parse(`${hour}:00:00.000Z`)
316
+ return new Date(utcTime - offset * 60 * 1000).toISOString().slice(0, 10)
317
+ }
318
+
319
+ function emptyUsageTotals() {
320
+ return { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
321
+ }
322
+
323
+ function addUsageTotals(target, source) {
324
+ target.steps += source.steps || 0
325
+ target.missingUsage += source.missingUsage || 0
326
+ target.inputTokens += source.inputTokens || 0
327
+ target.outputTokens += source.outputTokens || 0
328
+ target.cacheReadTokens += source.cacheReadTokens || 0
329
+ target.cacheWriteTokens += source.cacheWriteTokens || 0
330
+ return target
331
+ }
332
+
333
+ function cacheHitRate(totals) {
334
+ const denominator = totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens
335
+ return denominator === 0 ? 0 : totals.cacheReadTokens / denominator
336
+ }
337
+
338
+ function projectForCwd(ctx, cwd) {
339
+ if (typeof cwd !== 'string' || cwd.length === 0) return { id: 'ungrouped', title: 'Ungrouped', path: null }
340
+ const registry = ctx.get('workspaceRegistry')
341
+ let workspaces = []
342
+ if (registry !== undefined) {
343
+ try {
344
+ const listed = registry.list()
345
+ workspaces = Array.isArray(listed) ? listed : [...listed]
346
+ } catch (_) {}
347
+ }
348
+ const absoluteCwd = resolve(cwd)
349
+ let selected
350
+ for (const workspace of workspaces) {
351
+ const workspacePath = resolve(String(workspace.path))
352
+ const child = relative(workspacePath, absoluteCwd)
353
+ if (child === '..' || child.startsWith(`..${sep}`) || resolve(workspacePath, child) !== absoluteCwd) continue
354
+ if (selected === undefined || workspacePath.length > selected.path.length) {
355
+ selected = { id: String(workspace.id), title: String(workspace.title || workspace.id), path: workspacePath }
356
+ }
357
+ }
358
+ return selected || { id: `cwd:${absoluteCwd}`, title: basename(absoluteCwd) || absoluteCwd, path: absoluteCwd }
359
+ }
360
+
361
+ function revisionKey(revision) {
362
+ return typeof revision === 'string' ? revision : JSON.stringify(revision)
363
+ }
364
+
365
+ function createUsageIndex() {
366
+ return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {} }
367
+ }
368
+
369
+ async function loadUsageIndex(dshHome) {
370
+ try {
371
+ const parsed = JSON.parse(await readFile(join(dshHome, USAGE_INDEX_FILE), 'utf8'))
372
+ if (parsed?.version !== USAGE_INDEX_VERSION || typeof parsed.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
373
+ return parsed
374
+ } catch (error) {
375
+ if (error?.code === 'ENOENT') return createUsageIndex()
376
+ return createUsageIndex()
377
+ }
378
+ }
379
+
380
+ async function saveUsageIndex(dshHome, index) {
381
+ await mkdir(dshHome, { recursive: true })
382
+ const target = join(dshHome, USAGE_INDEX_FILE)
383
+ const temporary = `${target}.${randomUUID()}.tmp`
384
+ try {
385
+ await writeFile(temporary, JSON.stringify(index), { mode: 0o600 })
386
+ await rename(temporary, target)
387
+ } finally {
388
+ await rm(temporary, { force: true })
389
+ }
390
+ }
391
+
392
+ function usageFailure(event) {
393
+ if (event.type === 'llm/retry') return event.data?.failure
394
+ if (event.type === 'turn/end' && event.data?.reason?.kind === 'error') return event.data.reason.error
395
+ return undefined
396
+ }
397
+
398
+ function recentErrorTime(time) {
399
+ return Number.isFinite(time) && time >= Date.now() - 24 * 60 * 60 * 1000
400
+ }
401
+
402
+ function addUsageError(session, event, model) {
403
+ const failure = usageFailure(event)
404
+ if (failure === undefined) return false
405
+ if (!recentErrorTime(event.time)) return true
406
+ if (session.modelErrors === undefined) session.modelErrors = {}
407
+ const provider = event.type === 'llm/retry' && typeof event.data?.provider === 'string' ? event.data.provider : model.provider
408
+ const errorModel = provider === model.provider ? model.model : 'unknown'
409
+ const code = typeof failure.code === 'string' && failure.code.length > 0 ? failure.code : 'UNKNOWN'
410
+ const status = Number.isSafeInteger(failure.status) ? failure.status : null
411
+ const key = `${provider}/${errorModel}|${code}|${status === null ? '-' : status}`
412
+ const current = session.modelErrors[key] || { key, provider, model: errorModel, code, status, message: String(failure.message || code), recentTimes: [] }
413
+ current.recentTimes.push(event.time)
414
+ session.modelErrors[key] = current
415
+ return true
416
+ }
417
+
418
+ function toolResultBlock(event) {
419
+ if (event.type !== 'tool/result') return undefined
420
+ const content = event.data?.message?.content
421
+ return Array.isArray(content) ? content.find((block) => block?.type === 'tool-result') : undefined
422
+ }
423
+
424
+ function toolFailureCode(tool, code, message) {
425
+ const value = String(code || '').trim()
426
+ if (/ABORT|CANCEL/i.test(value) || /aborted|cancelled|canceled/i.test(message)) return undefined
427
+ if (value && value !== 'UNKNOWN' && value !== 'Error') return value
428
+ if (/requires reading\b/i.test(message)) return 'FS_NOT_OBSERVED'
429
+ if (/old_string was not found/i.test(message)) return 'OLD_STRING_NOT_FOUND'
430
+ if (/no such file or directory|path[^\n]*not found/i.test(message)) return 'PATH_NOT_FOUND'
431
+ if (/file access denied|permission denied|EACCES/i.test(message)) return 'PERMISSION_DENIED'
432
+ if (/timed out|timeout/i.test(message)) return 'TOOL_TIMEOUT'
433
+ const exit = message.match(/\[exit code:\s*(-?\d+)\]/i)
434
+ if (tool === 'bash' && exit) return `EXIT_${exit[1]}`
435
+ return 'TOOL_ERROR'
436
+ }
437
+
438
+ function toolFailureMessage(tool, code) {
439
+ if (code === 'FS_NOT_OBSERVED') return `${tool} requires reading <path> first — read the file, then retry`
440
+ if (code === 'OLD_STRING_NOT_FOUND') return `${tool}: old_string was not found in <path>`
441
+ if (code === 'PATH_NOT_FOUND') return `${tool} search failed: <path> not found`
442
+ if (code === 'PERMISSION_DENIED') return `${tool} failed: permission denied for <path>`
443
+ if (code === 'TOOL_TIMEOUT') return `${tool} timed out`
444
+ if (code.startsWith('EXIT_')) return `${tool} command exited with code ${code.slice(5)}`
445
+ return `${tool} failed (${code})`
446
+ }
447
+
448
+ function addToolError(session, event) {
449
+ let tool
450
+ let code
451
+ let message
452
+ if (event.type === 'tool/call') {
453
+ if (session.toolCalls === undefined) session.toolCalls = {}
454
+ session.toolCalls[String(event.data?.callId)] = String(event.data?.name || 'unknown')
455
+ return true
456
+ }
457
+ if (event.type === 'tool/result') {
458
+ if (event.surfaceOp !== 'append') return true
459
+ const block = toolResultBlock(event)
460
+ const callId = String(block?.toolCallId || '')
461
+ tool = session.toolCalls?.[callId] || 'unknown'
462
+ if (session.toolCalls !== undefined) delete session.toolCalls[callId]
463
+ const textBlock = Array.isArray(block?.content) ? block.content.find((item) => item?.type === 'text') : undefined
464
+ message = String(textBlock?.text || '')
465
+ const exitFailure = tool === 'bash' && /\[exit code:\s*-?\d+\]/i.test(message)
466
+ if (block?.isError !== true && !exitFailure) return true
467
+ code = toolFailureCode(tool, event.data?.error?.code, message)
468
+ } else if (event.type === 'tool/code-dispatch') {
469
+ tool = String(event.data?.name || 'unknown')
470
+ const textBlock = Array.isArray(event.data?.content) ? event.data.content.find((item) => item?.type === 'text') : undefined
471
+ message = String(textBlock?.text || '')
472
+ const exitFailure = tool === 'bash' && /\[exit code:\s*-?\d+\]/i.test(message)
473
+ if (event.data?.isError !== true && !exitFailure) return true
474
+ code = toolFailureCode(tool, undefined, message)
475
+ } else {
476
+ return false
477
+ }
478
+ if (code === undefined || !recentErrorTime(event.time)) return true
479
+ if (session.toolErrors === undefined) session.toolErrors = {}
480
+ const key = `${tool}|${code}`
481
+ const current = session.toolErrors[key] || { key, tool, code, message: toolFailureMessage(tool, code), recentTimes: [] }
482
+ current.recentTimes.push(event.time)
483
+ session.toolErrors[key] = current
484
+ return true
485
+ }
486
+
487
+ function pruneSessionErrors(session, cutoff) {
488
+ for (const field of ['modelErrors', 'toolErrors']) {
489
+ for (const [key, error] of Object.entries(session[field] || {})) {
490
+ error.recentTimes = (error.recentTimes || []).filter((time) => time >= cutoff)
491
+ if (error.recentTimes.length === 0) delete session[field][key]
492
+ }
493
+ }
494
+ }
495
+
496
+ function foldUsageEvents(ctx, record, previous, events) {
497
+ const project = projectForCwd(ctx, record.header.cwd)
498
+ const session = previous || { revision: '', lastSeq: Math.max(0, record.header.seedLength || 0) - 1, project, currentModel: null, hours: {} }
499
+ session.project = project
500
+ for (const event of events) {
501
+ if (event.seq < (record.header.seedLength || 0)) continue
502
+ session.lastSeq = Math.max(session.lastSeq, event.seq)
503
+ if (event.type === 'request/header') {
504
+ const provider = event.data?.header?.config?.provider
505
+ const model = event.data?.header?.config?.model
506
+ if (typeof provider === 'string' && typeof model === 'string') session.currentModel = { provider, model, id: `${provider}/${model}` }
507
+ continue
508
+ }
509
+ const model = session.currentModel || { provider: 'unknown', model: 'unknown', id: 'unknown/unknown' }
510
+ if (addToolError(session, event)) continue
511
+ if (addUsageError(session, event, model)) continue
512
+ if (event.type !== 'assistant/message') continue
513
+ const hour = usageHour(event.time)
514
+ const bucket = session.hours[hour] || (session.hours[hour] = { totals: emptyUsageTotals(), models: {} })
515
+ const modelBucket = bucket.models[model.id] || (bucket.models[model.id] = { id: model.id, provider: model.provider, model: model.model, totals: emptyUsageTotals() })
516
+ const usage = event.data?.usage
517
+ const delta = emptyUsageTotals()
518
+ delta.steps = 1
519
+ if (usage === undefined) delta.missingUsage = 1
520
+ else {
521
+ delta.inputTokens = Number(usage.inputTokens) || 0
522
+ delta.outputTokens = Number(usage.outputTokens) || 0
523
+ delta.cacheReadTokens = Number(usage.cacheReadTokens) || 0
524
+ delta.cacheWriteTokens = Number(usage.cacheWriteTokens) || 0
525
+ }
526
+ addUsageTotals(bucket.totals, delta)
527
+ addUsageTotals(modelBucket.totals, delta)
528
+ }
529
+ return session
530
+ }
531
+
532
+ function publicUsage(index, timezoneOffsetMinutes = 0) {
533
+ const result = { updatedAt: index.updatedAt, indexedSessions: Object.keys(index.sessions).length, totals: emptyUsageTotals(), days: {}, errors: { models: [], tools: [] } }
534
+ const projects = new Map()
535
+ const modelErrors = new Map()
536
+ const toolErrors = new Map()
537
+ const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
538
+ for (const session of Object.values(index.sessions)) {
539
+ projects.set(session.project.id, session.project)
540
+ pruneSessionErrors(session, recentCutoff)
541
+ for (const error of Object.values(session.modelErrors || {})) {
542
+ const count = error.recentTimes.length
543
+ const key = `${session.project.id}|${error.key}`
544
+ const aggregate = modelErrors.get(key) || { key: error.key, provider: error.provider, model: error.model, code: error.code, status: error.status, message: error.message, count: 0, projectId: session.project.id, projectTitle: session.project.title }
545
+ aggregate.count += count
546
+ modelErrors.set(key, aggregate)
547
+ }
548
+ for (const error of Object.values(session.toolErrors || {})) {
549
+ const count = error.recentTimes.length
550
+ const key = `${session.project.id}|${error.key}`
551
+ const aggregate = toolErrors.get(key) || { key: error.key, tool: error.tool, code: error.code, message: error.message, count: 0, projectId: session.project.id, projectTitle: session.project.title }
552
+ aggregate.count += count
553
+ toolErrors.set(key, aggregate)
554
+ }
555
+ for (const [hour, source] of Object.entries(session.hours || {})) {
556
+ const day = localDayForHour(hour, timezoneOffsetMinutes)
557
+ const dayBucket = result.days[day] || (result.days[day] = { totals: emptyUsageTotals(), projects: new Map() })
558
+ addUsageTotals(dayBucket.totals, source.totals)
559
+ addUsageTotals(result.totals, source.totals)
560
+ const projectBucket = dayBucket.projects.get(session.project.id) || { ...session.project, totals: emptyUsageTotals(), models: new Map() }
561
+ addUsageTotals(projectBucket.totals, source.totals)
562
+ for (const model of Object.values(source.models)) {
563
+ const modelBucket = projectBucket.models.get(model.id) || { id: model.id, provider: model.provider, model: model.model, totals: emptyUsageTotals() }
564
+ addUsageTotals(modelBucket.totals, model.totals)
565
+ projectBucket.models.set(model.id, modelBucket)
566
+ }
567
+ dayBucket.projects.set(session.project.id, projectBucket)
568
+ }
569
+ }
570
+ const errorSort = (a, b) => b.count - a.count || a.key.localeCompare(b.key) || a.projectId.localeCompare(b.projectId)
571
+ result.errors.models = [...modelErrors.values()].sort(errorSort)
572
+ result.errors.tools = [...toolErrors.values()].sort(errorSort)
573
+ const finishTotals = (totals) => ({ ...totals, cacheHitRate: cacheHitRate(totals) })
574
+ result.totals = finishTotals(result.totals)
575
+ result.projects = [...projects.values()].sort((a, b) => a.title.localeCompare(b.title))
576
+ for (const bucket of Object.values(result.days)) {
577
+ bucket.totals = finishTotals(bucket.totals)
578
+ bucket.projects = [...bucket.projects.values()].map((project) => ({
579
+ ...project,
580
+ totals: finishTotals(project.totals),
581
+ models: [...project.models.values()].map((model) => ({ ...model, totals: finishTotals(model.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
582
+ })).sort((a, b) => a.title.localeCompare(b.title))
583
+ }
584
+ return result
585
+ }
586
+
587
+ async function refreshUsageIndex(ctx, dshHome, index) {
588
+ const persistence = ctx.get('sessionPersistence')
589
+ if (persistence === undefined) throw new Error('session-persistence-unavailable')
590
+ const snapshots = await persistence.listSnapshots()
591
+ const liveIds = new Set(snapshots.map((record) => String(record.header.id)))
592
+ for (const id of Object.keys(index.sessions)) if (!liveIds.has(id)) delete index.sessions[id]
593
+ const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
594
+ for (const session of Object.values(index.sessions)) pruneSessionErrors(session, recentCutoff)
595
+ for (const record of snapshots) {
596
+ const id = String(record.header.id)
597
+ const revision = revisionKey(record.revision)
598
+ const previous = index.sessions[id]
599
+ if (previous?.revision === revision) continue
600
+ const fromSeq = previous === undefined ? Math.max(0, record.header.seedLength || 0) : previous.lastSeq + 1
601
+ const read = await persistence.readFrom(record.header.id, fromSeq)
602
+ const next = foldUsageEvents(ctx, record, previous, read.events)
603
+ next.revision = revision
604
+ index.sessions[id] = next
605
+ }
606
+ index.updatedAt = Date.now()
607
+ await saveUsageIndex(dshHome, index)
608
+ return index
609
+ }
610
+
611
+ function modeString(mode) {
612
+ return '0' + (mode & 0o777).toString(8).padStart(3, '0')
613
+ }
614
+
615
+ async function runFixedCommand(ctx, argv) {
616
+ const subprocess = ctx.get('subprocess')
617
+ if (subprocess === undefined) throw new Error('subprocess-unavailable')
618
+ const executable = await subprocess.resolveExecutable(argv[0])
619
+ const handle = subprocess.spawn({
620
+ argv: [executable, ...argv.slice(1)],
621
+ cwd: '/',
622
+ stdio: {
623
+ stdin: 'ignore',
624
+ stdout: { maxBytes: 16 * 1024 },
625
+ stderr: { maxBytes: 64 * 1024 },
626
+ },
627
+ graceMs: 5000,
628
+ })
629
+ const outcome = await handle.done
630
+ const stderr = handle.collected.stderr?.readFrom(0).text || ''
631
+ if (outcome.exitCode !== 0 || outcome.signal !== null) {
632
+ throw new Error(`${argv[0]}-failed: ${stderr.trim() || outcome.signal || outcome.exitCode}`)
633
+ }
634
+ }
635
+
636
+ async function permissionSnapshot(ctx, dshHome, plans) {
637
+ if (process.platform !== 'linux' || typeof process.getuid !== 'function' || typeof process.getgid !== 'function') {
638
+ return { supported: false }
639
+ }
640
+ const workspaceRegistry = ctx.get('workspaceRegistry')
641
+ const entries = [{ label: 'DSH_HOME', path: dshHome }]
642
+ if (workspaceRegistry !== undefined) {
643
+ for (const workspace of workspaceRegistry.list()) {
644
+ if (entries.some((entry) => entry.path === workspace.path)) continue
645
+ entries.push({ label: String(workspace.title || workspace.id), path: String(workspace.path) })
646
+ }
647
+ }
648
+ const items = []
649
+ for (const entry of entries) {
650
+ try {
651
+ const info = await stat(entry.path)
652
+ if (!info.isDirectory()) continue
653
+ items.push({
654
+ label: entry.label,
655
+ path: entry.path,
656
+ owner: `${info.uid}:${info.gid}`,
657
+ mode: modeString(info.mode),
658
+ writable: await hasAgentAccess(entry.path, true),
659
+ })
660
+ } catch (error) {
661
+ items.push({
662
+ label: entry.label,
663
+ path: entry.path,
664
+ owner: 'unavailable',
665
+ mode: '----',
666
+ writable: false,
667
+ error: error?.code || error?.message || String(error),
668
+ })
669
+ }
670
+ }
671
+ const planId = randomUUID()
672
+ plans.set(planId, items.filter((item) => item.error === undefined).map((item) => item.path))
673
+ return {
674
+ supported: true,
675
+ planId,
676
+ targetOwner: `${process.getuid()}:${process.getgid()}`,
677
+ items,
678
+ }
679
+ }
680
+
681
+ function isAtOrUnderPath(ancestor, path) {
682
+ const child = relative(ancestor, path)
683
+ return child === '' || (child !== '..' && !child.startsWith(`..${sep}`))
684
+ }
685
+
686
+ // 只有宿主自己的凭据文档使用 owner-only 契约;工作区里的同名文件按 Agent 可写性检查。
687
+ function isCredentialsDocument(path, dshHome) {
688
+ return path === join(dshHome, '.credentials.yaml')
689
+ }
690
+
691
+ async function hasAgentAccess(path, directory) {
692
+ try {
693
+ await access(path, directory ? fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK : fsConstants.R_OK | fsConstants.W_OK)
694
+ return true
695
+ } catch (_) {
696
+ return false
697
+ }
698
+ }
699
+
700
+ async function deepCheckPermissions(dshHome, plans, planId) {
701
+ if (typeof planId !== 'string') return undefined
702
+ const paths = plans.get(planId)
703
+ if (paths === undefined) return undefined
704
+ const startedAt = Date.now()
705
+ const result = { scanned: 0, ownerIssues: 0, directoryModeIssues: 0, fileModeIssues: 0, unreadable: 0, samples: [] }
706
+ const visit = async (path) => {
707
+ let info
708
+ try {
709
+ info = await lstat(path)
710
+ result.scanned += 1
711
+ } catch (error) {
712
+ result.unreadable += 1
713
+ if (result.samples.length < 50) result.samples.push({ path, issue: 'unreadable', detail: error?.code || error?.message || String(error) })
714
+ return
715
+ }
716
+ const issues = []
717
+ const mode = info.mode & 0o777
718
+ if (isCredentialsDocument(path, dshHome)) {
719
+ const accessible = await hasAgentAccess(path, false)
720
+ if ((mode & 0o077) !== 0 || !accessible) { result.fileModeIssues += 1; issues.push('file-access') }
721
+ if (issues.length > 0 && result.samples.length < 50) result.samples.push({ path, issue: issues.join(','), detail: modeString(info.mode) })
722
+ return
723
+ }
724
+ if (info.isDirectory()) {
725
+ if (!(await hasAgentAccess(path, true))) { result.directoryModeIssues += 1; issues.push('directory-access') }
726
+ } else if (info.isFile() && !(await hasAgentAccess(path, false))) {
727
+ result.fileModeIssues += 1
728
+ issues.push('file-access')
729
+ }
730
+ if (issues.length > 0 && result.samples.length < 50) result.samples.push({ path, issue: issues.join(','), detail: modeString(info.mode) })
731
+ if (!info.isDirectory()) return
732
+ let entries
733
+ try { entries = await readdir(path, { withFileTypes: true }) } catch (error) {
734
+ result.unreadable += 1
735
+ if (result.samples.length < 50) result.samples.push({ path, issue: 'unreadable', detail: error?.code || error?.message || String(error) })
736
+ return
737
+ }
738
+ for (const entry of entries) {
739
+ if (entry.name === '.git') continue
740
+ await visit(join(path, entry.name))
741
+ }
742
+ }
743
+ // 工作区注册表常含嵌套路径(如 /workspace 与 /workspace/projects/<x>):被其他根
744
+ // 覆盖的路径只随外层根扫描一次,否则同一批文件会被 stat 两到三次,异常也被重复计数。
745
+ const roots = []
746
+ for (const path of [...paths].sort((a, b) => a.length - b.length)) {
747
+ if (roots.some((root) => isAtOrUnderPath(root, path))) continue
748
+ roots.push(path)
749
+ }
750
+ for (const path of roots) await visit(path)
751
+ result.durationMs = Date.now() - startedAt
752
+ return result
753
+ }
754
+
755
+ async function repairPermissions(ctx, dshHome, plans, planId) {
756
+ if (typeof planId !== 'string') return undefined
757
+ const paths = plans.get(planId)
758
+ if (paths === undefined) return undefined
759
+ plans.delete(planId)
760
+ const owner = `${process.getuid()}:${process.getgid()}`
761
+ const roots = []
762
+ for (const path of [...paths].sort((a, b) => a.length - b.length)) {
763
+ if (roots.some((root) => isAtOrUnderPath(root, path))) continue
764
+ roots.push(path)
765
+ }
766
+ for (const path of roots) {
767
+ // 先逐个恢复目录遍历能力;使用 `{} ;` 让 find 在进入子目录前立即 chmod,且完整跳过 .git。
768
+ await runFixedCommand(ctx, ['find', path, '-name', '.git', '-prune', '-o', '-type', 'd', '-exec', 'chmod', 'u+rwx', '{}', ';'])
769
+ await runFixedCommand(ctx, ['find', path, '-name', '.git', '-prune', '-o', '-exec', 'chown', '-h', owner, '--', '{}', '+'])
770
+ await runFixedCommand(ctx, ['find', path, '-name', '.git', '-prune', '-o', '-type', 'f', '-exec', 'chmod', 'u+rw', '{}', '+'])
771
+ }
772
+ try {
773
+ const credentials = join(dshHome, '.credentials.yaml')
774
+ const info = await lstat(credentials)
775
+ if (info.isFile()) await runFixedCommand(ctx, ['chmod', '600', '--', credentials])
776
+ } catch (error) {
777
+ if (error?.code !== 'ENOENT') throw error
778
+ }
779
+ return permissionSnapshot(ctx, dshHome, plans)
780
+ }
781
+
782
+ async function collectDiagnostics(ctx, dshHome) {
783
+ const checks = []
784
+ const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
785
+
786
+ const persistence = ctx.get('sessionPersistence')
787
+ if (persistence === undefined) add('session-storage', 'error', 'unavailable')
788
+ else {
789
+ try { add('session-storage', 'ok', (await persistence.listSnapshots()).length) } catch (error) { add('session-storage', 'error', error?.message || error) }
790
+ }
791
+
792
+ const registry = ctx.get('workspaceRegistry')
793
+ let workspaceCount = 0
794
+ if (registry === undefined) add('workspace-registry', 'warning', 'unavailable')
795
+ else {
796
+ try {
797
+ const listed = registry.list()
798
+ workspaceCount = Array.isArray(listed) ? listed.length : [...listed].length
799
+ add('workspace-registry', 'ok', workspaceCount)
800
+ } catch (error) { add('workspace-registry', 'error', error?.message || error) }
801
+ }
802
+
803
+ try {
804
+ const info = await stat(dshHome)
805
+ add('dsh-home', info.isDirectory() ? 'ok' : 'error', modeString(info.mode))
806
+ } catch (error) { add('dsh-home', 'error', error?.code || error?.message || error) }
807
+
808
+ try {
809
+ const backups = await listBackups(dshHome)
810
+ add('backup-storage', backups.items.length === 0 ? 'warning' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
811
+ } catch (error) { add('backup-storage', 'error', error?.code || error?.message || error) }
812
+
813
+ const subprocess = ctx.get('subprocess')
814
+ if (subprocess === undefined) add('tar', 'error', 'subprocess-unavailable')
815
+ else {
816
+ try { add('tar', 'ok', await subprocess.resolveExecutable('tar')) } catch (error) { add('tar', 'error', error?.message || error) }
817
+ }
818
+
819
+ try {
820
+ const snapshot = await permissionSnapshot(ctx, dshHome, new Map())
821
+ if (snapshot.supported !== true) add('permissions', 'info', 'unsupported')
822
+ else {
823
+ const abnormal = snapshot.items.filter((item) => item.writable === false).length
824
+ add('permissions', abnormal === 0 ? 'ok' : 'warning', abnormal)
825
+ }
826
+ } catch (error) { add('permissions', 'warning', error?.message || error) }
827
+
828
+ let status = 'ok'
829
+ if (checks.some((check) => check.status === 'error')) status = 'error'
830
+ else if (checks.some((check) => check.status === 'warning')) status = 'warning'
831
+ return { status, checkedAt: Date.now(), checks }
832
+ }
833
+
834
+ async function collectHealth(ctx) {
835
+ const sessionsService = ctx.get('sessions')
836
+ const sessionQueryService = ctx.get('sessionQuery')
837
+ const activity = collectActiveWork(ctx)
838
+ const sessionRecords = sessionQueryService === undefined ? [] : await sessionQueryService.listSessions()
839
+ const memory = process.memoryUsage()
840
+
841
+ return {
842
+ uptimeSeconds: process.uptime(),
843
+ rssBytes: memory.rss,
844
+ liveSessions: sessionsService === undefined ? 0 : sessionsService.list().length,
845
+ persistedSessions: sessionRecords.filter((record) => record.persisted === true).length,
846
+ activeAgents: activity.items.filter((item) => item.type === 'agent').length,
847
+ activeJobs: activity.items.filter((item) => item.type === 'job').length,
848
+ }
849
+ }
850
+
851
+ function collectActiveWork(ctx) {
852
+ const agentsService = ctx.get('agents')
853
+ const jobsService = ctx.get('jobs')
854
+ const sharedTerminalsService = ctx.get('terminals')
855
+ const agents = agentsService === undefined ? [] : agentsService.list()
856
+ const items = []
857
+
858
+ for (const agent of agents) {
859
+ if (agent.status !== 'running') continue
860
+ const id = String(agent.id)
861
+ items.push({ type: 'agent', id, label: id, status: 'running' })
862
+ }
863
+
864
+ if (jobsService !== undefined) {
865
+ const jobsById = new Map()
866
+ for (const caller of [undefined, ...agents]) {
867
+ for (const job of jobsService.list(caller)) {
868
+ if (job.status !== 'running' && job.status !== 'stopping') continue
869
+ jobsById.set(String(job.id), job)
870
+ }
871
+ }
872
+ for (const job of jobsById.values()) {
873
+ items.push({
874
+ type: 'job',
875
+ id: String(job.id),
876
+ label: String(job.label || job.id),
877
+ status: job.status,
878
+ ...(job.ownerSession === undefined ? {} : { ownerSession: String(job.ownerSession) }),
879
+ })
880
+ }
881
+ }
882
+
883
+ const terminalsById = new Map()
884
+ for (const owner of agents) {
885
+ let terminalsService = sharedTerminalsService
886
+ try {
887
+ terminalsService = owner.ctx?.get('terminals') ?? sharedTerminalsService
888
+ } catch (_) {}
889
+ if (terminalsService === undefined) continue
890
+ try {
891
+ for (const terminal of terminalsService.list(owner)) {
892
+ if (terminal.status?.kind !== 'running') continue
893
+ const id = String(terminal.sessionId)
894
+ terminalsById.set(id, { terminal, owner })
895
+ }
896
+ } catch (_) {}
897
+ }
898
+ for (const { terminal, owner } of terminalsById.values()) {
899
+ items.push({
900
+ type: 'terminal',
901
+ id: String(terminal.sessionId),
902
+ label: String(terminal.name || `${terminal.type} terminal`),
903
+ status: 'running',
904
+ ownerSession: String(owner.id),
905
+ })
906
+ }
907
+
908
+ return { hasActive: items.length > 0, items }
909
+ }
910
+
911
+ function scheduleRestart(ctx) {
912
+ const doExit = () => {
913
+ try {
914
+ // 退出码 42 交给 Docker/systemd/pm2 的重启策略处理。
915
+ process.exit(42)
916
+ } catch (error) {
917
+ console.error('dsh-service: exit failed', error?.message || error)
918
+ }
919
+ }
920
+ const timer = ctx.get('timer')
921
+ if (timer !== undefined) return timer.timeout(doExit, 500)
922
+ return doExit()
923
+ }
924
+
925
+ function apply(ctx) {
926
+ const dshHome = resolveDshHome()
927
+ const permissionPlans = new Map()
928
+ let usageIndexPromise = loadUsageIndex(dshHome)
929
+ let usageRefreshPromise
930
+ let updateCache
931
+ let updatePromise
932
+ ctx.effect(() => () => permissionPlans.clear(), 'dsh-service permission plans')
933
+ const commands = ctx.get('commands')
934
+ if (commands !== undefined) {
935
+ ctx.effect(() => commands.register({
936
+ name: 'restart',
937
+ description: 'Restart the DSH Web process after checking active work',
938
+ handler: async (invocation) => {
939
+ if (invocation.rawInput.trim() !== '') return { kind: 'error', text: '/restart does not accept arguments.' }
940
+ const activity = collectActiveWork(ctx)
941
+ if (activity.hasActive) return { kind: 'error', text: `Restart refused: ${activity.items.length} active item(s) detected. Use the Service Control restart tab to review them.` }
942
+ scheduleRestart(ctx)
943
+ return { kind: 'success', text: 'Restart scheduled. The DSH Web process will exit in 0.5 seconds.' }
944
+ },
945
+ }), 'dsh-service restart command')
946
+ }
947
+ const webServer = ctx.get('webServer')
948
+ if (webServer !== undefined) {
949
+ ctx.effect(() => webServer.register({
950
+ kind: 'exact',
951
+ path: '/healthz',
952
+ handler: (req, res) => {
953
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
954
+ res.writeHead(405)
955
+ res.end()
956
+ return
957
+ }
958
+ res.writeHead(200)
959
+ res.end()
960
+ },
961
+ }), 'dsh-service healthz route')
962
+ }
963
+
964
+ // DSH 的 Connection RPC channel 只能是单层绝对路径;子功能放在 endpoint 中。
965
+ // 合法示例:channel=/dsh-service,endpoint=version/check-update/activity/web。
966
+ ctx.connection.rpc.handle('/dsh-service', async (endpoint, payload) => {
967
+ if (endpoint === 'version') {
968
+ return { ok: true, value: { current: dshVersion, pluginVersion, instanceId } }
969
+ }
970
+
971
+ if (endpoint === 'check-update') {
972
+ const now = Date.now()
973
+ if (updateCache && now - updateCache.checkedAt < updateCache.ttl) {
974
+ return updateCache.ok
975
+ ? { ok: true, value: Object.assign({}, updateCache.value, { cached: true }) }
976
+ : { ok: false, error: updateCache.error, cached: true }
977
+ }
978
+ try {
979
+ if (updatePromise === undefined) {
980
+ updatePromise = Promise.allSettled([
981
+ fetchPublishedVersions(DSH_PACKAGE),
982
+ fetchPublishedVersions(PLUGIN_PACKAGE),
983
+ ]).finally(() => { updatePromise = undefined })
984
+ }
985
+ const [dshResult, pluginResult] = await updatePromise
986
+ const dsh = dshResult.status === 'fulfilled'
987
+ ? { current: dshVersion, latest: dshResult.value.latest, tags: dshResult.value.tags, upToDate: atLeastSemver(dshVersion, dshResult.value.latest), status: 'available', url: 'https://github.com/deepseek-ai/DeepSeek-Harness/releases' }
988
+ : { current: dshVersion, latest: null, tags: { latest: null, next: null }, upToDate: null, status: 'unavailable', url: 'https://github.com/deepseek-ai/DeepSeek-Harness/releases' }
989
+ const pluginError = pluginResult.status === 'rejected' ? String(pluginResult.reason?.message || pluginResult.reason) : ''
990
+ const plugin = pluginResult.status === 'fulfilled'
991
+ ? { current: pluginVersion, latest: pluginResult.value.latest, tags: pluginResult.value.tags, upToDate: atLeastSemver(pluginVersion, pluginResult.value.latest), status: 'available', url: 'https://github.com/gehennawu/dsh-service/releases' }
992
+ : { current: pluginVersion, latest: null, tags: { latest: null, next: null }, upToDate: null, status: pluginError.includes('HTTP 404') ? 'unpublished' : 'unavailable', url: 'https://github.com/gehennawu/dsh-service/releases' }
993
+ if (dsh.status === 'unavailable' && plugin.status === 'unavailable') throw dshResult.reason
994
+ const value = { checkedAt: now, cached: false, dsh, plugin }
995
+ updateCache = { ok: true, value, checkedAt: now, ttl: 10 * 60 * 1000 }
996
+ return { ok: true, value }
997
+ } catch (error) {
998
+ const message = error?.message || String(error)
999
+ updateCache = { ok: false, error: message, checkedAt: now, ttl: 60 * 1000 }
1000
+ return { ok: false, error: message, cached: false }
1001
+ }
1002
+ }
1003
+
1004
+ if (endpoint === 'activity') {
1005
+ return { ok: true, value: collectActiveWork(ctx) }
1006
+ }
1007
+
1008
+ if (endpoint === 'health') {
1009
+ try {
1010
+ return { ok: true, value: await collectHealth(ctx) }
1011
+ } catch (error) {
1012
+ return { ok: false, error: error?.message || String(error) }
1013
+ }
1014
+ }
1015
+
1016
+ if (endpoint === 'diagnostics') {
1017
+ try {
1018
+ return { ok: true, value: await collectDiagnostics(ctx, dshHome) }
1019
+ } catch (error) {
1020
+ return { ok: false, error: error?.message || String(error) }
1021
+ }
1022
+ }
1023
+
1024
+ if (endpoint === 'usage') {
1025
+ try {
1026
+ return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
1027
+ } catch (error) {
1028
+ return { ok: false, error: error?.message || String(error) }
1029
+ }
1030
+ }
1031
+
1032
+ if (endpoint === 'usage-refresh') {
1033
+ try {
1034
+ if (usageRefreshPromise === undefined) {
1035
+ usageRefreshPromise = usageIndexPromise.then((index) => refreshUsageIndex(ctx, dshHome, index)).finally(() => { usageRefreshPromise = undefined })
1036
+ }
1037
+ return { ok: true, value: publicUsage(await usageRefreshPromise, payload?.timezoneOffsetMinutes) }
1038
+ } catch (error) {
1039
+ return { ok: false, error: error?.message || String(error) }
1040
+ }
1041
+ }
1042
+
1043
+ if (endpoint === 'permissions-plan') {
1044
+ try {
1045
+ return { ok: true, value: await permissionSnapshot(ctx, dshHome, permissionPlans) }
1046
+ } catch (error) {
1047
+ return { ok: false, error: error?.message || String(error) }
1048
+ }
1049
+ }
1050
+
1051
+ if (endpoint === 'permissions-deep') {
1052
+ try {
1053
+ const value = await deepCheckPermissions(dshHome, permissionPlans, payload?.planId)
1054
+ if (value === undefined) return { ok: false, error: 'unknown-permission-plan' }
1055
+ return { ok: true, value }
1056
+ } catch (error) {
1057
+ return { ok: false, error: error?.message || String(error) }
1058
+ }
1059
+ }
1060
+
1061
+ if (endpoint === 'permissions-repair') {
1062
+ try {
1063
+ const value = await repairPermissions(ctx, dshHome, permissionPlans, payload?.planId)
1064
+ if (value === undefined) return { ok: false, error: 'unknown-permission-plan' }
1065
+ return { ok: true, value }
1066
+ } catch (error) {
1067
+ return { ok: false, error: error?.message || String(error) }
1068
+ }
1069
+ }
1070
+
1071
+ if (endpoint === 'backup-list') {
1072
+ try {
1073
+ return { ok: true, value: await listBackups(dshHome) }
1074
+ } catch (error) {
1075
+ return { ok: false, error: error?.message || String(error) }
1076
+ }
1077
+ }
1078
+
1079
+ if (endpoint === 'backup-create') {
1080
+ try {
1081
+ return { ok: true, value: await createBackup(ctx, dshHome) }
1082
+ } catch (error) {
1083
+ return { ok: false, error: error?.message || String(error) }
1084
+ }
1085
+ }
1086
+
1087
+ if (endpoint === 'backup-delete') {
1088
+ try {
1089
+ const value = await deleteBackup(dshHome, payload?.id)
1090
+ if (value === undefined) return { ok: false, error: 'unknown-backup' }
1091
+ return { ok: true, value }
1092
+ } catch (error) {
1093
+ return { ok: false, error: error?.message || String(error) }
1094
+ }
1095
+ }
1096
+
1097
+ if (endpoint === 'backup-import') {
1098
+ try {
1099
+ const value = await importBackup(dshHome, payload?.name, payload?.data)
1100
+ if (value === undefined) return { ok: false, error: 'invalid-backup' }
1101
+ return { ok: true, value }
1102
+ } catch (error) {
1103
+ return { ok: false, error: error?.message || String(error) }
1104
+ }
1105
+ }
1106
+
1107
+ if (endpoint === 'web') {
1108
+ const activity = collectActiveWork(ctx)
1109
+ if (activity.hasActive && payload?.force !== true) {
1110
+ return { ok: false, error: 'active-work', value: activity }
1111
+ }
1112
+
1113
+ scheduleRestart(ctx)
1114
+ return {
1115
+ ok: true,
1116
+ value: {
1117
+ message: '重启指令已发出,进程将在 0.5 秒后退出',
1118
+ instanceId,
1119
+ },
1120
+ }
1121
+ }
1122
+
1123
+ return { ok: false, error: 'unknown endpoint: ' + String(endpoint) }
1124
+ }, { authority: 'loopback' })
1125
+ }
1126
+
1127
+ export { apply, inject, name }
1128
+ export default { apply, inject, name }