dsh-remote-plugin 0.7.0 → 0.7.2

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/client.js CHANGED
@@ -20,7 +20,11 @@ window.__ModuleLoader__.load({
20
20
  }
21
21
 
22
22
  var DRAWER_STYLE = {
23
- position: 'fixed', top: 12, right: 12, bottom: 12, zIndex: 2147483000,
23
+ // Desktop's hidden title bar overlays the page (40px on Windows).
24
+ // Electron exposes its current height through the CSS environment value;
25
+ // keep a safe fallback for Desktop hosts without that value.
26
+ position: 'fixed', top: window.location && window.location.protocol === 'dsh-app:' ? 'calc(12px + env(titlebar-area-height, 40px))' : 12,
27
+ right: 12, bottom: 12, zIndex: 2147483000,
24
28
  width: 'min(520px, calc(100vw - 24px))', background: '#0b0e1a',
25
29
  border: '1px solid rgba(125, 207, 255, .22)', borderRadius: 24, overflow: 'hidden',
26
30
  boxShadow: '-16px 18px 48px rgba(0,0,0,.46), 0 0 0 1px rgba(91,140,255,.08)',
@@ -141,7 +145,13 @@ window.__ModuleLoader__.load({
141
145
  }, [])
142
146
  return React.createElement('div', {
143
147
  'aria-hidden': !open,
144
- style: Object.assign({}, DRAWER_STYLE, { transform: open ? 'translateX(0)' : 'translateX(102%)' }),
148
+ style: Object.assign({}, DRAWER_STYLE, {
149
+ transform: open ? 'translateX(0)' : 'translateX(102%)',
150
+ boxShadow: open ? DRAWER_STYLE.boxShadow : 'none',
151
+ pointerEvents: open ? 'auto' : 'none',
152
+ visibility: open ? 'visible' : 'hidden',
153
+ transition: open ? 'transform .22s ease' : 'transform .22s ease, visibility 0s linear .22s',
154
+ }),
145
155
  },
146
156
  loaded.current ? React.createElement('iframe', {
147
157
  src: '/remote/plugin.html',
package/gateway.cjs CHANGED
@@ -1703,6 +1703,12 @@ async function translateModernRpc(method, payload, rpcId) {
1703
1703
  if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return legacyFallback('no generated RPC adapter')
1704
1704
  endpoint = 'workspace/' + verb
1705
1705
  args = { request: payload }
1706
+ if (verb === 'archiveSession') transform = value => {
1707
+ // The HTTP receipt precedes workspace/follow on another connection.
1708
+ // Publish the committed archive set before a client's immediate refresh.
1709
+ if (Array.isArray(value?.archivedSessionIds)) applyModernWorkspaceFrame({ type: 'archived', ...value })
1710
+ return value
1711
+ }
1706
1712
  } else if (method.startsWith('goal.')) {
1707
1713
  const verb = method.slice('goal.'.length)
1708
1714
  if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return legacyFallback('no generated RPC adapter')
@@ -1767,6 +1773,31 @@ async function translateModernRpc(method, payload, rpcId) {
1767
1773
  return legacyEnvelope(rpcId, modernError(error.message, 'image-vision-failed'))
1768
1774
  }
1769
1775
  }
1776
+ if (method === 'subagent.list' && modernResponseNeedsLegacyFallback(response)) {
1777
+ // DSH 0.2 removed subagents/list; discovery is now a non-activating
1778
+ // read of the parent's durable catalog and each child's projections.
1779
+ const parent = await callUpstreamRemote('session/projections', { request: { sessionId: payload.parentSessionId } }, rpcId)
1780
+ if (modernResponseNeedsLegacyFallback(parent)) return legacyFallback('generated subagent catalog unavailable')
1781
+ if (!parent.body?.result?.ok) return parent.body || legacyEnvelope(rpcId, modernError('DSH subagent catalog read failed'))
1782
+ const catalog = parent.body.result.value?.values?.subagentCatalog
1783
+ if (!Array.isArray(catalog)) return legacyEnvelope(rpcId, modernError('DSH did not expose a subagent catalog projection', 'subagent/projections-unavailable'))
1784
+ await refreshModernSessions()
1785
+ const entries = await Promise.all(catalog.map(async child => {
1786
+ if (!['one-shot', 'continuable'].includes(child.mode)) return { kind: 'diagnostic', id: child.id, reason: 'unsupported' }
1787
+ try {
1788
+ const read = await callUpstreamRemote('session/projections', { request: { sessionId: child.id } })
1789
+ if (!read.body?.result?.ok || !read.body.result.value) return { kind: 'diagnostic', id: child.id, reason: 'unavailable' }
1790
+ const values = read.body.result.value.values
1791
+ const identity = values?.subagent
1792
+ if (!identity || identity.mode !== child.mode) return { kind: 'diagnostic', id: child.id, reason: 'corrupt' }
1793
+ return { kind: 'child', id: child.id, mode: identity.mode,
1794
+ ...(identity.label === undefined ? {} : { label: identity.label }),
1795
+ activity: modernState.sessions.get(child.id)?.running ? 'running' : 'inactive',
1796
+ hasChildren: Array.isArray(values.subagentCatalog) && values.subagentCatalog.length > 0 }
1797
+ } catch { return { kind: 'diagnostic', id: child.id, reason: 'unavailable' } }
1798
+ }))
1799
+ return legacyEnvelope(rpcId, { ok: true, value: { entries, parentAvailable: !!modernState.sessions.get(payload.parentSessionId)?.agentAvailable } })
1800
+ }
1770
1801
  if (modernResponseNeedsLegacyFallback(response)) return legacyFallback(`generated RPC ${endpoint} unavailable`)
1771
1802
  if (!response.body?.result?.ok) {
1772
1803
  recordCompatibility('generated-rpc-error', { method, status: response.status, detail: remoteFailureKind(response) })
@@ -1818,6 +1849,8 @@ function rememberCollectorReplay(kind, full, raw) {
1818
1849
  let key = ''
1819
1850
  if (payload.type === 'session/subscribed' && payload.sessionId) key = `session:${payload.sessionId}`
1820
1851
  else if (payload.type === 'session/reasoning' && payload.sessionId) key = `reasoning:${payload.sessionId}`
1852
+ else if (payload.type === 'session/queue' && payload.sessionId) key = `queue:${payload.sessionId}`
1853
+ else if (payload.type === 'session/jobs' && payload.sessionId) key = `jobs:${payload.sessionId}`
1821
1854
  else if (payload.type === 'approval/requested' && payload.approvalId) key = `approval:${payload.approvalId}`
1822
1855
  else if (payload.type === 'question/requested' && full.rpcId) key = `question:${full.rpcId}`
1823
1856
  else if (payload.type === 'approval/resolved' && payload.approvalId) replay.delete(`approval:${payload.approvalId}`)
@@ -2095,6 +2128,28 @@ function openModernSessionStream(ws, sessionId) {
2095
2128
  type: 'open', streamId, endpoint: 'session/follow',
2096
2129
  payload: { args: { request: { address: { kind: 'session', sessionId }, assistantStream: true } } },
2097
2130
  }))
2131
+ ws.send(JSON.stringify({
2132
+ type: 'open', streamId: 'jobs:' + sessionId, endpoint: 'job/list',
2133
+ payload: { args: { request: { sessionId } } },
2134
+ }))
2135
+ }
2136
+
2137
+ function applyModernProjection(sessionId, key, value, seq) {
2138
+ legacyPush('mux', { type: 'session/projection', sessionId, key, value, seq })
2139
+ if (key === 'inbox') {
2140
+ // DSH 0.2 stores queue/steer state in the durable Inbox projection.
2141
+ const items = ['next-turn', 'next-step'].flatMap(target =>
2142
+ (Array.isArray(value?.[target]) ? value[target] : []).filter(message => message?.id).map(message => ({
2143
+ id: message.id, placement: target === 'next-turn' ? 'queued' : 'context', message,
2144
+ })))
2145
+ legacyPush('mux', { type: 'session/queue', sessionId, items })
2146
+ }
2147
+ }
2148
+
2149
+ function applyModernJobFrame(sessionId, value) {
2150
+ if (value?.type === 'rows' && Array.isArray(value.jobs)) {
2151
+ legacyPush('mux', { type: 'session/jobs', sessionId, jobs: value.jobs })
2152
+ }
2098
2153
  }
2099
2154
 
2100
2155
  function applyModernControlFrame(value) {
@@ -2107,16 +2162,14 @@ function applyModernControlFrame(value) {
2107
2162
  }
2108
2163
  for (const [sessionId, block] of Object.entries(value.value?.projections || {})) {
2109
2164
  for (const [key, projection] of Object.entries(block?.values || {})) {
2110
- legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: block?.asOfSeq ?? 0 })
2165
+ applyModernProjection(sessionId, key, projection, block?.asOfSeq ?? 0)
2111
2166
  }
2112
2167
  }
2113
2168
  return
2114
2169
  }
2115
2170
  if (value?.type === 'queue') legacyPush('mux', { type: 'session/queue', sessionId: value.sessionId, items: value.items || [] })
2116
2171
  else if (value?.type === 'jobs') legacyPush('mux', { type: 'session/jobs', sessionId: value.sessionId, jobs: value.jobs || [] })
2117
- else if (value?.type === 'projection') legacyPush('mux', {
2118
- type: 'session/projection', sessionId: value.sessionId, key: value.key, value: value.value, seq: value.seq,
2119
- })
2172
+ else if (value?.type === 'projection') applyModernProjection(value.sessionId, value.key, value.value, value.seq)
2120
2173
  }
2121
2174
 
2122
2175
  function applyModernWorkspaceFrame(value) {
@@ -2209,7 +2262,7 @@ function applyModernSessionFrame(sessionId, value) {
2209
2262
  if (record?.event) legacyPush('mux', { type: 'session/event', sessionId, event: record.event })
2210
2263
  }
2211
2264
  for (const [key, projection] of Object.entries(value.projections?.values || {})) {
2212
- legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: value.projections?.asOfSeq ?? value.cursor })
2265
+ applyModernProjection(sessionId, key, projection, value.projections?.asOfSeq ?? value.cursor)
2213
2266
  }
