dsh-remote-plugin 0.7.0-rc.3 → 0.7.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.
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
@@ -23,13 +23,15 @@
23
23
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
24
24
  * DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
25
25
  * DSH_REMOTE_ADVERTISE_HOSTS 额外写入配对二维码的宿主 IP/主机名, 逗号或空白分隔
26
- * DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/disabled
26
+ * DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/process/disabled
27
+ * DSH_REMOTE_DSH_LAUNCH_FILE 本机 DSH 启动配置, 默认 ~/.dsh-remote/dsh-launch.json
27
28
  */
28
29
  'use strict'
29
30
 
30
31
  const http = require('node:http')
31
32
  const https = require('node:https')
32
- const { execFile } = require('node:child_process')
33
+ const { execFile, spawn } = require('node:child_process')
34
+ const net = require('node:net')
33
35
  const fs = require('node:fs')
34
36
  const path = require('node:path')
35
37
  const os = require('node:os')
@@ -91,7 +93,8 @@ const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim
91
93
  const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
92
94
  const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
93
95
  const DSH_CONTROL_MODE_RAW = String(process.env.DSH_REMOTE_DSH_CONTROL_MODE || 'auto').trim().toLowerCase()
94
- const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
96
+ const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'process', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
97
+ const DSH_LAUNCH_FILE = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || path.join(os.homedir(), '.dsh-remote', 'dsh-launch.json')
95
98
  const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
96
99
  const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
97
100
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
@@ -154,6 +157,7 @@ function containerRuntimeDetected() {
154
157
  }
155
158
 
156
159
  function dshControlSupport() {
160
+ if (DSH_CONTROL_MODE === 'process') return { supported: true, manager: 'process' }
157
161
  if (DSH_CONTROL_MODE === 'disabled') {
158
162
  return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前 DSH 由 Docker、面板或其他外部平台管理' }
159
163
  }
@@ -1035,6 +1039,96 @@ async function windowsServiceStatus() {
1035
1039
  }
1036
1040
  }
1037
1041
 