2214
2267
  return
2215
2268
  }
@@ -2282,7 +2335,11 @@ function applyModernRemoteEvent(ws, value) {
2282
2335
  modernState.sessionCursors.delete(args[0])
2283
2336
  modernState.assistantStreams.delete(args[0])
2284
2337
  collectorReplay.mux.delete(`reasoning:${args[0]}`)
2338
+ collectorReplay.mux.delete(`session:${args[0]}`)
2339
+ collectorReplay.mux.delete(`queue:${args[0]}`)
2340
+ collectorReplay.mux.delete(`jobs:${args[0]}`)
2285
2341
  try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
2342
+ try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'jobs:' + args[0] })) } catch {}
2286
2343
  legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
2287
2344
  } else if (value.event === 'api-session/status') {
2288
2345
  legacyPush('host', { type: 'host/session-status', sessionId: args[0], running: !!args[1] })
@@ -2371,6 +2428,9 @@ function startModernEventCollector() {
2371
2428
  const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
2372
2429
  const frame = JSON.parse(data)
2373
2430
  if (frame.type === 'error') {
2431
+ // Old generated-RPC Hosts may have no job namespace. This optional
2432
+ // roster must not degrade the working session and approval streams.
2433
+ if (frame.streamId?.startsWith('jobs:') && ['gateway/definition-unavailable', 'gateway/invocation-unavailable', 'gateway/service-unavailable', 'gateway/method-unavailable', 'method-unavailable', 'not-found'].includes(frame.error?.code)) return
2374
2434
  const message = frame.error?.message || 'modern stream error'
2375
2435
  for (const state of Object.values(eventCollectorState)) state.lastError = message
2376
2436
  legacyPush('mux', { type: 'stream/error', error: frame.error || { message } })
@@ -2380,6 +2440,7 @@ function startModernEventCollector() {
2380
2440
  if (frame.streamId === 'events') applyModernRemoteEvent(current, frame.value)
2381
2441
  else if (frame.streamId === 'control') applyModernControlFrame(frame.value)
2382
2442
  else if (frame.streamId === 'workspaces') applyModernWorkspaceFrame(frame.value)
2443
+ else if (frame.streamId.startsWith('jobs:')) applyModernJobFrame(frame.streamId.slice(5), frame.value)
2383
2444
  else if (frame.streamId.startsWith('session:')) applyModernSessionFrame(frame.streamId.slice(8), frame.value)
2384
2445
  } catch {}
2385
2446
  }
@@ -4197,11 +4258,14 @@ function serveHandoff(req, res, url) {
4197
4258
  fsJson(res, 400, { error: 'sessionId required' })
4198
4259
  return
4199
4260
  }
4261
+ // 白名单字段只接受字符串:对象/数字/数组等一律按空串处理,
4262
+ // 避免 String({}) 产出 '[object Object]' 之类垃圾落盘并广播到其他设备
4263
+ const str = (v) => typeof v === 'string' ? v : ''
4200
4264
  handoffSave({
4201
4265
  sessionId: sessionId.slice(0, 128),
4202
- title: String(payload.title || '').slice(0, 200),
4203
- device: String(payload.device || '').slice(0, 80),
4204
- clientId: String(payload.clientId || '').slice(0, 96),
4266
+ title: str(payload.title).slice(0, 200),
4267
+ device: str(payload.device).slice(0, 80),
4268
+ clientId: str(payload.clientId).slice(0, 96),
4205
4269
  at: Date.now()
4206
4270
  })
4207
4271
  fsJson(res, 200, { ok: true })
@@ -4440,6 +4504,8 @@ async function proxyLegacyRpcWithModernFallback(req, res, url) {
4440
4504
 
4441
4505
  async function proxyModernApi(req, res, url) {
4442
4506
  if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
4507
+ // dsh-context is a Connection Fetch route with its own JSON contract, not RPC.
4508
+ if (url.pathname === '/api/dsh-context/detail') return false
4443
4509
  if (await detectUpstreamApiFlavor() !== 'modern') return false
4444
4510
  let raw = ''
4445
4511
  try {
@@ -4560,7 +4626,13 @@ function proxyApi(req, res, url) {
4560
4626
  res.end()
4561
4627
  return
4562
4628
  }
4563
- const ok = authorized(req, url)
4629
+ // Credential exchange is restricted to named client endpoints. Administrative
4630
+ // plugin routes must authenticate the master token before reaching DSH.
4631
+ const remoteClientPath = ['/remote/api/command', '/remote/api/command-status', '/remote/admin/api/dsh'].includes(url.pathname)
4632
+ || (url.pathname === '/remote/api/insights/cost' && req.method === 'GET')
4633
+ || /^\/remote\/api\/plugins\/(state|details|market|operations|recover)$/.test(url.pathname)
4634
+ const remoteAdminPath = url.pathname.startsWith('/remote/') && !remoteClientPath
4635
+ const ok = remoteAdminPath ? adminAuthorized(req, url) : remoteClientPath ? controlAuthorized(req, url) : authorized(req, url)
4564
4636
  touchDevice(req, ok ? {} : { failedAuth: true })
4565
4637
  if (!ok) {
4566
4638
  authFailures++
package/index.mjs CHANGED
@@ -13,6 +13,7 @@ import { homedir, hostname, networkInterfaces } from 'node:os'
13
13
  import { dirname, extname, normalize, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
  import { createPluginCenter, detectProfile } from './plugin-center.mjs'
16
+ import { readCostInsights } from './insights.mjs'
16
17
 
17
18
  export const name = 'dsh-remote'
18
19
  export const inject = ['webServer', 'commands', 'agents', 'connection']
@@ -637,6 +638,15 @@ async function resolveFile(pathname) {
637
638
 
638
639
  async function serveStatic(req, res, ctx) {
639
640
  const pathname = new URL(req.url ?? '/', 'http://x').pathname
641
+ if (pathname === `${MOUNT}/api/insights/cost`) {
642
+ if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, code: 'unauthorized' })
643
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, code: 'read-only' })
644
+ const query = new URL(req.url, 'http://x').searchParams
645
+ if ([...query.keys()].some(key => key !== 'sessionId') || query.getAll('sessionId').length > 1) return sendJson(res, 400, { ok: false, code: 'invalid-query' })
646
+ const result = await readCostInsights(ctx, query.get('sessionId') || '')
647
+ res.setHeader('cache-control', 'no-store')
648
+ return sendJson(res, result.status, result.body)
649
+ }
640
650
  if (pathname.startsWith(`${MOUNT}/api/plugins/`)) {
641
651
  if (!remoteCommandAuthorized(req)) return sendJson(res, 401, { ok: false, message: 'unauthorized' })
642
652
  try {
package/insights.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // Read-only, bounded adapter for dsh-cost-meter 1.8.x. Never forward its config.
2
+ const states = new WeakMap()
3
+ const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
4
+ const number = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
5
+ const text = (value, max = 80) => typeof value === 'string' ? value.slice(0, max) : ''
6
+ const status = value => ['ok', 'off', 'error', 'stale', 'loading'].includes(value) ? value : 'unknown'
7
+ function amount(value) {
8
+ const row = record(value)
9
+ return { cost: number(row.cost), apiCost: number(row.apiCost ?? row.cost), calls: number(row.calls) }
10
+ }
11
+ function windows(value) {
12
+ return Object.entries(record(value)).slice(0, 8).map(([key, raw]) => {
13
+ const row = record(raw), percent = number(row.percent)
14
+ return { name: text(key), percent: percent !== null && percent <= 100 ? percent : null,
15
+ resetsAt: text(row.resetsAt), unlimited: row.unlimited === true }
16
+ })
17
+ }
18
+ export function summarizeCost(state, session, sessionId) {
19
+ const raw = record(state), config = record(raw.config), balance = record(raw.balance)
20
+ const plans = Object.entries(record(raw.codingPlans)).slice(0, 20)
21
+ .filter(([, row]) => record(row).enabled === true && record(row).display !== 'off')
22
+ .map(([name, value]) => {
23
+ const row = record(value)
24
+ return { name: text(name), status: status(row.status), fetchedAt: number(row.fetchedAt), windows: windows(row.windows) }
25
+ })
26
+ const go = record(raw.goQuota)
27
+ if (record(config.goQuota).enabled === true && record(config.goQuota).display !== 'off') {
28
+ plans.unshift({ name: 'OpenCode Go', status: status(go.status), fetchedAt: number(go.fetchedAt),
29
+ windows: windows({ rolling: go.rolling, weekly: go.weekly, monthly: go.monthly }).filter(row => row.percent !== null) })
30
+ }
31
+ const s = record(session)
32
+ return { schema: 1, available: true, source: 'dsh-cost-meter', generatedAt: Date.now(), currency: 'USD',
33
+ day: text(record(raw.runtime).dayKey || record(raw.today).date, 10),
34
+ today: config.hideTodayCost === true ? null : amount(raw.today),
35
+ session: sessionId ? { found: s.found === true, own: s.found === true ? amount(s.own) : null,
36
+ subagents: s.found === true ? amount(s.subagents) : null, subagentCount: number(s.subagentCount) } : null,
37
+ balance: config.hideOfficialBalance === true || record(config.balance).display === 'off' ? null : {
38
+ status: status(balance.status), fetchedAt: number(balance.fetchedAt),
39
+ currency: /^[A-Z]{3}$/.test(balance.currency) ? balance.currency : '', total: number(balance.totalBalance) }, plans }
40
+ }
41
+ function bounded(task, ms = 20000) {
42
+ let timer
43
+ return Promise.race([task, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms) })])
44
+ .finally(() => clearTimeout(timer))
45
+ }
46
+ export async function readCostInsights(ctx, sessionId = '') {
47
+ if (typeof sessionId !== 'string' || sessionId.length > 200 || /[\s\x00-\x1f\x7f]/.test(sessionId)) {
48
+ return { status: 400, body: { ok: false, code: 'invalid-session' } }
49
+ }
50
+ let service
51
+ try { service = ctx.get?.('costMeter') } catch {}
52
+ if (!service) return { status: 200, body: { ok: true, schema: 1, available: false, code: 'not-installed' } }
53
+ if (typeof service.getState !== 'function') return { status: 200, body: { ok: true, schema: 1, available: false, code: 'unsupported' } }
54
+ try {
55
+ // A short shared cache coalesces device reads; no credentials or settings are sent.
56
+ let cache = states.get(ctx)
57
+ const identity = service[Symbol.for('cordis.original')] ?? service
58
+ if (!cache || cache.service !== identity) { cache = { service: identity, at: 0 }; states.set(ctx, cache) }
59
+ if (!cache.task && (!cache.value || Date.now() - cache.at > 15000)) {
60
+ cache.task = bounded(Promise.resolve().then(() => service.getState())).then(value => {
61
+ if (!value || typeof value !== 'object' || !value.today) throw new Error('unsupported')
62
+ cache.value = value; cache.at = Date.now(); return value
63
+ }).finally(() => { cache.task = null })
64
+ }
65
+ const raw = cache.task ? await cache.task : cache.value
66
+ const session = sessionId && typeof service.getSessionCost === 'function'
67
+ ? await bounded(Promise.resolve().then(() => service.getSessionCost(sessionId))) : null
68
+ return { status: 200, body: { ok: true, ...summarizeCost(raw, session, sessionId) } }
69
+ } catch {
70
+ // Plugin failures may mention credentials, URLs or paths. Return a fixed code.
71
+ return { status: 502, body: { ok: false, code: 'cost-unavailable' } }
72
+ }
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -19,7 +19,8 @@
19
19
  "apk",
20
20
  "cordis.patch.yml",
21
21
  "*.md",
22
- "plugin-center.mjs"
22
+ "plugin-center.mjs",
23
+ "insights.mjs"
23
24
  ],
24
25
  "keywords": [
25
26
  "dsh-plugin",
package/plugin-center.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /* Profile-scoped plugin management. No shell input, dependencies or client-selected paths. */
2
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, realpathSync } from 'node:fs'
2
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, realpathSync, openSync, closeSync } from 'node:fs'
3
3
  import { dirname, basename, join, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { createHash } from 'node:crypto'
@@ -20,19 +20,66 @@ const fail = (message, status = 400) => Object.assign(new Error(message), { stat
20
20
  const protectedPackage = name => name === 'dsh-remote-plugin' || name.startsWith('@deepseek-ai/')
21
21
  const revision = path => createHash('sha256').update(readFileSync(path)).digest('hex')
22
22
 
23
- export function detectProfile(ctx, cli = process.argv[1]) {
23
+ function alive(pid) {
24
+ if (!Number.isInteger(pid) || pid <= 0) return false
25
+ try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' }
26
+ }
27
+
28
+ // Never clear a live worker or its package-manager child. PID reuse fails closed.
29
+ function recoverDeadLock(storage) {
30
+ const path = join(storage, 'lock.json')
31
+ if (!existsSync(path)) return
32
+ const owner = read(path)
33
+ if (!idPattern.test(owner.id || '')) throw fail('插件任务锁损坏,请在主机检查', 409)
34
+ const launchPath = join(storage, 'launch-' + owner.id + '.json')
35
+ const workerPid = owner.workerPid || (existsSync(launchPath) ? read(launchPath).pid : null)
36
+ if (alive(workerPid) || alive(owner.cliPid)) return
37
+ if (!workerPid && (!owner.launcherPid || alive(owner.launcherPid) || Date.now() - owner.createdAt < 30000)) return
38
+ const jobPath = join(storage, 'job-' + owner.id + '.json')
39
+ if (existsSync(jobPath)) {
40
+ const job = read(jobPath)
41
+ if (['queued', 'running'].includes(job.phase)) {
42
+ job.phase = 'interrupted'; job.endedAt = Date.now(); job.restartRequired = true
43
+ job.message = '任务进程已退出,已解除锁定。文件可能已改变,请核对插件状态;未执行自动回滚。'
44
+ atomic(jobPath, job)
45
+ }
46
+ }
47
+ // All reads/writes here are synchronous; recheck ownership before unlinking.
48
+ if (read(path).id === owner.id) unlinkSync(path)
49
+ }
50
+
51
+ export function detectProfile(ctx, cli = process.argv[1], runtime = process) {
24
52
  try {
25
53
  const dir = realpathSync(fileURLToPath(ctx.root?.baseUrl || ctx.baseUrl))
26
54
  const manifest = read(join(dir, 'package.json'))
27
55
  if (basename(dirname(dir)) !== 'profiles' || !Array.isArray(manifest.dsh?.profile?.bundles)) return null
28
56
  const name = basename(dir)
29
57
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*(?![\s\S])/.test(name)) return null
30
- let root = dirname(realpathSync(cli))
58
+ const base = { dir, name, home: dirname(dirname(dir)), cli: null }
59
+ // Desktop reserves this profile for its installation-owned carrier CLI.
60
+ // Bind to the host that booted this root, never a global CLI or PATH entry.
61
+ if (name === 'desktop') {
62
+ try {
63
+ const entry = realpathSync(cli), hostRoot = dirname(dirname(entry))
64
+ const host = read(join(hostRoot, 'package.json'))
65
+ const runtimeDir = realpathSync(resolve(hostRoot, '../../..'))
66
+ const bundled = read(join(runtimeDir, 'package.json'))
67
+ const carrier = join(hostRoot, 'lib', 'cli.js')
68
+ if (runtime.versions?.electron && host.name === '@deepseek-ai/dsh-desktop-host'
69
+ && entry === realpathSync(join(hostRoot, 'lib', 'index.js'))
70
+ && bundled.name === '@deepseek-ai/dsh-desktop-runtime' && bundled.version === host.version
71
+ && runtimeDir === realpathSync(runtime.argv[2]) && dir === realpathSync(runtime.argv[3])
72
+ && existsSync(carrier)) return { ...base, cli: carrier, cliMode: 'desktop' }
73
+ } catch {}
74
+ return base
75
+ }
76
+ let root
77
+ try { root = dirname(realpathSync(cli)) } catch { return base }
31
78
  let found = null
32
79
  for (let i = 0; i < 5; i++, root = dirname(root)) {
33
80
  try { if (read(join(root, 'package.json')).name === '@deepseek-ai/dsh') { found = join(root, 'lib', 'bin.js'); break } } catch {}
34
81
  }
35
- return { dir, name, home: dirname(dirname(dir)), cli: found && existsSync(found) ? found : null }
82
+ return { ...base, cli: found && existsSync(found) ? found : null }
36
83
  } catch { return null }
37
84
  }
38
85
 
@@ -68,10 +115,18 @@ export function createPluginCenter(ctx, options = {}) {
68
115
  const profile = options.profile === undefined ? detectProfile(ctx) : options.profile
69
116
  const details = options.details || pluginDetails
70
117
  const launch = options.launch || (args => {
71
- const child = spawn(process.execPath, [SELF, '--worker', ...args], { detached: true, windowsHide: true, stdio: 'ignore' })
118
+ const desktop = profile?.cliMode === 'desktop'
119
+ const errorLog = openSync(join(profile.dir, '.remote-plugin-center', 'worker-' + args[2] + '.log'), 'a', 0o600)
120
+ let child
121
+ try {
122
+ child = spawn(process.execPath, [...(desktop ? ['--expose-internals'] : []), SELF, '--worker', ...args], {
123
+ detached: true, windowsHide: true, stdio: ['ignore', 'ignore', errorLog],
124
+ env: { ...process.env, ...(desktop ? { ELECTRON_RUN_AS_NODE: '1' } : {}) },
125
+ })
126
+ } finally { closeSync(errorLog) }
72
127
  return new Promise((resolveLaunch, reject) => {
73
128
  child.once('error', reject)
74
- child.once('spawn', () => { child.unref(); resolveLaunch() })
129
+ child.once('spawn', () => { child.unref(); resolveLaunch(child.pid) })
75
130
  })
76
131
  })
77
132
  const manifestPath = profile && join(profile.dir, 'package.json')
@@ -79,6 +134,7 @@ export function createPluginCenter(ctx, options = {}) {
79
134
  const storage = profile && join(profile.dir, '.remote-plugin-center')
80
135
  let submitting = false
81
136
  async function inventory() {
137
+ if (storage) recoverDeadLock(storage)
82
138
  let runtime = [], runtimeAvailable = false
83
139
  const loader = ctx.get?.('loader') || ctx.loader
84
140
  if (loader?.entries) {
@@ -97,19 +153,46 @@ export function createPluginCenter(ctx, options = {}) {
97
153
  if (packagePattern.test(name)) {
98
154
  try { pkg = read(join(profile.dir, 'node_modules', ...name.split('/'), 'package.json')) } catch {}
99
155
  }
100
- return { name, version: pkg?.version || '', requested: manifest.dependencies?.[name] || '',
156
+ const displayName = typeof pkg?.displayName === 'string' ? pkg.displayName : typeof pkg?.dsh?.displayName === 'string' ? pkg.dsh.displayName : name
157
+ return { name, displayName, version: pkg?.version || '', requested: manifest.dependencies?.[name] || '',
101
158
  enabled: bundles.includes(name), bundle: !!pkg?.dsh?.bundle?.patch || bundles.includes(name),
102
159
  managed: !!manifest.dependencies?.[name] && !protectedPackage(name),
103
- description: pkg?.description || '' }
160
+ description: pkg?.description || '', runtime: runtime.filter(entry => entry.name === name) }
104
161
  })
105
162
  let operations = []
106
163
  if (existsSync(storage)) operations = readdirSync(storage).filter(name => /^job-[a-zA-Z0-9-]+\.json$/.test(name)).map(name => {
107
164
  try { return read(join(storage, name)) } catch { return null }
108
165
  }).filter(Boolean).sort((a, b) => b.startedAt - a.startedAt).slice(0, 20).map(publicJob)
166
+ const lockPath = join(storage, 'lock.json')
167
+ const owner = existsSync(lockPath) ? read(lockPath) : null
168
+ const legacyLock = owner && idPattern.test(owner.id || '') && !owner.launcherPid && !owner.workerPid && !owner.cliPid && !existsSync(join(storage, 'launch-' + owner.id + '.json'))
109
169
  return { ok: true, profile: profile.name, revision: revision(manifestPath), writable: !!profile.cli,
110
- reason: profile.cli ? '' : '当前启动方式没有可验证的 DSH CLI,管理操作不可用。',
111
- pendingRestart: revision(manifestPath) !== initialRevision,
112
- busy: submitting || existsSync(join(storage, 'lock.json')), items, runtime, runtimeAvailable, operations }
170
+ reason: profile.cli ? '' : profile.name === 'desktop'
171
+ ? '未能验证当前桌面安装的插件管理入口;仅显示状态,请检查 DSH Desktop 版本与安装完整性。'
172
+ : '当前启动方式没有可验证的 DSH CLI,管理操作不可用。',
173
+ pendingRestart: revision(manifestPath) !== initialRevision || operations.some(job => job.phase === 'interrupted' && job.restartRequired),
174
+ busy: submitting || !!owner, recovery: legacyLock ? { id: owner.id, reason: '旧任务锁没有进程信息,需在主机核对后手动解除。' } : null,
175
+ items, runtime, runtimeAvailable, operations }
176
+ }
177
+ function recoverLegacy(body) {
178
+ if (!profile?.cli || submitting) throw fail('当前不能恢复任务', 409)
179
+ const lockPath = join(storage, 'lock.json')
180
+ if (!existsSync(lockPath)) throw fail('任务锁已变化,请刷新', 409)
181
+ const owner = read(lockPath)
182
+ if (!idPattern.test(body.id || '') || body.id !== owner.id || owner.launcherPid || owner.workerPid || owner.cliPid || existsSync(join(storage, 'launch-' + owner.id + '.json'))) throw fail('仅支持人工恢复无进程信息的旧任务锁', 409)
183
+ if (revision(manifestPath) !== body.revision) throw fail('插件列表已变化,请刷新', 409)
184
+ if (body.confirmNoRunningProcess !== true) throw fail('请先在主机确认插件安装进程已退出', 409)
185
+ const jobPath = join(storage, 'job-' + owner.id + '.json')
186
+ if (existsSync(jobPath)) {
187
+ const job = read(jobPath)
188
+ if (['queued', 'running'].includes(job.phase)) {
189
+ job.phase = 'interrupted'; job.endedAt = Date.now(); job.restartRequired = true
190
+ job.message = '用户核对进程后解除了旧任务锁;保留现有文件和备份,未执行自动回滚。'
191
+ atomic(jobPath, job)
192
+ }
193
+ }
194
+ renameSync(lockPath, join(storage, 'recovered-lock-' + owner.id + '.json'))
195
+ return { ok: true }
113
196
  }
114
197
  async function submit(body) {
115
198
  if (!profile?.cli) throw fail('Current DSH profile is read-only', 409)
@@ -118,6 +201,7 @@ export function createPluginCenter(ctx, options = {}) {
118
201
  if (!idPattern.test(id || '') || !packagePattern.test(name || '') || !['install', 'update', 'remove', 'enable', 'disable'].includes(action)) throw fail('Invalid operation')
119
202
  if (protectedPackage(name)) throw fail('核心插件与 Remote 自身请通过主机维护流程管理', 409)
120
203
  mkdirSync(storage, { recursive: true, mode: 0o700 })
204
+ recoverDeadLock(storage)
121
205
  const jobPath = join(storage, 'job-' + id + '.json')
122
206
  if (existsSync(jobPath)) {
123
207
  const previous = read(jobPath)
@@ -142,12 +226,14 @@ export function createPluginCenter(ctx, options = {}) {
142
226
  }
143
227
  if (revision(manifestPath) !== body.revision) throw fail('插件配置已变化,请刷新', 409)
144
228
  const lock = join(storage, 'lock.json')
145
- try { writeFileSync(lock, JSON.stringify({ id }), { flag: 'wx', mode: 0o600 }) }
229
+ try { writeFileSync(lock, JSON.stringify({ id, launcherPid: process.pid, createdAt: Date.now() }), { flag: 'wx', mode: 0o600 }) }
146
230
  catch (error) { if (error.code === 'EEXIST') throw fail('已有插件任务运行中;请查看操作记录', 409); throw error }
147
231
  const job = { id, action, name, version: version || '', revision: body.revision, profile: profile.name, phase: 'queued', startedAt: Date.now(), log: '', restartRequired: false }
148
232
  try {
149
233
  atomic(jobPath, job)
150
- await launch([profile.dir, profile.cli, id])
234
+ const pid = await launch([profile.dir, profile.cli, id, profile.cliMode || 'node'])
235
+ // Separate ledger: the launcher must never overwrite a worker's CLI PID.
236
+ if (Number.isInteger(pid) && pid > 0) atomic(join(storage, 'launch-' + id + '.json'), { pid })
151
237
  } catch (error) {
152
238
  job.phase = 'failed'; job.message = '无法启动插件任务'; job.endedAt = Date.now()
153
239
  atomic(jobPath, job); unlinkSync(lock); throw error
@@ -167,6 +253,7 @@ export function createPluginCenter(ctx, options = {}) {
167
253
  return { ok: true, source: REGISTRY, total: data.total || 0, items: (data.objects || []).map(({ package: p }) => ({ name: p.name, version: p.version, description: p.description || '' })) }
168
254
  }
169
255
  if (method === 'POST' && sub === '/operations') return { ok: true, operation: await submit(body) }
256
+ if (method === 'POST' && sub === '/recover') return recoverLegacy(body)
170
257
  throw fail('Unknown plugin endpoint', 404)
171
258
  }
172
259
  return { handle, inventory, submit }
@@ -177,11 +264,13 @@ function publicJob(job) {
177
264
  return { id, action, name, version, profile, phase, startedAt, endedAt, log, message, restartRequired }
178
265
  }
179
266
 
180
- export async function runWorker(dir, cli, id, execute = runCli) {
267
+ export async function runWorker(dir, cli, id, execute = runCli, cliMode = 'node') {
181
268
  if (!idPattern.test(id || '')) throw fail('Invalid worker id')
182
269
  const storage = join(dir, '.remote-plugin-center')
183
270
  const lock = join(storage, 'lock.json')
184
- if (read(lock).id !== id) throw fail('Worker does not own lock')
271
+ const owner = read(lock)
272
+ if (owner.id !== id || owner.workerPid && owner.workerPid !== process.pid) throw fail('Worker does not own lock')
273
+ atomic(lock, { ...owner, workerPid: process.pid })
185
274
  const jobPath = join(storage, 'job-' + id + '.json')
186
275
  const job = read(jobPath)
187
276
  const manifestPath = join(dir, 'package.json')
@@ -199,11 +288,11 @@ export async function runWorker(dir, cli, id, execute = runCli) {
199
288
  }
200
289
  if (existsSync(disabledPath)) copyFileSync(disabledPath, join(backup, 'disabled.json'))
201
290
  if (['install', 'update', 'remove'].includes(job.action)) {
202
- const args = job.action === 'remove' ? ['remove', job.name, '--ignore-scripts'] : ['add', job.name + '@' + job.version, '--save-exact', '--ignore-scripts', '--registry=' + REGISTRY]
291
+ const args = job.action === 'remove' ? ['remove', job.name, '--config.ignore-scripts=true'] : ['add', job.name + '@' + job.version, '--save-exact', '--ignore-scripts', '--registry=' + REGISTRY]
203
292
  await execute(cli, ['plugin', '--profile', basename(dir), ...args], dir, chunk => {
204
293
  job.log = (job.log + chunk.replace(/\x1b\[[0-9;]*m/g, '').replace(/(token|password|authorization)\s*[=:]\s*\S+/gi, '$1=[redacted]')).slice(-12000)
205
294
  save()
206
- })
295
+ }, pid => atomic(lock, { ...read(lock), cliPid: pid }), cliMode)
207
296
  }
208
297
  if (job.action === 'disable') disabled.add(job.name)
209
298
  if (job.action === 'enable' || job.action === 'remove') disabled.delete(job.name)
@@ -230,10 +319,18 @@ export async function runWorker(dir, cli, id, execute = runCli) {
230
319
  }
231
320
  }
232
321
 
233
- function runCli(cli, args, dir, output) {
322
+ export function cliInvocation(cli, args, dir, cliMode = 'node', runtime = process) {
323
+ if (!['node', 'desktop'].includes(cliMode)) throw fail('Unsupported CLI mode')
324
+ return { command: runtime.execPath, args: [...(cliMode === 'desktop' ? ['--expose-internals'] : []), cli, ...args],
325
+ env: { ...runtime.env, DSH_HOME: dirname(dirname(dir)), npm_config_ignore_scripts: 'true',
326
+ ...(cliMode === 'desktop' ? { ELECTRON_RUN_AS_NODE: '1' } : {}) } }
327
+ }
328
+
329
+ function runCli(cli, args, dir, output, onSpawn, cliMode) {
234
330
  return new Promise((resolveRun, reject) => {
235
- const env = { ...process.env, DSH_HOME: dirname(dirname(dir)), npm_config_ignore_scripts: 'true' }
236
- const child = spawn(process.execPath, [cli, ...args], { cwd: dir, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
331
+ const invocation = cliInvocation(cli, args, dir, cliMode)
332
+ const child = spawn(invocation.command, invocation.args, { cwd: dir, env: invocation.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
333
+ child.once('spawn', () => onSpawn(child.pid))
237
334
  child.stdout.on('data', chunk => output(chunk.toString()))
238
335
  child.stderr.on('data', chunk => output(chunk.toString()))
239
336
  child.once('error', reject)
@@ -242,5 +339,5 @@ function runCli(cli, args, dir, output) {
242
339
  }
243
340
 
244
341
  if (process.argv[1] && resolve(process.argv[1]) === SELF && process.argv[2] === '--worker') {
245
- await runWorker(process.argv[3], process.argv[4], process.argv[5])
342
+ await runWorker(process.argv[3], process.argv[4], process.argv[5], undefined, process.argv[6])
246
343
  }
package/public/admin.html CHANGED
@@ -342,6 +342,7 @@
342
342
  <div class="admin-title">
343
343
  <div class="left">
344
344
  <a id="btn-console" class="mini-btn tb-btn" href="../" data-i18n-title="consoleTitle">‹ <span data-i18n="console">控制台</span></a>
345
+ <a id="btn-plugin-panel" class="mini-btn tb-btn hidden" href="../plugin.html" data-i18n-title="pluginPanel">‹ <span data-i18n="pluginPanel">状态面板</span></a>
345
346
  <button id="btn-close-drawer" class="mini-btn tb-btn hidden" data-i18n-title="collapse">‹ <span data-i18n="collapse">收起面板</span></button>
346
347
  <h1>DSH Remote <span data-i18n="admin">管理</span></h1>
347
348
  </div>
@@ -542,6 +543,7 @@
542
543
  zh: {
543
544
  'console': '控制台',
544
545
  'consoleTitle': '返回控制台',
546
+ 'pluginPanel': '状态面板',
545
547
  'collapse': '收起面板',
546
548
  'admin': '管理',
547
549
  'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': '网关运行正常', 'hero.attention': '网关需要关注', 'hero.plugin': 'DSH 插件已连接', 'hero.offline': '网关未运行', 'hero.checking': '正在检查网关',
@@ -652,6 +654,7 @@
652
654
  en: {
653
655
  'console': 'Console',
654
656
  'consoleTitle': 'Back to console',
657
+ 'pluginPanel': 'Status panel',
655
658
  'collapse': 'Collapse panel',
656
659
  'admin': 'Admin',
657
660
  'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': 'Gateway is healthy', 'hero.attention': 'Gateway needs attention', 'hero.plugin': 'DSH plugin connected', 'hero.offline': 'Gateway is not running', 'hero.checking': 'Checking gateway',
package/public/admin.js CHANGED
@@ -1028,6 +1028,7 @@ function start(showLogin) {
1028
1028
  if (pluginMode) {
1029
1029
  $('login-view').classList.add('hidden')
1030
1030
  $('btn-console').classList.add('hidden')
1031
+ if (location.protocol === 'dsh-app:' && window.parent !== window) $('btn-plugin-panel').classList.remove('hidden')
1031
1032
  $('btn-close-drawer').classList.remove('hidden')
1032
1033
  start(false)
1033
1034
  } else if (token) {