1042
+ // Only a live ChildProcess handle grants restart authority. Never trust a saved PID:
1043
+ // a gateway restart or PID reuse must not turn an unrelated process into our child.
1044
+ let dshManagedChild = null
1045
+ let dshManagedExit = null
1046
+ function readDshLaunch() {
1047
+ const config = JSON.parse(fs.readFileSync(DSH_LAUNCH_FILE, 'utf8'))
1048
+ if (config.version !== 1 || !path.isAbsolute(config.executable || '') ||
1049
+ !path.isAbsolute(config.cwd || '') || !Array.isArray(config.args) ||
1050
+ !config.args.length || !config.args.every(arg => typeof arg === 'string' && !arg.includes('\0')) ||
1051
+ !path.isAbsolute(config.args[0]) || !fs.statSync(config.args[0]).isFile() ||
1052
+ !fs.statSync(config.executable).isFile() || !fs.statSync(config.cwd).isDirectory()) {
1053
+ throw new Error('DSH 启动配置无效,请从 DSH Web 插件重新生成')
1054
+ }
1055
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(UPSTREAM.hostname) || config.upstream !== UPSTREAM.origin) {
1056
+ throw new Error('DSH 启动配置与本机上游地址不匹配')
1057
+ }
1058
+ const env = {}
1059
+ for (const key of ['DSH_HOME', 'HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
1060
+ if (typeof config.env?.[key] === 'string') env[key] = config.env[key]
1061
+ }
1062
+ return { ...config, env }
1063
+ }
1064
+
1065
+ function dshPortOccupied() {
1066
+ return new Promise(resolve => {
1067
+ const socket = net.connect({ host: UPSTREAM.hostname.replace(/^\[|\]$/g, ''), port: UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80) })
1068
+ const finish = value => { socket.destroy(); resolve(value) }
1069
+ socket.once('connect', () => finish(true))
1070
+ socket.once('error', err => finish(err.code !== 'ECONNREFUSED'))
1071
+ socket.setTimeout(1500, () => finish(true)) // uncertainty must not spawn a duplicate
1072
+ })
1073
+ }
1074
+
1075
+ async function dshProcessStatus() {
1076
+ const base = { ok: true, supported: true, manager: 'process', service: 'dsh-process', running: false, mainPid: 0, canRestart: false }
1077
+ try { readDshLaunch() } catch (err) {
1078
+ return { ...base, supported: false, code: 'LAUNCH_CONFIG_INVALID', message: '缺少有效 DSH 启动配置,请先从 DSH Web 启动 Remote 插件', detail: err.message }
1079
+ }
1080
+ if (dshManagedChild) {
1081
+ return { ...base, running: true, mainPid: dshManagedChild.pid, owned: true, canRestart: true, activeState: 'active', subState: 'running' }
1082
+ }
1083
+ const occupied = await dshPortOccupied()
1084
+ const probe = occupied ? await probeDshUpstream() : null
1085
+ return { ...base, running: !!probe?.ok, occupied, owned: false,
1086
+ activeState: occupied ? (probe?.ok ? 'active' : 'inactive') : dshManagedExit ? 'failed' : 'inactive',
1087
+ subState: occupied ? 'external' : 'stopped',
1088
+ ...(dshManagedExit && !occupied ? { result: 'process-exited', execMainStatus: dshManagedExit.code, logFile: dshManagedExit.logFile } : {}),
1089
+ }
1090
+ }
1091
+
1092
+ async function executeDshProcessAction(action) {
1093
+ const config = readDshLaunch()
1094
+ if (action === 'restart' && !dshManagedChild && await dshPortOccupied()) {
1095
+ return { ok: false, code: 'EXTERNAL_PROCESS', error: '当前 DSH 并非本网关启动,不能安全重启;请在原启动器中停止后重试' }
1096
+ }
1097
+ if (action === 'restart' && dshManagedChild) {
1098
+ const child = dshManagedChild
1099
+ const stopped = new Promise(resolve => {
1100
+ const timer = setTimeout(() => { child.removeListener('exit', onExit); resolve(false) }, 5000)
1101
+ const onExit = () => { clearTimeout(timer); resolve(true) }
1102
+ child.once('exit', onExit)
1103
+ })
1104
+ child.kill()
1105
+ if (!await stopped) return { ok: false, code: 'PROCESS_STOP_TIMEOUT', error: 'DSH 进程未及时退出,未启动第二个实例' }
1106
+ }
1107
+ if (dshManagedChild || await dshPortOccupied()) return { ok: false, code: 'PORT_IN_USE', error: 'DSH 端口已被占用,未启动重复实例' }
1108
+ const logFile = path.join(path.dirname(DSH_LAUNCH_FILE), 'dsh-process.log')
1109
+ fs.mkdirSync(path.dirname(logFile), { recursive: true })
1110
+ const fd = fs.openSync(logFile, 'a', 0o600)
1111
+ let child
1112
+ try {
1113
+ child = spawn(config.executable, config.args, {
1114
+ cwd: config.cwd, env: { ...process.env, ...config.env },
1115
+ detached: true, windowsHide: true, shell: false, stdio: ['ignore', fd, fd],
1116
+ })
1117
+ } finally { fs.closeSync(fd) }
1118
+ dshManagedChild = child
1119
+ dshManagedExit = null
1120
+ child.once('exit', code => {
1121
+ if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code, logFile } }
1122
+ })
1123
+ return new Promise(resolve => {
1124
+ child.once('error', err => {
1125
+ if (dshManagedChild === child) { dshManagedChild = null; dshManagedExit = { code: err.code, logFile } }
1126
+ resolve({ ok: false, code: 'PROCESS_START_FAILED', error: `${err.message};日志:${logFile}` })
1127
+ })
1128
+ child.once('spawn', () => { child.unref(); resolve({ ok: true, code: 0 }) })
1129
+ })
1130
+ }
1131
+
1038
1132
  async function dshServiceStatus() {
1039
1133
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
1040
1134
  return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
@@ -1042,7 +1136,12 @@ async function dshServiceStatus() {
1042
1136
  if (!DSH_CONTROL_SUPPORT.supported) {
1043
1137
  return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...DSH_CONTROL_SUPPORT }
1044
1138
  }
1045
- if (process.platform === 'win32') return windowsServiceStatus()
1139
+ if (DSH_CONTROL_MODE === 'process') return dshProcessStatus()
1140
+ if (process.platform === 'win32') {
1141
+ const service = await windowsServiceStatus()
1142
+ if (DSH_CONTROL_MODE === 'auto' && service.code === 'SERVICE_NOT_FOUND') return dshProcessStatus()
1143
+ return { ...service, manager: 'windows' }
1144
+ }
1046
1145
  const r = await execFileResult(SYSTEMCTL, [
1047
1146
  '--user', 'show', DSH_SERVICE,
1048
1147
  '--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
@@ -1081,6 +1180,7 @@ async function dshServiceStatus() {
1081
1180
  }
1082
1181
 
1083
1182
  async function executeDshServiceAction(action, initial) {
1183
+ if (initial?.manager === 'process') return executeDshProcessAction(action)
1084
1184
  if (process.platform !== 'win32') {
1085
1185
  return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
1086
1186
  }
@@ -1171,27 +1271,25 @@ function reconnectDshEventCollectors() {
1171
1271
 
1172
1272
  async function runDshControlOperation(operation) {
1173
1273
  try {
1174
- const manager = process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
1175
- dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
1274
+ dshOperationStep(operation, 'checking', '正在检查 DSH 启动配置与运行状态')
1176
1275
  const initial = await dshServiceStatus()
1177
1276
  operation.initialStatus = initial
1178
1277
  operation.observed = initial
1278
+ const manager = initial.manager === 'process' ? '本机进程' : process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
1279
+ operation.service = initial.service
1179
1280
  if (!initial.supported) {
1180
1281
  failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
1181
1282
  return
1182
1283
  }
1183
- if (operation.action === 'start' && initial.running) {
1184
- dshOperationStep(operation, 'complete', `DSH 已在运行(${initial.service},PID ${initial.mainPid || '未知'})`, {
1185
- ok: true, done: true, code: 'ALREADY_RUNNING', status: initial, upstream: await probeDshUpstream(),
1186
- })
1187
- return
1188
- }
1189
-
1190
1284
  dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
1191
- const command = await executeDshServiceAction(operation.action, initial)
1285
+ const command = operation.action === 'start' && initial.running
1286
+ ? { ok: true, code: 'ALREADY_RUNNING' }
1287
+ : await executeDshServiceAction(operation.action, initial)
1192
1288
  operation.command = { ok: command.ok, code: command.code, signal: command.signal }
1193
1289
  if (!command.ok) {
1194
- const failure = classifyDshServiceFailure(command)
1290
+ const failure = initial.manager === 'process'
1291
+ ? { code: command.code, message: command.error, detail: '' }
1292
+ : classifyDshServiceFailure(command)
1195
1293
  failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
1196
1294
  return
1197
1295
  }
@@ -1216,7 +1314,7 @@ async function runDshControlOperation(operation) {
1216
1314
  }
1217
1315
  if (operation.action === 'restart' && (status.mainPid > 0 && status.mainPid !== initialPid || status.activeState !== 'active')) restartObserved = true
1218
1316
  if (status.activeState === 'failed') {
1219
- failDshOperation(operation, 'SERVICE_FAILED', `DSH 服务进入 failed 状态(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, '', status)
1317
+ failDshOperation(operation, 'SERVICE_FAILED', `DSH 启动进程已退出(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, status.logFile ? `启动日志:${status.logFile}` : '', status)
1220
1318
  return
1221
1319
  }
1222
1320
  if (status.running && restartObserved) {
@@ -1605,6 +1703,12 @@ async function translateModernRpc(method, payload, rpcId) {
1605
1703
  if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return legacyFallback('no generated RPC adapter')
1606
1704
  endpoint = 'workspace/' + verb
1607
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
+ }
1608
1712
  } else if (method.startsWith('goal.')) {
1609
1713
  const verb = method.slice('goal.'.length)
1610
1714
  if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return legacyFallback('no generated RPC adapter')
@@ -1660,7 +1764,40 @@ async function translateModernRpc(method, payload, rpcId) {
1660
1764
  return legacyFallback('no generated RPC adapter')
1661
1765
  }
1662
1766
 
1663
- const response = await callUpstreamRemote(endpoint, args, rpcId)
1767
+ let response = await callUpstreamRemote(endpoint, args, rpcId)
1768
+ if (method === 'session.prompt' && imageAdmissionRejected(response.body)) {
1769
+ try {
1770
+ const bridged = await bridgeVisionPrompt(args.request)
1771
+ if (bridged) response = await callUpstreamRemote(endpoint, { ...args, request: bridged }, rpcId)
1772
+ } catch (error) {
1773
+ return legacyEnvelope(rpcId, modernError(error.message, 'image-vision-failed'))
1774
+ }
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
+ }
1664
1801
  if (modernResponseNeedsLegacyFallback(response)) return legacyFallback(`generated RPC ${endpoint} unavailable`)
1665
1802
  if (!response.body?.result?.ok) {
1666
1803
  recordCompatibility('generated-rpc-error', { method, status: response.status, detail: remoteFailureKind(response) })
@@ -1712,6 +1849,8 @@ function rememberCollectorReplay(kind, full, raw) {
1712
1849
  let key = ''
1713
1850
  if (payload.type === 'session/subscribed' && payload.sessionId) key = `session:${payload.sessionId}`
1714
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}`
1715
1854
  else if (payload.type === 'approval/requested' && payload.approvalId) key = `approval:${payload.approvalId}`
1716
1855
  else if (payload.type === 'question/requested' && full.rpcId) key = `question:${full.rpcId}`
1717
1856
  else if (payload.type === 'approval/resolved' && payload.approvalId) replay.delete(`approval:${payload.approvalId}`)
@@ -1989,6 +2128,28 @@ function openModernSessionStream(ws, sessionId) {
1989
2128
  type: 'open', streamId, endpoint: 'session/follow',
1990
2129
  payload: { args: { request: { address: { kind: 'session', sessionId }, assistantStream: true } } },
1991
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
+ }
1992
2153
  }
1993
2154
 
1994
2155
  function applyModernControlFrame(value) {
@@ -2001,16 +2162,14 @@ function applyModernControlFrame(value) {
2001
2162
  }
2002
2163
  for (const [sessionId, block] of Object.entries(value.value?.projections || {})) {
2003
2164
  for (const [key, projection] of Object.entries(block?.values || {})) {
2004
- legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: block?.asOfSeq ?? 0 })
2165
+ applyModernProjection(sessionId, key, projection, block?.asOfSeq ?? 0)
2005
2166
  }
2006
2167
  }
2007
2168
  return
2008
2169
  }
2009
2170
  if (value?.type === 'queue') legacyPush('mux', { type: 'session/queue', sessionId: value.sessionId, items: value.items || [] })
2010
2171
  else if (value?.type === 'jobs') legacyPush('mux', { type: 'session/jobs', sessionId: value.sessionId, jobs: value.jobs || [] })
2011
- else if (value?.type === 'projection') legacyPush('mux', {
2012
- type: 'session/projection', sessionId: value.sessionId, key: value.key, value: value.value, seq: value.seq,
2013
- })
2172
+ else if (value?.type === 'projection') applyModernProjection(value.sessionId, value.key, value.value, value.seq)
2014
2173
  }
2015
2174
 
2016
2175
  function applyModernWorkspaceFrame(value) {
@@ -2103,7 +2262,7 @@ function applyModernSessionFrame(sessionId, value) {
2103
2262
  if (record?.event) legacyPush('mux', { type: 'session/event', sessionId, event: record.event })
2104
2263
  }
2105
2264
  for (const [key, projection] of Object.entries(value.projections?.values || {})) {
2106
- 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)
2107
2266
  }
2108
2267
  return
2109
2268
  }
@@ -2176,7 +2335,11 @@ function applyModernRemoteEvent(ws, value) {
2176
2335
  modernState.sessionCursors.delete(args[0])
2177
2336
  modernState.assistantStreams.delete(args[0])
2178
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]}`)
2179
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 {}
2180
2343
  legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
2181
2344
  } else if (value.event === 'api-session/status') {
2182
2345
  legacyPush('host', { type: 'host/session-status', sessionId: args[0], running: !!args[1] })
@@ -2265,6 +2428,9 @@ function startModernEventCollector() {
2265
2428
  const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
2266
2429
  const frame = JSON.parse(data)
2267
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
2268
2434
  const message = frame.error?.message || 'modern stream error'
2269
2435
  for (const state of Object.values(eventCollectorState)) state.lastError = message
2270
2436
  legacyPush('mux', { type: 'stream/error', error: frame.error || { message } })
@@ -2274,6 +2440,7 @@ function startModernEventCollector() {
2274
2440
  if (frame.streamId === 'events') applyModernRemoteEvent(current, frame.value)
2275
2441
  else if (frame.streamId === 'control') applyModernControlFrame(frame.value)
2276
2442
  else if (frame.streamId === 'workspaces') applyModernWorkspaceFrame(frame.value)
2443
+ else if (frame.streamId.startsWith('jobs:')) applyModernJobFrame(frame.streamId.slice(5), frame.value)
2277
2444
  else if (frame.streamId.startsWith('session:')) applyModernSessionFrame(frame.streamId.slice(8), frame.value)
2278
2445
  } catch {}
2279
2446
  }
@@ -2761,7 +2928,7 @@ function serveStatic(req, res, url) {
2761
2928
  })
2762
2929
  return
2763
2930
  }
2764
- const apkOverride = pathname === '/dsh-remote.apk'
2931
+ const apkOverride = pathname === '/dsh-remote.apk' || pathname === '/dsh-remote-harmonyos-unsigned.hap' || pathname === '/dsh-remote-harmonyos.hap'
2765
2932
  const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
2766
2933
  const filePath = path.normalize(path.join(baseDir, pathname))
2767
2934
  if (filePath !== baseDir && !filePath.startsWith(baseDir + path.sep)) {
@@ -2787,7 +2954,7 @@ function serveStatic(req, res, url) {
2787
2954
  }
2788
2955
  res.writeHead(200, {
2789
2956
  'content-type': MIME[ext] || 'application/octet-stream',
2790
- 'cache-control': ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
2957
+ 'cache-control': pathname === '/harmonyos-update.json' ? 'no-store' : ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
2791
2958
  'content-length': st.size,
2792
2959
  'last-modified': lastModified
2793
2960
  })
@@ -2814,7 +2981,7 @@ function upstreamReachable(cb) {
2814
2981
  req.end()
2815
2982
  }
2816
2983
 
2817
- function serveAdminApi(req, res, url) {
2984
+ async function serveAdminApi(req, res, url) {
2818
2985
  const sub = url.pathname.slice('/admin/api'.length) || '/'
2819
2986
  if (sub === '/dsh') return serveDshControl(req, res, url)
2820
2987
  if (sub === '/state' && req.method === 'GET') {
@@ -2825,6 +2992,7 @@ function serveAdminApi(req, res, url) {
2825
2992
  res.end(JSON.stringify({ error: 'unauthorized' }))
2826
2993
  return
2827
2994
  }
2995
+ const control = await dshControlDescription()
2828
2996
  upstreamReachable((reachable) => {
2829
2997
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2830
2998
  res.end(JSON.stringify({
@@ -2840,8 +3008,8 @@ function serveAdminApi(req, res, url) {
2840
3008
  host: HOST,
2841
3009
  port: PORT,
2842
3010
  protocol: { version: PROTOCOL_VERSION },
2843
- capabilities: CAPABILITIES,
2844
- dshControl: DSH_CONTROL_SUPPORT,
3011
+ capabilities: { ...CAPABILITIES, dshLifecycle: control.supported ? 2 : 0 },
3012
+ dshControl: control,
2845
3013
  upstream: { url: UPSTREAM.origin, reachable },
2846
3014
  latest: {
2847
3015
  version: latestState.version,
@@ -4090,11 +4258,14 @@ function serveHandoff(req, res, url) {
4090
4258
  fsJson(res, 400, { error: 'sessionId required' })
4091
4259
  return
4092
4260
  }
4261
+ // 白名单字段只接受字符串:对象/数字/数组等一律按空串处理,
4262
+ // 避免 String({}) 产出 '[object Object]' 之类垃圾落盘并广播到其他设备
4263
+ const str = (v) => typeof v === 'string' ? v : ''
4093
4264
  handoffSave({
4094
4265
  sessionId: sessionId.slice(0, 128),
4095
- title: String(payload.title || '').slice(0, 200),
4096
- device: String(payload.device || '').slice(0, 80),
4097
- 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),
4098
4269
  at: Date.now()
4099
4270
  })
4100
4271
  fsJson(res, 200, { ok: true })
@@ -4218,7 +4389,50 @@ function readApiRequest(req, maxBytes = 4 * 1024 * 1024) {
4218
4389
  })
4219
4390
  }
4220
4391
 
4221
- async function forwardLegacyRpc(url, raw) {
4392
+ // Only the explicit pre-admission image rejection is safe to retry. Never retry
4393
+ // a timeout, ambiguous transport failure, or an already accepted prompt.
4394
+ function imageAdmissionRejected(body) {
4395
+ const result = body?.result
4396
+ return result?.ok === false && result.error?.code === 'session/attachment-invalid'
4397
+ && result.error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES'
4398
+ }
4399
+
4400
+ async function bridgeVisionPrompt(payload) {
4401
+ if (!Array.isArray(payload?.content) || !payload.content.some(part => part?.type === 'image')) return null
4402
+ const pluginFetch = (route, options = {}) => fetch(new URL('/api/dsh-image-vision/' + route, UPSTREAM), {
4403
+ ...options,
4404
+ headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
4405
+ signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
4406
+ redirect: 'error',
4407
+ })
4408
+ const configResponse = await pluginFetch('config')
4409
+ if (configResponse.status === 404) throw new Error('当前模型不支持图片,未找到兼容的 dsh-image-vision 插件;请安装并启用视觉辅助插件,或切换视觉模型。')
4410
+ if (!configResponse.ok) throw new Error('无法检查视觉辅助插件状态,请稍后重试。')
4411
+ const config = await configResponse.json()
4412
+ if (config?.config?.enabled !== true) throw new Error('当前模型不支持图片,视觉辅助插件尚未开启。请在 DSH 中启用插件后重试。')
4413
+ const content = []
4414
+ for (const part of payload.content) {
4415
+ if (part?.type !== 'image') { content.push(part); continue }
4416
+ if (typeof part.data !== 'string' || !part.data || typeof part.mediaType !== 'string') {
4417
+ throw new Error('视觉辅助转换失败:图片缺少内联数据,请重新选择图片。')
4418
+ }
4419
+ const response = await pluginFetch('attach', { method: 'POST', body: JSON.stringify({
4420
+ data: part.data, mediaType: part.mediaType,
4421
+ ...(typeof part.name === 'string' ? { name: part.name } : {}),
4422
+ }) })
4423
+ const value = await response.json().catch(() => null)
4424
+ // Accept only the plugin's same-upstream attachment reference, not arbitrary
4425
+ // returned text/URLs. Preserve every other block and the original send mode.
4426
+ if (!response.ok || value?.ok !== true || typeof value.markdown !== 'string'
4427
+ || !/^!\[图片\]\(\/api\/dsh-image-vision\/raw\/[A-Za-z0-9%._~-]+\?[^\s()<>]+\)$/.test(value.markdown)) {
4428
+ throw new Error('视觉辅助图片上传失败,消息尚未发送,请检查插件后重试。')
4429
+ }
4430
+ content.push({ type: 'text', text: value.markdown })
4431
+ }
4432
+ return { ...payload, content }
4433
+ }
4434
+
4435
+ async function forwardLegacyRpc(url, raw, allowVisionBridge = true) {
4222
4436
  const target = new URL(url.pathname + url.search, UPSTREAM)
4223
4437
  const response = await fetch(target, {
4224
4438
  method: 'POST',
@@ -4226,7 +4440,20 @@ async function forwardLegacyRpc(url, raw) {
4226
4440
  body: raw,
4227
4441
  signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
4228
4442
  })
4229
- return { status: response.status, headers: response.headers, raw: await response.text() }
4443
+ const result = { status: response.status, headers: response.headers, raw: await response.text() }
4444
+ if (allowVisionBridge) {
4445
+ let body, request
4446
+ try { body = JSON.parse(result.raw); request = JSON.parse(raw) } catch {}
4447
+ if (request?.method === 'session.prompt' && imageAdmissionRejected(body)) {
4448
+ try {
4449
+ const bridged = await bridgeVisionPrompt(request.payload)
4450
+ if (bridged) return forwardLegacyRpc(url, JSON.stringify({ ...request, payload: bridged }), false)
4451
+ } catch (error) {
4452
+ return { ...result, status: 200, raw: JSON.stringify(legacyEnvelope(request.rpcId, modernError(error.message, 'image-vision-failed'))) }
4453
+ }
4454
+ }
4455
+ }
4456
+ return result
4230
4457
  }
4231
4458
 
4232
4459
  function sendBufferedUpstreamResponse(res, response) {
@@ -4416,6 +4643,15 @@ function proxyApi(req, res, url) {
4416
4643
  }
4417
4644
 
4418
4645
  // ---------- 其它 ----------
4646
+ async function dshControlDescription() {
4647
+ if (!DSH_CONTROL_SUPPORT.supported) return DSH_CONTROL_SUPPORT
4648
+ if (process.platform !== 'win32' && DSH_CONTROL_MODE !== 'process') return DSH_CONTROL_SUPPORT
4649
+ const status = await dshServiceStatus()
4650
+ return { supported: status.supported, manager: status.manager || DSH_CONTROL_SUPPORT.manager,
4651
+ ...(status.code ? { code: status.code, message: status.message } : {}),
4652
+ ...(status.canRestart !== undefined ? { canRestart: status.canRestart } : {}) }
4653
+ }
4654
+
4419
4655
  async function serveHealth(req, res, url) {
4420
4656
  restoreMissingTokenFile()
4421
4657
  const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
@@ -4456,6 +4692,7 @@ async function serveHealth(req, res, url) {
4456
4692
  const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
4457
4693
  const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
4458
4694
  const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
4695
+ const control = await dshControlDescription()
4459
4696
  cors(res)
4460
4697
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
4461
4698
  res.end(JSON.stringify({
@@ -4466,8 +4703,8 @@ async function serveHealth(req, res, url) {
4466
4703
  readiness,
4467
4704
  version: VERSION,
4468
4705
  protocol: { version: PROTOCOL_VERSION },
4469
- capabilities: CAPABILITIES,
4470
- dshControl: DSH_CONTROL_SUPPORT,
4706
+ capabilities: { ...CAPABILITIES, dshLifecycle: control.supported ? 2 : 0 },
4707
+ dshControl: control,
4471
4708
  pid: process.pid,
4472
4709
  upstream: UPSTREAM.origin,
4473
4710
  upstreamProbe: DSH_HEALTH_PATH,
@@ -4500,7 +4737,7 @@ const server = http.createServer(async (req, res) => {
4500
4737
  if (url.pathname === '/diagnostics') return serveDiagnostics(req, res, url)
4501
4738
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
4502
4739
  if (url.pathname === '/handoff') return serveHandoff(req, res, url)
4503
- if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
4740
+ if (url.pathname.startsWith('/admin/api')) return await serveAdminApi(req, res, url)
4504
4741
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
4505
4742
  if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
4506
4743
  if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
package/index.mjs CHANGED
@@ -6,13 +6,13 @@
6
6
  * 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
7
7
  */
8
8
  import { execFileSync, spawn } from 'node:child_process'
9
- import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
10
10
  import { stat } from 'node:fs/promises'
11
11
  import net from 'node:net'
12
12
  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
- import { createPluginCenter } from './plugin-center.mjs'
15
+ import { createPluginCenter, detectProfile } from './plugin-center.mjs'
16
16
 
17
17
  export const name = 'dsh-remote'
18
18
  export const inject = ['webServer', 'commands', 'agents', 'connection']
@@ -976,10 +976,37 @@ async function serveStatic(req, res, ctx) {
976
976
  }
977
977
 
978
978
  const pluginCenters = new WeakMap()
979
+ export function saveDshLaunchConfig(ctx) {
980
+ // Desktop owns its Electron runtime. Never record a desktop or unknown launcher.
981
+ if (process.platform !== 'win32' || process.versions.electron) return false
982
+ const profile = detectProfile(ctx)
983
+ if (!profile?.cli || profile.name === 'desktop' || resolve(process.argv[1] || '') !== resolve(profile.cli)) return false
984
+ const env = { DSH_HOME: profile.home }
985
+ for (const key of ['HOME', 'USERPROFILE', 'PATH', 'NODE_OPTIONS']) {
986
+ if (process.env[key] !== undefined) env[key] = process.env[key]
987
+ }
988
+ const args = [profile.cli, ...process.argv.slice(2)]
989
+ if (!args.includes('--no-open')) args.push('--no-open')
990
+ const config = { version: 1, executable: process.execPath, args, cwd: process.cwd(),
991
+ env, profile: profile.name, upstream: upstreamUrlForListener({ host: ctx.webServer.host, port: ctx.webServer.port }) }
992
+ const file = process.env.DSH_REMOTE_DSH_LAUNCH_FILE || `${homedir()}/.dsh-remote/dsh-launch.json`
993
+ const tmp = `${file}.${process.pid}.tmp`
994
+ try {
995
+ mkdirSync(dirname(file), { recursive: true })
996
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
997
+ renameSync(tmp, file)
998
+ return true
999
+ } catch (err) {
1000
+ logGateway('无法保存 DSH 启动配置: ' + err.message)
1001
+ return false
1002
+ }
1003
+ }
1004
+
979
1005
  export function apply(ctx) {
980
1006
  pluginCenters.set(ctx, createPluginCenter(ctx))
981
1007
  dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
982
1008
  dshConnection = ctx.connection
1009
+ saveDshLaunchConfig(ctx)
983
1010
  ctx.effect(() => ctx.webServer.register({
984
1011
  kind: 'prefix',
985
1012
  path: MOUNT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.7.0-rc.3",
3
+ "version": "0.7.1",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -501,9 +501,11 @@ function closeNotesModal() {
501
501
  }
502
502
  async function checkNotesOnStart() {
503
503
  try {
504
- const res = await fetch('../update.json?t=' + Date.now())
505
- if (!res.ok) return
506
- openNotesModal(await res.json())
504
+ const res = await fetch('../update.json?t=' + Date.now(), { cache: 'no-store' })
505
+ if (!res.ok || res.redirected || !String(res.headers.get('content-type') || '').includes('application/json')) return
506
+ const info = await res.json()
507
+ if (!info || typeof info !== 'object' || !info.version) return
508
+ openNotesModal(info)
507
509
  } catch {}
508
510
  }
509
511
  async function submitFeedback() {
@@ -1,10 +1,18 @@
1
1
  {
2
- "version": "0.7.0-rc.3",
2
+ "version": "0.7.1",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "482490d9801a9e5c947fe02e3c7636b39c7089db8b38f55d732a963d848ee3fa",
5
- "releasedAt": "2026-09-20T09:53:01.965Z",
6
- "notes": "0.7.0-rc.3:手机端新增可选长按插队发送,默认关闭,可设置繁忙时默认排队或插队;直接提交插队请求,等待、超时与已接受状态分别提示,避免误认为工具已中断,失败时保留草稿。合入鸿蒙客户端、平板分栏与服务卡片源码,完善跨端接续接口;修复 WebSocket Ping 跨包组帧及接续接口非法输入处理。保留 rc.2 令牌恢复、认证状态和 IPv6 监听修复。本次提供 Android APK 与网关,未提供签名鸿蒙 HAP;鸿蒙大文件哈希、断点续传、快速切换服务器及 Android/Linux/fnOS 真机场景仍待验收。",
4
+ "sha256": "7ca47a78d152754d4991d5605d8e368ee0e4a20b7bfe1a90055d72c04069cff1",
5
+ "releasedAt": "2026-10-02T13:59:16.334Z",
6
+ "notes": "适配 DSH 0.2.0-rc.2 的 Inbox 队列、后台任务流与子代理目录,修复子代理接口 404 和归档后刷新状态滞后,保留旧版实时通道兼容;修复 Desktop 插件面板被标题栏遮挡、收起后阴影残留及更新说明响应校验。合入鸿蒙插件中心、长按发送和主题改进,并修复切换连接时的旧确认卡、误发送及迟到回执问题;鸿蒙本次仅更新源码,暂不提供安装包。",
7
7
  "history": [
8
+ {
9
+ "version": "0.7.1",
10
+ "notes": "适配 DSH 0.2.0-rc.2 的 Inbox 队列、后台任务流与子代理目录,修复子代理接口 404 和归档后刷新状态滞后,保留旧版实时通道兼容;修复 Desktop 插件面板被标题栏遮挡、收起后阴影残留及更新说明响应校验。合入鸿蒙插件中心、长按发送和主题改进,并修复切换连接时的旧确认卡、误发送及迟到回执问题;鸿蒙本次仅更新源码,暂不提供安装包。"
11
+ },
12
+ {
13
+ "version": "0.7.0",
14
+ "notes": "0.7.0:Windows 上未注册为服务的 DSH 可由 Remote 网关按已检查的 CLI 启动配置受控启动;已有 Windows 服务仍优先使用,网关只重启自己启动并持有句柄的进程。手机发送图片遇到 DSH 明确拒绝(当前模型不支持图片)时,可通过已启用的 dsh-image-vision 插件桥接附件;桥接失败不会提交不完整消息。保留长按插队发送、跨设备接续、鸿蒙客户端源码与平板分栏、网关 WebSocket Ping 分帧修复等 0.7.0 RC 功能。本次提供 Android APK 与 Windows/Linux 网关,不提供鸿蒙 HAP。"
15
+ },
8
16
  {
9
17
  "version": "0.6.26",
10
18
  "notes": "0.6.26:修复 DSH 通配监听地址导致的认证与实时连接失败,兼容 IPv6-only;上传覆盖失败保留原文件,避免并发同名覆盖和续传分片竞争;网关改用认证关闭,避免误杀其他进程;完善 dsh-ui 堆叠柱状图、标签页、表格排序与特殊单元格、趋势线、环形进度、文件树和修改前后预览。已通过 Windows 自动测试与浏览器检查,Linux/Docker 专项实测仍待完成。"
@@ -36,14 +44,6 @@
36
44
  {
37
45
  "version": "0.6.19",
38
46
  "notes": "0.6.19:全面优化手机端、桌面端、管理页和插件页的布局、动效与响应式体验,并支持减少动态效果偏好;重做蓝色、草原、音乐厅等主题的明暗层级和文字图标对比度,新增利落的黑白配色;统一主页图标配色并修正设置齿轮中心孔偏移;目标面板支持收起为始终可见的侧边入口,保留运行状态、可随时恢复,并按会话与目标在本地记忆。"
39
- },
40
- {
41
- "version": "0.6.18",
42
- "notes": "0.6.18:新增独立 Token 量趋势统计并保留原有费用趋势;服务器连接身份改为地址加令牌,支持同地址不同令牌并兼容旧配置;投票在网关不可达时尝试直连公网收集器托底(需收集器允许 App/Web CORS);修复 Windows 下 gateway-stats 调用 zstd 时弹出终端窗口的问题,并补充回归测试;同步完善相关模块文档。"
43
- },
44
- {
45
- "version": "0.6.17",
46
- "notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。"
47
47
  }
48
48
  ]
49
49
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.7.0-rc.3"
2
+ "version": "0.7.1"
3
3
  }