dsh-remote-plugin 0.6.19 → 0.6.20

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
@@ -10,7 +10,14 @@ window.__ModuleLoader__.load({
10
10
  var exports = module.exports
11
11
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
12
12
  var React = require('react')
13
- var runtime = require('@deepseek-ai/dsh-client-runtime/client')
13
+ // DSH 0.1.2-alpha.1 将 defineStore 从已删除的 client-runtime 迁入
14
+ // client-store 平台种子;0.1.1-rc.2 及更早版本仍只提供旧模块。
15
+ var storeRuntime
16
+ try {
17
+ storeRuntime = require('@deepseek-ai/dsh-client-store')
18
+ } catch (_) {
19
+ storeRuntime = require('@deepseek-ai/dsh-client-runtime/client')
20
+ }
14
21
 
15
22
  var DRAWER_STYLE = {
16
23
  position: 'fixed', top: 12, right: 12, bottom: 12, zIndex: 2147483000,
@@ -146,7 +153,7 @@ window.__ModuleLoader__.load({
146
153
 
147
154
  var inject = ['slots']
148
155
  function apply(ctx) {
149
- var store = runtime.defineStore({
156
+ var store = storeRuntime.defineStore({
150
157
  init: function () { return { open: false } },
151
158
  actions: {
152
159
  toggle: function (d) { d.open = !d.open },
package/gateway.cjs CHANGED
@@ -78,6 +78,7 @@ const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM
78
78
  const DSH_HEALTH_PATH = String(process.env.DSH_HEALTH_PATH || '/').startsWith('/')
79
79
  ? String(process.env.DSH_HEALTH_PATH || '/')
80
80
  : '/' + String(process.env.DSH_HEALTH_PATH)
81
+ const DSH_UPSTREAM_COOKIE_FILE = process.env.DSH_REMOTE_DSH_COOKIE_FILE || path.join(os.homedir(), '.dsh-remote', 'dsh-upstream.cookie')
81
82
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
82
83
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
83
84
  const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
@@ -94,6 +95,24 @@ const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 1
94
95
  const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
95
96
  const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
96
97
 
98
+ /** 读取插件兑换的新版 DSH 会话 Cookie;动态读取允许 DSH 重启后原地刷新。 */
99
+ function dshUpstreamCookie() {
100
+ try {
101
+ const value = fs.readFileSync(DSH_UPSTREAM_COOKIE_FILE, 'utf8').trim()
102
+ if (!value.includes('=') || value.length > 4096 || /[\0\r\n]/.test(value)) return ''
103
+ return value
104
+ } catch {
105
+ return ''
106
+ }
107
+ }
108
+
109
+ function dshUpstreamHeaders(base = {}) {
110
+ const headers = { ...base }
111
+ const cookie = dshUpstreamCookie()
112
+ if (cookie) headers.cookie = cookie
113
+ return headers
114
+ }
115
+
97
116
  // 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
98
117
  const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
99
118
  'https://api.github.com/repos/Blank-not-black/dsh-Remote/releases/latest'
@@ -1055,6 +1074,7 @@ async function probeDshUpstream() {
1055
1074
  const startedAt = Date.now()
1056
1075
  try {
1057
1076
  const probe = await fetch(new URL(DSH_HEALTH_PATH, UPSTREAM), {
1077
+ headers: dshUpstreamHeaders(),
1058
1078
  signal: AbortSignal.timeout(Math.min(2500, UPSTREAM_REQUEST_TIMEOUT_MS)),
1059
1079
  cache: 'no-store',
1060
1080
  })
@@ -1314,6 +1334,211 @@ const eventCollectorState = {
1314
1334
  }
1315
1335
  const eventCollectors = { mux: null, host: null }
1316
1336
 
1337
+ // DSH 0.1.2-alpha.1 replaced dotted RPC names and the two downlink sockets with
1338
+ // generated slash RPCs over one logical-stream mux. Keep the public Remote
1339
+ // contract stable here so older DSH releases and newer generated Remotes can
1340
+ // both serve the same zero-build clients.
1341
+ let upstreamApiFlavor = 'unknown'
1342
+ let upstreamApiFlavorProbe = null
1343
+ const modernState = {
1344
+ home: '',
1345
+ eventClientId: '',
1346
+ sessions: new Map(),
1347
+ sessionCursors: new Map(),
1348
+ workspaces: { items: [], archivedSessionIds: [] },
1349
+ pendingEvents: new Map(),
1350
+ }
1351
+
1352
+ async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
1353
+ const target = new URL('/api/' + endpoint, UPSTREAM)
1354
+ const response = await fetch(target, {
1355
+ method: 'POST',
1356
+ headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
1357
+ body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload: { args } }),
1358
+ signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
1359
+ })
1360
+ const body = await response.json().catch(() => null)
1361
+ return { status: response.status, body }
1362
+ }
1363
+
1364
+ async function detectUpstreamApiFlavor(force = false) {
1365
+ if (!force && upstreamApiFlavor !== 'unknown') return upstreamApiFlavor
1366
+ if (!force && upstreamApiFlavorProbe) return upstreamApiFlavorProbe
1367
+ upstreamApiFlavorProbe = (async () => {
1368
+ try {
1369
+ const probe = await callUpstreamRemote('session/list', { _request: {} })
1370
+ upstreamApiFlavor = probe.status === 200
1371
+ && probe.body?.result?.ok === true
1372
+ && Array.isArray(probe.body?.result?.value?.items)
1373
+ ? 'modern'
1374
+ : 'legacy'
1375
+ if (upstreamApiFlavor === 'modern' && probe.body?.result?.ok) {
1376
+ updateModernSessions(probe.body.result.value?.items)
1377
+ }
1378
+ } catch {
1379
+ upstreamApiFlavor = 'legacy'
1380
+ } finally {
1381
+ upstreamApiFlavorProbe = null
1382
+ }
1383
+ return upstreamApiFlavor
1384
+ })()
1385
+ return upstreamApiFlavorProbe
1386
+ }
1387
+
1388
+ function updateModernSessions(items) {
1389
+ if (!Array.isArray(items)) return
1390
+ modernState.sessions.clear()
1391
+ for (const item of items) {
1392
+ if (!item?.sessionId) continue
1393
+ modernState.sessions.set(item.sessionId, item)
1394
+ const cursor = Number(item.projections?.asOfSeq)
1395
+ if (Number.isSafeInteger(cursor) && cursor >= -1) modernState.sessionCursors.set(item.sessionId, cursor)
1396
+ }
1397
+ }
1398
+
1399
+ function legacyEnvelope(rpcId, result) {
1400
+ return { rpcId, result }
1401
+ }
1402
+
1403
+ function modernError(message, code = 'upstream-incompatible', details = {}) {
1404
+ return { ok: false, error: { code, message, details } }
1405
+ }
1406
+
1407
+ function legacyHistoryValue(value, summary) {
1408
+ const records = Array.isArray(value?.records) ? value.records : []
1409
+ return {
1410
+ events: records.map(record => ({ event: record?.event })).filter(entry => entry.event),
1411
+ hasMore: !!value?.hasMore,
1412
+ ...(summary?.projections ? { projections: summary.projections } : {}),
1413
+ }
1414
+ }
1415
+
1416
+ async function refreshModernSessions() {
1417
+ const response = await callUpstreamRemote('session/list', { _request: {} })
1418
+ if (response.status === 200 && response.body?.result?.ok) updateModernSessions(response.body.result.value?.items)
1419
+ return response
1420
+ }
1421
+
1422
+ async function translateModernRpc(method, payload, rpcId) {
1423
+ let endpoint = ''
1424
+ let args = {}
1425
+ let transform = value => value
1426
+
1427
+ if (method === 'host.describe') {
1428
+ return legacyEnvelope(rpcId, { ok: true, value: { home: modernState.home || os.homedir(), canOpenPath: false } })
1429
+ }
1430
+ if (method === 'workspace.list') {
1431
+ return legacyEnvelope(rpcId, { ok: true, value: modernState.workspaces })
1432
+ }
1433
+ if (method === 'session.list') {
1434
+ const response = await refreshModernSessions()
1435
+ return response.body || legacyEnvelope(rpcId, modernError('DSH session/list returned no JSON response'))
1436
+ }
1437
+ if (method === 'session.history') {
1438
+ let summary = modernState.sessions.get(payload.sessionId)
1439
+ if (!summary) {
1440
+ await refreshModernSessions()
1441
+ summary = modernState.sessions.get(payload.sessionId)
1442
+ }
1443
+ const throughSeq = modernState.sessionCursors.get(payload.sessionId) ?? Number(summary?.projections?.asOfSeq)
1444
+ if (!Number.isSafeInteger(throughSeq) || throughSeq < -1) {
1445
+ return legacyEnvelope(rpcId, modernError('DSH did not expose a history cursor for this session', 'session-not-found'))
1446
+ }
1447
+ endpoint = 'session/page'
1448
+ args = { request: {
1449
+ address: { kind: 'session', sessionId: payload.sessionId },
1450
+ throughSeq,
1451
+ ...(payload.beforeSeq === undefined ? {} : { beforeSeq: payload.beforeSeq }),
1452
+ ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
1453
+ } }
1454
+ transform = value => legacyHistoryValue(value, summary)
1455
+ } else if (method === 'session.models') {
1456
+ endpoint = 'session/modelCatalog'
1457
+ args = {}
1458
+ transform = catalog => {
1459
+ const summary = modernState.sessions.get(payload.sessionId)
1460
+ const projected = summary?.projections?.values?.modelSelection
1461
+ const current = projected?.next || projected?.lastUsed || catalog?.default
1462
+ return {
1463
+ current,
1464
+ routable: !!current && (!Array.isArray(catalog?.routableProviders) || catalog.routableProviders.includes(current.provider)),
1465
+ groups: catalog?.groups || [],
1466
+ failures: catalog?.failures || [],
1467
+ }
1468
+ }
1469
+ } else if (method.startsWith('session.')) {
1470
+ const verb = method.slice('session.'.length)
1471
+ if (!['search', 'create', 'selectModel', 'rename', 'fork', 'prompt', 'attachment', 'updateQueue', 'cancel'].includes(verb)) return null
1472
+ endpoint = 'session/' + verb
1473
+ const request = verb === 'prompt' && !payload.requestId
1474
+ ? { ...payload, requestId: crypto.randomUUID() }
1475
+ : payload
1476
+ args = verb === 'search' ? { request } : { request }
1477
+ } else if (method.startsWith('workspace.')) {
1478
+ const verb = method.slice('workspace.'.length)
1479
+ if (!['create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession'].includes(verb)) return null
1480
+ endpoint = 'workspace/' + verb
1481
+ args = { request: payload }
1482
+ } else if (method.startsWith('goal.')) {
1483
+ const verb = method.slice('goal.'.length)
1484
+ if (!['create', 'edit', 'pause', 'resume', 'complete', 'clear'].includes(verb)) return null
1485
+ endpoint = 'goals/' + verb
1486
+ args = verb === 'create'
1487
+ ? { agentId: payload.sessionId, request: { objective: payload.objective, ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } }
1488
+ : { agentId: payload.sessionId, ref: payload.ref, ...(verb === 'edit' ? { request: { ...(payload.objective === undefined ? {} : { objective: payload.objective }), ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }) } } : {}) }
1489
+ transform = value => verb === 'create'
1490
+ ? value
1491
+ : verb === 'clear'
1492
+ ? { cleared: true }
1493
+ : { ref: { id: value?.id, revision: value?.revision } }
1494
+ } else if (method === 'subagent.list') {
1495
+ endpoint = 'subagents/list'
1496
+ args = { parentSessionId: payload.parentSessionId }
1497
+ } else if (method === 'subagent.interrupt') {
1498
+ endpoint = 'subagents/interruptByParent'
1499
+ args = { childSessionId: payload.childSessionId, parentSessionId: payload.parentSessionId, mode: payload.mode }
1500
+ } else if (method === 'settings.describe') {
1501
+ endpoint = 'settings/describe'
1502
+ args = {}
1503
+ } else if (method === 'settings.mutate') {
1504
+ endpoint = 'settings/mutate'
1505
+ args = { ns: payload.ns, ops: payload.ops, expectedRevision: payload.expectedRevision }
1506
+ } else if (method === 'settings.openDocument') {
1507
+ endpoint = 'settings/openSettingsDocument'
1508
+ args = {}
1509
+ } else if (method.startsWith('credentials.')) {
1510
+ const verb = method.slice('credentials.'.length)
1511
+ if (!['describe', 'set', 'unset'].includes(verb)) return null
1512
+ endpoint = 'credentials/' + verb
1513
+ args = verb === 'describe' ? { refs: payload.refs } : verb === 'set' ? { ref: payload.ref, value: payload.value } : { ref: payload.ref }
1514
+ if (verb !== 'describe') transform = () => ({})
1515
+ } else if (method === 'llm.providers') {
1516
+ endpoint = 'llm/listConfigurableProviders'
1517
+ args = {}
1518
+ transform = values => ({ providers: (Array.isArray(values) ? values : []).map(entry => ({
1519
+ provider: entry.provider,
1520
+ displayName: entry.displayName,
1521
+ settingsNs: entry.settingsNs,
1522
+ settingsPath: entry.settingsPath || [],
1523
+ active: true,
1524
+ ...(entry.declared === undefined ? {} : { declared: entry.declared }),
1525
+ })) })
1526
+ } else if (method === 'llm.discoverModels') {
1527
+ endpoint = 'llm/discoverModels'
1528
+ args = {
1529
+ settingsNs: payload.settingsNs,
1530
+ request: Object.fromEntries(Object.entries(payload).filter(([key, value]) => key !== 'settingsNs' && value !== undefined)),
1531
+ }
1532
+ transform = models => ({ models: Array.isArray(models) ? models : [] })
1533
+ } else {
1534
+ return null
1535
+ }
1536
+
1537
+ const response = await callUpstreamRemote(endpoint, args, rpcId)
1538
+ if (!response.body?.result?.ok) return response.body || legacyEnvelope(rpcId, modernError(`DSH ${endpoint} returned no JSON response`))
1539
+ return legacyEnvelope(rpcId, { ok: true, value: transform(response.body.result.value) })
1540
+ }
1541
+
1317
1542
  /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
1318
1543
  function truncateEventValue(v, depth = 0) {
1319
1544
  if (typeof v === 'string') return v.length > EVENT_MAX_STRING ? v.slice(0, EVENT_MAX_STRING) + '…[truncated]' : v
@@ -1530,7 +1755,8 @@ function startEventCollector(kind) {
1530
1755
  if (stopped) return
1531
1756
  let current
1532
1757
  try {
1533
- current = new WebSocket(url)
1758
+ const headers = dshUpstreamHeaders()
1759
+ current = Object.keys(headers).length ? new WebSocket(url, { headers }) : new WebSocket(url)
1534
1760
  ws = current
1535
1761
  } catch (err) {
1536
1762
  state.lastError = String(err?.message || err)
@@ -1616,6 +1842,280 @@ function startEventCollector(kind) {
1616
1842
  }
1617
1843
  }
1618
1844
 
1845
+ function legacyPush(kind, payload, rpcId = crypto.randomUUID()) {
1846
+ pushEvent(kind, { rpcId, payload })
1847
+ }
1848
+
1849
+ function openModernSessionStream(ws, sessionId) {
1850
+ if (!sessionId || ws.readyState !== 1) return
1851
+ const streamId = 'session:' + sessionId
1852
+ ws.send(JSON.stringify({
1853
+ type: 'open', streamId, endpoint: 'session/follow',
1854
+ payload: { args: { request: { address: { kind: 'session', sessionId } } } },
1855
+ }))
1856
+ }
1857
+
1858
+ function applyModernControlFrame(value) {
1859
+ if (value?.type === 'baseline') {
1860
+ for (const [sessionId, items] of Object.entries(value.value?.queues || {})) {
1861
+ legacyPush('mux', { type: 'session/queue', sessionId, items })
1862
+ }
1863
+ for (const [sessionId, jobs] of Object.entries(value.value?.jobs || {})) {
1864
+ legacyPush('mux', { type: 'session/jobs', sessionId, jobs })
1865
+ }
1866
+ for (const [sessionId, block] of Object.entries(value.value?.projections || {})) {
1867
+ for (const [key, projection] of Object.entries(block?.values || {})) {
1868
+ legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: block?.asOfSeq ?? 0 })
1869
+ }
1870
+ }
1871
+ return
1872
+ }
1873
+ if (value?.type === 'queue') legacyPush('mux', { type: 'session/queue', sessionId: value.sessionId, items: value.items || [] })
1874
+ else if (value?.type === 'jobs') legacyPush('mux', { type: 'session/jobs', sessionId: value.sessionId, jobs: value.jobs || [] })
1875
+ else if (value?.type === 'projection') legacyPush('mux', {
1876
+ type: 'session/projection', sessionId: value.sessionId, key: value.key, value: value.value, seq: value.seq,
1877
+ })
1878
+ }
1879
+
1880
+ function applyModernWorkspaceFrame(value) {
1881
+ if (value?.type === 'baseline') {
1882
+ modernState.workspaces = {
1883
+ items: Array.isArray(value.value?.items) ? value.value.items : [],
1884
+ archivedSessionIds: Array.isArray(value.value?.archivedSessionIds) ? value.value.archivedSessionIds : [],
1885
+ }
1886
+ return
1887
+ }
1888
+ if (value?.type === 'upsert' && value.workspace) {
1889
+ const items = modernState.workspaces.items.filter(item => item.workspaceId !== value.workspace.workspaceId)
1890
+ items.push(value.workspace)
1891
+ modernState.workspaces = { ...modernState.workspaces, items }
1892
+ legacyPush('host', { type: 'host/workspace-changed', workspace: value.workspace })
1893
+ } else if (value?.type === 'remove') {
1894
+ modernState.workspaces = {
1895
+ ...modernState.workspaces,
1896
+ items: modernState.workspaces.items.filter(item => item.workspaceId !== value.workspaceId),
1897
+ }
1898
+ legacyPush('host', { type: 'host/workspace-removed', workspaceId: value.workspaceId })
1899
+ } else if (value?.type === 'order') {
1900
+ const byId = new Map(modernState.workspaces.items.map(item => [item.workspaceId, item]))
1901
+ const ordered = (value.workspaceIds || []).map(id => byId.get(id)).filter(Boolean)
1902
+ for (const item of modernState.workspaces.items) if (!value.workspaceIds?.includes(item.workspaceId)) ordered.push(item)
1903
+ modernState.workspaces = { ...modernState.workspaces, items: ordered }
1904
+ legacyPush('host', { type: 'host/workspace-order-changed', workspaceIds: value.workspaceIds || [] })
1905
+ } else if (value?.type === 'archived') {
1906
+ modernState.workspaces = { ...modernState.workspaces, archivedSessionIds: value.archivedSessionIds || [] }
1907
+ legacyPush('host', { type: 'host/archived-sessions-changed', archivedSessionIds: value.archivedSessionIds || [] })
1908
+ }
1909
+ }
1910
+
1911
+ function applyModernSessionFrame(sessionId, value) {
1912
+ if (value?.type === 'snapshot') {
1913
+ modernState.sessionCursors.set(sessionId, value.cursor)
1914
+ legacyPush('mux', { type: 'session/subscribed', sessionId, lastSeq: value.cursor })
1915
+ for (const record of value.records || []) {
1916
+ if (record?.event) legacyPush('mux', { type: 'session/event', sessionId, event: record.event })
1917
+ }
1918
+ for (const [key, projection] of Object.entries(value.projections?.values || {})) {
1919
+ legacyPush('mux', { type: 'session/projection', sessionId, key, value: projection, seq: value.projections?.asOfSeq ?? value.cursor })
1920
+ }
1921
+ return
1922
+ }
1923
+ if (value?.type === 'event' && value.event) {
1924
+ modernState.sessionCursors.set(sessionId, value.event.seq)
1925
+ legacyPush('mux', { type: 'session/event', sessionId, event: value.event })
1926
+ }
1927
+ }
1928
+
1929
+ function resolveModernPendingEvent(eventId, cancelled = false) {
1930
+ const pending = modernState.pendingEvents.get(eventId)
1931
+ if (!pending) return
1932
+ modernState.pendingEvents.delete(eventId)
1933
+ if (pending.event === 'approval/request') {
1934
+ legacyPush('mux', {
1935
+ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: eventId,
1936
+ outcome: cancelled ? 'cancelled' : pending.outcome,
1937
+ })
1938
+ } else if (pending.event === 'user-questions/request') {
1939
+ legacyPush('mux', {
1940
+ type: 'question/resolved', sessionId: pending.sessionId, questionRpcId: eventId,
1941
+ outcome: cancelled ? 'cancelled' : 'answered',
1942
+ })
1943
+ }
1944
+ }
1945
+
1946
+ function applyModernRemoteEvent(ws, value) {
1947
+ if (value?.type === 'ready') {
1948
+ modernState.eventClientId = value.clientId || ''
1949
+ modernState.home = value.host?.home || modernState.home
1950
+ return
1951
+ }
1952
+ if (value?.type === 'cancel') {
1953
+ resolveModernPendingEvent(value.eventId, true)
1954
+ return
1955
+ }
1956
+ if (value?.type === 'waterfall') {
1957
+ const sessionId = value.agentId
1958
+ modernState.pendingEvents.set(value.eventId, { event: value.event, sessionId, outcome: '' })
1959
+ if (value.event === 'approval/request') {
1960
+ legacyPush('mux', {
1961
+ type: 'approval/requested', sessionId, approvalId: value.eventId,
1962
+ toolName: value.request?.toolName || '',
1963
+ ...(value.request?.callId === undefined ? {} : { callId: value.request.callId }),
1964
+ ...(value.request?.reason === undefined ? {} : { reason: value.request.reason }),
1965
+ }, value.eventId)
1966
+ } else if (value.event === 'user-questions/request') {
1967
+ legacyPush('mux', { type: 'question/requested', sessionId, questions: value.request?.questions || [] }, value.eventId)
1968
+ }
1969
+ return
1970
+ }
1971
+ if (value?.type !== 'emit') return
1972
+ const args = value.args || []
1973
+ if (value.event === 'api-session/added') {
1974
+ const summary = args[0]
1975
+ if (summary?.sessionId) {
1976
+ modernState.sessions.set(summary.sessionId, summary)
1977
+ openModernSessionStream(ws, summary.sessionId)
1978
+ legacyPush('host', {
1979
+ type: 'host/session-added', sessionId: summary.sessionId, blank: !!summary.blank,
1980
+ ...(summary.parentSessionId === undefined ? {} : { parentSessionId: summary.parentSessionId }),
1981
+ ...(summary.origin === undefined ? {} : { origin: summary.origin }),
1982
+ ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),
1983
+ })
1984
+ }
1985
+ } else if (value.event === 'api-session/removed') {
1986
+ modernState.sessions.delete(args[0])
1987
+ modernState.sessionCursors.delete(args[0])
1988
+ try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
1989
+ legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
1990
+ } else if (value.event === 'api-session/status') {
1991
+ legacyPush('host', { type: 'host/session-status', sessionId: args[0], running: !!args[1] })
1992
+ } else if (value.event === 'api-session/error') {
1993
+ legacyPush('host', { type: 'host/agent-error', sessionId: args[0], message: String(args[1] || '') })
1994
+ } else {
1995
+ legacyPush('host', { type: 'host/remote-event', event: value.event, args })
1996
+ }
1997
+ }
1998
+
1999
+ function startModernEventCollector() {
2000
+ if (typeof WebSocket !== 'function') return null
2001
+ let ws = null
2002
+ let stopped = false
2003
+ let retryTimer = null
2004
+ let connectTimer = null
2005
+ let attempt = 0
2006
+ const scheme = UPSTREAM.protocol === 'https:' ? 'wss' : 'ws'
2007
+ const url = `${scheme}://${UPSTREAM_AUTHORITY}/api/remote.mux`
2008
+ const setConnected = (connected, error = '') => {
2009
+ for (const state of Object.values(eventCollectorState)) {
2010
+ state.connected = connected
2011
+ if (connected) {
2012
+ state.lastConnectAt = Date.now()
2013
+ state.lastError = ''
2014
+ state.attempt = 0
2015
+ } else if (error) state.lastError = error
2016
+ }
2017
+ }
2018
+ const schedule = () => {
2019
+ if (stopped || retryTimer) return
2020
+ const delay = Math.round(Math.min(1500 * Math.pow(2, attempt++), 60000) * (0.8 + Math.random() * 0.4))
2021
+ retryTimer = setTimeout(() => { retryTimer = null; connect() }, delay)
2022
+ retryTimer.unref?.()
2023
+ }
2024
+ const connect = () => {
2025
+ if (stopped) return
2026
+ let current
2027
+ try {
2028
+ const headers = dshUpstreamHeaders()
2029
+ current = Object.keys(headers).length ? new WebSocket(url, { headers }) : new WebSocket(url)
2030
+ ws = current
2031
+ } catch (error) {
2032
+ setConnected(false, String(error?.message || error))
2033
+ schedule()
2034
+ return
2035
+ }
2036
+ let finished = false
2037
+ const finish = (code = 0, reason = '', error = '') => {
2038
+ if (finished) return
2039
+ finished = true
2040
+ clearTimeout(connectTimer)
2041
+ modernState.eventClientId = ''
2042
+ setConnected(false, error)
2043
+ for (const state of Object.values(eventCollectorState)) {
2044
+ state.lastCloseCode = Number(code) || 0
2045
+ state.lastCloseReason = String(reason || '')
2046
+ state.reconnects++
2047
+ }
2048
+ if (ws === current) ws = null
2049
+ if (!stopped) schedule()
2050
+ }
2051
+ connectTimer = setTimeout(() => {
2052
+ finish(0, '', 'websocket connect timeout')
2053
+ try { current.close() } catch {}
2054
+ }, WS_UPGRADE_TIMEOUT_MS)
2055
+ connectTimer.unref?.()
2056
+ current.onopen = () => {
2057
+ if (finished || stopped || ws !== current) return
2058
+ clearTimeout(connectTimer)
2059
+ attempt = 0
2060
+ setConnected(true)
2061
+ current.send(JSON.stringify({ type: 'open', streamId: 'events', endpoint: '$events', payload: { args: {} } }))
2062
+ current.send(JSON.stringify({ type: 'open', streamId: 'control', endpoint: 'session/control', payload: { args: {} } }))
2063
+ current.send(JSON.stringify({ type: 'open', streamId: 'workspaces', endpoint: 'workspace/follow', payload: { args: {} } }))
2064
+ for (const sessionId of modernState.sessions.keys()) openModernSessionStream(current, sessionId)
2065
+ }
2066
+ current.onmessage = ev => {
2067
+ try {
2068
+ const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
2069
+ const frame = JSON.parse(data)
2070
+ if (frame.type === 'error') {
2071
+ const message = frame.error?.message || 'modern stream error'
2072
+ for (const state of Object.values(eventCollectorState)) state.lastError = message
2073
+ legacyPush('mux', { type: 'stream/error', error: frame.error || { message } })
2074
+ return
2075
+ }
2076
+ if (frame.type !== 'item') return
2077
+ if (frame.streamId === 'events') applyModernRemoteEvent(current, frame.value)
2078
+ else if (frame.streamId === 'control') applyModernControlFrame(frame.value)
2079
+ else if (frame.streamId === 'workspaces') applyModernWorkspaceFrame(frame.value)
2080
+ else if (frame.streamId.startsWith('session:')) applyModernSessionFrame(frame.streamId.slice(8), frame.value)
2081
+ } catch {}
2082
+ }
2083
+ current.onclose = ev => finish(ev?.code, ev?.reason)
2084
+ current.onerror = err => {
2085
+ finish(0, '', String(err?.error?.message || err?.message || 'websocket error'))
2086
+ try { current.close() } catch {}
2087
+ }
2088
+ }
2089
+ connect()
2090
+ return {
2091
+ kind: 'modern',
2092
+ reconnectNow() {
2093
+ if (stopped || ws?.readyState === 0 || ws?.readyState === 1) return
2094
+ clearTimeout(retryTimer)
2095
+ retryTimer = null
2096
+ attempt = 0
2097
+ connect()
2098
+ },
2099
+ close() {
2100
+ stopped = true
2101
+ clearTimeout(retryTimer)
2102
+ clearTimeout(connectTimer)
2103
+ try { ws?.close() } catch {}
2104
+ },
2105
+ }
2106
+ }
2107
+
2108
+ async function startCompatibleEventCollectors() {
2109
+ if (await detectUpstreamApiFlavor() === 'modern') {
2110
+ const collector = startModernEventCollector()
2111
+ eventCollectors.mux = collector
2112
+ eventCollectors.host = collector
2113
+ return
2114
+ }
2115
+ eventCollectors.mux = startEventCollector('mux')
2116
+ eventCollectors.host = startEventCollector('host')
2117
+ }
2118
+
1619
2119
  // ---------- 统计 API ----------
1620
2120
  let statsScanning = false
1621
2121
  async function scanStatsOnce(delay) {
@@ -2048,6 +2548,7 @@ function upstreamReachable(cb) {
2048
2548
  port: UPSTREAM_PORT,
2049
2549
  method: 'GET',
2050
2550
  path: '/health',
2551
+ headers: dshUpstreamHeaders(),
2051
2552
  timeout: 1500
2052
2553
  }, (res) => {
2053
2554
  res.resume()
@@ -2330,16 +2831,21 @@ async function loadFsWorkspaceRoots(force = false) {
2330
2831
  if (fsWorkspaceRootsFetch) return fsWorkspaceRootsFetch
2331
2832
  fsWorkspaceRootsFetch = (async () => {
2332
2833
  try {
2333
- const target = new URL('/api/workspace.list', UPSTREAM)
2334
- const res = await fetch(target, {
2335
- method: 'POST',
2336
- headers: { 'content-type': 'application/json' },
2337
- body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
2338
- signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
2339
- })
2340
- if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
2341
- const body = await res.json()
2342
- const value = body?.result?.ok ? body.result.value : null
2834
+ let value
2835
+ if (await detectUpstreamApiFlavor() === 'modern') {
2836
+ value = modernState.workspaces
2837
+ } else {
2838
+ const target = new URL('/api/workspace.list', UPSTREAM)
2839
+ const res = await fetch(target, {
2840
+ method: 'POST',
2841
+ headers: dshUpstreamHeaders({ 'content-type': 'application/json' }),
2842
+ body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
2843
+ signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
2844
+ })
2845
+ if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
2846
+ const body = await res.json()
2847
+ value = body?.result?.ok ? body.result.value : null
2848
+ }
2343
2849
  const items = Array.isArray(value?.items) ? value.items : []
2344
2850
  const roots = [...new Set(items.map(fsWorkspacePath).filter(Boolean))]
2345
2851
  const reals = roots.map(root => { try { return fs.realpathSync(root) } catch { return null } }).filter(Boolean)
@@ -3263,38 +3769,84 @@ function serveWorkbench(req, res, url) {
3263
3769
  }
3264
3770
 
3265
3771
  // ---------- /api 代理 ----------
3266
- function proxyApi(req, res, url) {
3267
- if (req.method === 'OPTIONS') {
3268
- cors(res)
3269
- res.writeHead(204)
3270
- res.end()
3271
- return
3272
- }
3273
- const ok = authorized(req, url)
3274
- touchDevice(req, ok ? {} : { failedAuth: true })
3275
- if (!ok) {
3276
- authFailures++
3772
+ async function proxyModernApi(req, res, url) {
3773
+ if (req.method !== 'POST' || url.pathname.startsWith('/remote/')) return false
3774
+ if (await detectUpstreamApiFlavor() !== 'modern') return false
3775
+ let raw = ''
3776
+ try {
3777
+ raw = await new Promise((resolve, reject) => {
3778
+ req.setEncoding('utf8')
3779
+ req.on('data', chunk => {
3780
+ raw += chunk
3781
+ if (raw.length > 4 * 1024 * 1024) reject(new Error('request body too large'))
3782
+ })
3783
+ req.once('end', () => resolve(raw))
3784
+ req.once('error', reject)
3785
+ req.once('aborted', () => reject(new Error('request aborted')))
3786
+ })
3787
+ const body = JSON.parse(raw || '{}')
3277
3788
  cors(res)
3278
- res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
3279
- res.end(JSON.stringify({ error: 'unauthorized' }))
3280
- return
3789
+ if (url.pathname === '/api/respond') {
3790
+ const eventId = body.rpcId
3791
+ const pending = modernState.pendingEvents.get(eventId)
3792
+ if (!pending || !modernState.eventClientId) {
3793
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
3794
+ res.end(JSON.stringify({ accepted: false }))
3795
+ return true
3796
+ }
3797
+ const value = body.result?.value
3798
+ const answer = pending.event === 'approval/request' ? value?.outcome : value?.answer
3799
+ pending.outcome = answer
3800
+ const response = await callUpstreamRemote('$events/result', {
3801
+ clientId: modernState.eventClientId,
3802
+ eventId,
3803
+ outcome: { kind: 'result', value: answer },
3804
+ })
3805
+ const accepted = response.status === 200 && response.body?.result?.ok === true
3806
+ if (accepted) resolveModernPendingEvent(eventId, false)
3807
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
3808
+ res.end(JSON.stringify({ accepted }))
3809
+ return true
3810
+ }
3811
+ if (body.type !== 'client-request' || typeof body.rpcId !== 'string' || typeof body.method !== 'string') {
3812
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
3813
+ res.end(JSON.stringify({ error: 'bad-request' }))
3814
+ return true
3815
+ }
3816
+ const translated = await translateModernRpc(body.method, body.payload || {}, body.rpcId)
3817
+ if (translated === null) {
3818
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
3819
+ res.end(JSON.stringify(legacyEnvelope(body.rpcId, modernError(`Remote method ${body.method} is unavailable on this DSH version`, 'method-unavailable'))))
3820
+ return true
3821
+ }
3822
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
3823
+ res.end(JSON.stringify(translated))
3824
+ return true
3825
+ } catch (error) {
3826
+ if (!res.headersSent) {
3827
+ cors(res)
3828
+ res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
3829
+ }
3830
+ if (!res.writableEnded) res.end(JSON.stringify({ error: 'upstream-incompatible', detail: String(error?.message || error) }))
3831
+ return true
3281
3832
  }
3833
+ }
3282
3834
 
3835
+ function proxyLegacyApi(req, res, url) {
3283
3836
  const headers = {}
3284
3837
  for (const [k, v] of Object.entries(req.headers)) {
3285
3838
  if (v === undefined) continue
3286
3839
  const key = k.toLowerCase()
3287
- if (['host', 'authorization', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
3840
+ if (['host', 'authorization', 'cookie', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
3288
3841
  'proxy-connection', 'accept-encoding', 'origin', 'referer',
3289
3842
  'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
3290
3843
  'x-dsh-remote-client'].includes(key)) continue
3291
3844
  headers[k] = v
3292
3845
  }
3293
3846
  headers.host = UPSTREAM.host
3847
+ Object.assign(headers, dshUpstreamHeaders())
3294
3848
  // /remote/* 由 DSH 插件端点处理;插件侧用网关自身 token 鉴权。
3295
- if (url.pathname.startsWith('/remote/')) {
3296
- headers.authorization = 'Bearer ' + TOKEN
3297
- }
3849
+ if (url.pathname.startsWith('/remote/')) headers.authorization = 'Bearer ' + TOKEN
3298
3850
 
3299
3851
  let responseDone = false
3300
3852
  const upstreamReq = UPSTREAM_TRANSPORT.request({
@@ -3336,6 +3888,31 @@ function proxyApi(req, res, url) {
3336
3888
  req.pipe(upstreamReq)
3337
3889
  }
3338
3890
 
3891
+ function proxyApi(req, res, url) {
3892
+ if (req.method === 'OPTIONS') {
3893
+ cors(res)
3894
+ res.writeHead(204)
3895
+ res.end()
3896
+ return
3897
+ }
3898
+ const ok = authorized(req, url)
3899
+ touchDevice(req, ok ? {} : { failedAuth: true })
3900
+ if (!ok) {
3901
+ authFailures++
3902
+ cors(res)
3903
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
3904
+ res.end(JSON.stringify({ error: 'unauthorized' }))
3905
+ return
3906
+ }
3907
+ void proxyModernApi(req, res, url).then(handled => {
3908
+ if (!handled) proxyLegacyApi(req, res, url)
3909
+ }).catch(error => {
3910
+ cors(res)
3911
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
3912
+ if (!res.writableEnded) res.end(JSON.stringify({ error: 'upstream-unreachable', detail: String(error?.message || error) }))
3913
+ })
3914
+ }
3915
+
3339
3916
  // ---------- 其它 ----------
3340
3917
  async function serveHealth(req, res, url) {
3341
3918
  const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
@@ -3364,7 +3941,7 @@ async function serveHealth(req, res, url) {
3364
3941
  const ctrl = new AbortController()
3365
3942
  timer = setTimeout(() => ctrl.abort(), 5000)
3366
3943
  const probeUrl = new URL(DSH_HEALTH_PATH, UPSTREAM).toString()
3367
- const probe = await fetch(probeUrl, { signal: ctrl.signal, cache: 'no-store' })
3944
+ const probe = await fetch(probeUrl, { headers: dshUpstreamHeaders(), signal: ctrl.signal, cache: 'no-store' })
3368
3945
  upstreamReachable = true
3369
3946
  upstreamStatus = probe.status
3370
3947
  upstreamOk = probe.ok
@@ -3637,7 +4214,7 @@ server.on('upgrade', (req, socket, head) => {
3637
4214
  for (const [k, v] of Object.entries(req.headers)) {
3638
4215
  if (v === undefined) continue
3639
4216
  const key = k.toLowerCase()
3640
- if (['host', 'authorization', 'connection', 'upgrade', 'sec-websocket-key',
4217
+ if (['host', 'authorization', 'cookie', 'connection', 'upgrade', 'sec-websocket-key',
3641
4218
  'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol',
3642
4219
  'proxy-connection', 'accept-encoding', 'origin', 'referer',
3643
4220
  'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
@@ -3645,6 +4222,7 @@ server.on('upgrade', (req, socket, head) => {
3645
4222
  headers[k] = v
3646
4223
  }
3647
4224
  headers.host = UPSTREAM.host
4225
+ Object.assign(headers, dshUpstreamHeaders())
3648
4226
  headers.connection = 'Upgrade'
3649
4227
  headers.upgrade = 'websocket'
3650
4228
  if (req.headers['sec-websocket-key']) headers['sec-websocket-key'] = req.headers['sec-websocket-key']
@@ -3747,8 +4325,7 @@ server.listen(PORT, HOST, () => {
3747
4325
  }
3748
4326
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
3749
4327
  // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
3750
- eventCollectors.mux = startEventCollector('mux')
3751
- eventCollectors.host = startEventCollector('host')
4328
+ void startCompatibleEventCollectors()
3752
4329
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
3753
4330
  setTimeout(() => checkForUpdates(false), 8000)
3754
4331
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
package/index.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
7
7
  */
8
8
  import { execFileSync, spawn } from 'node:child_process'
9
- import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { appendFileSync, chmodSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } 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'
@@ -14,7 +14,7 @@ import { dirname, extname, normalize, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
 
16
16
  export const name = 'dsh-remote'
17
- export const inject = ['webServer', 'commands', 'agents']
17
+ export const inject = ['webServer', 'commands', 'agents', 'connection']
18
18
 
19
19
  const MOUNT = '/remote'
20
20
  const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
@@ -68,6 +68,42 @@ try {
68
68
 
69
69
  // DSH 实际监听地址由 apply 时从 webServer 服务读取
70
70
  let dshListen = { host: '127.0.0.1', port: 3080 }
71
+ let dshConnection = null
72
+
73
+ function dshUpstreamCookieFile() {
74
+ return process.env.DSH_REMOTE_DSH_COOKIE_FILE || `${homedir()}/.dsh-remote/dsh-upstream.cookie`
75
+ }
76
+
77
+ /**
78
+ * DSH 0.1.2-alpha.1 起,Host RPC 与 WebSocket 都要求浏览器会话 Cookie。
79
+ * 新版 connection 服务可把仅进程内可见的启动令牌兑换为 Cookie;旧版没有
80
+ * authenticatedUrl,直接跳过即可。文件只让同一用户的独立网关读取。
81
+ */
82
+ async function refreshDshUpstreamCookie() {
83
+ if (typeof dshConnection?.authenticatedUrl !== 'function') return false
84
+ const upstream = `http://${dshListen.host}:${dshListen.port}`
85
+ try {
86
+ const loginUrl = dshConnection.authenticatedUrl(upstream)
87
+ const response = await fetch(loginUrl, {
88
+ redirect: 'manual',
89
+ signal: AbortSignal.timeout(2500),
90
+ })
91
+ const setCookie = response.headers.get('set-cookie') || ''
92
+ const cookie = setCookie.split(';', 1)[0].trim()
93
+ if (response.status !== 303 || !cookie.includes('=') || cookie.length > 4096 || /[\0\r\n]/.test(cookie)) {
94
+ throw new Error(`认证交换返回 HTTP ${response.status}`)
95
+ }
96
+ const file = dshUpstreamCookieFile()
97
+ mkdirSync(dirname(file), { recursive: true })
98
+ writeFileSync(file, cookie + '\n', { mode: 0o600 })
99
+ chmodSync(file, 0o600)
100
+ return true
101
+ } catch (e) {
102
+ // 禁止记录带启动令牌的 URL 或 Cookie,只保留不含凭据的错误摘要。
103
+ logGateway('刷新 DSH 上游认证失败: ' + (e?.message || String(e)))
104
+ return false
105
+ }
106
+ }
71
107
 
72
108
  function lanIPs() {
73
109
  const out = [...configuredAdvertisedHosts()]
@@ -169,6 +205,7 @@ function runExit(cmd, args) {
169
205
 
170
206
  const GATEWAY_ENV_KEYS = [
171
207
  'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
208
+ 'DSH_REMOTE_DSH_COOKIE_FILE',
172
209
  'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
173
210
  'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
174
211
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
@@ -363,6 +400,7 @@ function ensureGateway() {
363
400
  if (ensurePromise) return ensurePromise
364
401
  ensurePromise = (async () => {
365
402
  try {
403
+ await refreshDshUpstreamCookie()
366
404
  const health = await gatewayRunning()
367
405
  if (!health.running) {
368
406
  const out = await startGateway()
@@ -740,7 +778,9 @@ async function serveStatic(req, res, ctx) {
740
778
  commandNames = ['list-error: ' + (e?.message || String(e))]
741
779
  }
742
780
  const signal = AbortSignal.timeout(30000)
743
- const result = await ctx.commands.execute(agent, line, signal)
781
+ const result = ctx.commands.execute.length === 3
782
+ ? await ctx.commands.execute(agent, line, signal)
783
+ : await ctx.commands.execute(agent, line, [], signal)
744
784
  sendJson(res, 200, { ok: true, executed: result !== undefined, debug: { resolvePath, commandNames } })
745
785
  } catch (e) {
746
786
  sendJson(res, 200, { ok: false, message: e?.message || String(e) })
@@ -783,6 +823,7 @@ async function serveStatic(req, res, ctx) {
783
823
 
784
824
  export function apply(ctx) {
785
825
  dshListen = { host: ctx.webServer.host, port: ctx.webServer.port }
826
+ dshConnection = ctx.connection
786
827
  ctx.effect(() => ctx.webServer.register({
787
828
  kind: 'prefix',
788
829
  path: MOUNT,
@@ -794,4 +835,10 @@ export function apply(ctx) {
794
835
  })
795
836
  // DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
796
837
  void ensureGateway()
838
+ // apply 可能早于 Web 监听完成;延迟再交换一次新版 DSH 的会话 Cookie。
839
+ ctx.effect(() => {
840
+ const timer = setTimeout(() => { void ensureGateway() }, 5000)
841
+ timer.unref?.()
842
+ return () => clearTimeout(timer)
843
+ }, 'dsh-remote: refresh upstream authentication')
797
844
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/app.js CHANGED
@@ -2108,6 +2108,7 @@ async function openSession(id) {
2108
2108
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
2109
2109
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
2110
2110
  renderQueue()
2111
+ renderSessionPending()
2111
2112
  restoreCachedHistory()
2112
2113
  await loadHistory(true)
2113
2114
  renderSessionCards()
@@ -2140,6 +2141,7 @@ async function closeSession() {
2140
2141
  const discard = await shouldDiscardEmptySession(sessionId)
2141
2142
  if (state.current !== sessionId) return
2142
2143
  state.current = null
2144
+ renderSessionPending()
2143
2145
  if (discard) removeLocalSessionRecord(sessionId)
2144
2146
  setComposerFullscreen(false)
2145
2147
  clearComposerImages()
@@ -3329,7 +3331,7 @@ function renderOverview() {
3329
3331
  $('overview-attention-list').innerHTML = pending.length ? pending.slice(0, 3).map(({ kind, item }) => {
3330
3332
  const title = titleOf(state.byId.get(item.sessionId))
3331
3333
  if (kind === 'approval') return `<div class="overview-attention-item" data-overview-approval="${esc(item.approvalId)}">
3332
- <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(item.reason || t('pending.noReason'))} · ${esc(title)}</span></span>
3334
+ <span class="overview-item-mark">⌁</span><span class="overview-item-copy"><span class="overview-item-title">${esc(item.toolName || t('tool.default'))}</span><span class="overview-item-desc">${esc(approvalDetail(item))} · ${esc(title)}</span></span>
3333
3335
  <span class="overview-item-actions"><button type="button" class="mini-btn" data-overview-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-overview-approve="0">${t('pending.reject')}</button></span>
3334
3336
  </div>`
3335
3337
  return `<button type="button" class="overview-attention-item question" data-overview-question="${esc(item.rpcId)}">
@@ -3394,7 +3396,7 @@ function renderPending() {
3394
3396
  const title = titleOf(state.byId.get(a.sessionId))
3395
3397
  return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
3396
3398
  <div class="pc-title">${esc(t('pending.approvalTitle', { tool: a.toolName || t('tool.default') }))}</div>
3397
- <div class="pc-desc">${esc(a.reason || t('pending.noReason'))}</div>
3399
+ <div class="pc-desc">${esc(approvalDetail(a))}</div>
3398
3400
  <div class="pc-session">${esc(title)}</div>
3399
3401
  <div class="goal-actions"><button class="mini-btn" data-approve="1">${t('pending.allow')}</button><button class="mini-btn" data-approve="0">${t('pending.reject')}</button></div>
3400
3402
  </div>`
@@ -3414,10 +3416,48 @@ function renderPending() {
3414
3416
  })
3415
3417
  list.querySelectorAll('[data-question]').forEach(btn =>
3416
3418
  btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.question))))
3419
+ renderSessionPending()
3417
3420
  updatePendingBadge()
3418
3421
  renderOverview()
3419
3422
  }
3420
3423
 
3424
+ function approvalDetail(a) {
3425
+ const lines = []
3426
+ if (a?.reason) lines.push(String(a.reason))
3427
+ if (a?.arguments !== undefined) lines.push(safeJson(a.arguments))
3428
+ if (a?.callId) lines.push(`callId: ${a.callId}`)
3429
+ return lines.join('\n') || t('pending.noReason')
3430
+ }
3431
+
3432
+ function renderSessionPending() {
3433
+ const box = $('session-pending')
3434
+ if (!box) return
3435
+ const sessionId = state.current
3436
+ const items = [
3437
+ ...state.approvals.filter(a => a.sessionId === sessionId).map(a => ({ kind: 'approval', item: a })),
3438
+ ...state.questions.filter(q => q.sessionId === sessionId).map(q => ({ kind: 'question', item: q }))
3439
+ ]
3440
+ box.classList.toggle('hidden', !sessionId || !items.length)
3441
+ if (!sessionId || !items.length) { box.innerHTML = ''; return }
3442
+ box.innerHTML = `<div class="session-pending-head"><span>${esc(t('overview.attention'))}</span><span>${esc(t('pending.count', { n: items.length }))}</span></div><div class="session-pending-list">${items.map(({ kind, item }) => {
3443
+ if (kind === 'approval') return `<article class="pending-card approval" data-session-approval="${esc(item.approvalId)}">
3444
+ <div class="pc-title">${esc(item.toolName || t('tool.default'))}</div>
3445
+ <div class="pc-desc">${esc(approvalDetail(item))}</div>
3446
+ <div class="goal-actions"><button type="button" class="mini-btn" data-session-approve="1">${t('pending.allow')}</button><button type="button" class="mini-btn" data-session-approve="0">${t('pending.reject')}</button></div>
3447
+ </article>`
3448
+ return `<article class="pending-card question" data-session-question="${esc(item.rpcId)}">
3449
+ <div class="pc-title">❓ ${esc((item.questions || []).map(question => question.question).filter(Boolean).join(' / ') || t('notify.questionTitle'))}</div>
3450
+ <div class="goal-actions"><button type="button" class="mini-btn" data-session-answer>${t('pending.answer')}</button></div>
3451
+ </article>`
3452
+ }).join('')}</div>`
3453
+ box.querySelectorAll('[data-session-approve]').forEach(button => {
3454
+ button.addEventListener('click', () => approveApproval(button.closest('[data-session-approval]')?.dataset.sessionApproval || '', button.dataset.sessionApprove === '1'))
3455
+ })
3456
+ box.querySelectorAll('[data-session-answer]').forEach(button => {
3457
+ button.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === button.closest('[data-session-question]')?.dataset.sessionQuestion)))
3458
+ })
3459
+ }
3460
+
3421
3461
  async function approveApproval(id, allow) {
3422
3462
  const a = state.approvals.find(x => x.approvalId === id)
3423
3463
  if (!a) return
@@ -225,15 +225,15 @@ html.reorder-scroll-lock body { overscroll-behavior: none; }
225
225
  .ds-overview-attention-list, .ds-overview-session-list { display:flex; flex-direction:column; gap:8px; }
226
226
  .ds-overview-attention-item, .ds-overview-session-item { width:100%; min-width:0; display:flex; align-items:center; gap:10px; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:12px; background:var(--dsr-bg-2); color:var(--dsr-text); text-align:left; }
227
227
  button.ds-overview-attention-item, button.ds-overview-session-item { cursor:pointer; font:inherit; }
228
- .ds-overview-attention-item { border-left:3px solid var(--dsr-warning); }
228
+ .ds-overview-attention-item { align-items:flex-start; border-left:3px solid var(--dsr-warning); }
229
229
  .ds-overview-attention-item.question { border-left-color:var(--dsr-accent-2); }
230
230
  .ds-overview-mark { flex:0 0 auto; width:26px; height:26px; display:grid; place-items:center; border-radius:8px; background:var(--dsr-warning-soft); color:var(--dsr-warning); font-size:12px; }
231
231
  .ds-overview-attention-item.question .ds-overview-mark { background:var(--dsr-accent-2-soft); color:var(--dsr-accent-2); }
232
232
  .ds-overview-copy { flex:1; min-width:0; }
233
- .ds-overview-item-title, .ds-overview-item-desc { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
233
+ .ds-overview-item-title, .ds-overview-item-desc { display:block; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
234
234
  .ds-overview-item-title { font-size:12px; font-weight:650; }
235
- .ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; }
236
- .ds-overview-actions { flex:0 0 auto; display:flex; gap:4px; }
235
+ .ds-overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; white-space:normal; }
236
+ .ds-overview-actions { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:4px; }
237
237
  .ds-overview-actions .ds-btn { min-height:27px; padding:2px 8px; font-size:11px; }
238
238
  .ds-overview-actions .allow { background:var(--dsr-success-soft); border-color:var(--dsr-success-line); color:var(--dsr-success); }
239
239
  .ds-overview-actions .reject { background:var(--dsr-warning-soft); border-color:var(--dsr-warning-line); color:var(--dsr-warning); }
@@ -287,6 +287,11 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
287
287
  .ds-presets-guide { padding: 2px 8px 10px; font-size: 11px; line-height: 1.5; }
288
288
  .ds-session-cards { flex: none; max-height: 220px; overflow-y: auto; padding: 10px 22px 0; display: flex; flex-direction: column; gap: 8px; }
289
289
  .ds-session-cards:empty { display: none; }
290
+ .ds-session-pending { flex:none; margin:10px 22px 0; padding:10px; border:1px solid var(--dsr-warning-line); border-radius:13px; background:var(--dsr-warning-soft); }
291
+ .ds-session-pending-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; color:var(--dsr-text); font-size:12px; font-weight:750; }
292
+ .ds-session-pending-list { display:flex; flex-direction:column; gap:8px; }
293
+ .ds-session-pending .ds-notif-card { min-width:0; box-shadow:none; animation:none; }
294
+ .ds-session-pending .ds-notif-title, .ds-session-pending .ds-notif-body { overflow-wrap:anywhere; word-break:break-word; }
290
295
  .ds-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 12px; }
291
296
  .ds-card-title { font-size: 11px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; margin-bottom: 6px; text-transform: uppercase; }
292
297
  .ds-goal-disclosure { width: 100%; min-width: 0; display: flex; justify-content: flex-end; }
@@ -151,6 +151,7 @@
151
151
  <section id="view-chat" class="ds-view">
152
152
  <div id="session-cards" class="ds-session-cards"></div>
153
153
  <div id="queue-dock" class="ds-queue-dock hidden" aria-live="polite"></div>
154
+ <section id="session-pending" class="ds-session-pending hidden" aria-live="polite"></section>
154
155
  <div id="history" class="ds-history" aria-live="polite"></div>
155
156
  <div class="ds-composer">
156
157
  <div id="composer-status" class="ds-composer-status hidden" role="status" aria-live="polite"><span class="ds-composer-status-dot" aria-hidden="true"></span><span data-i18n="ds.composerRunning">运行中…</span></div>
@@ -1566,6 +1566,7 @@ async function openSession(id) {
1566
1566
  renderSessions()
1567
1567
  renderSessionCards()
1568
1568
  renderQueue()
1569
+ renderSessionPendingDesktop()
1569
1570
  updateComposerStatus()
1570
1571
  await loadHistory()
1571
1572
  }
@@ -1576,6 +1577,7 @@ function closeSession() {
1576
1577
  const cards = $('session-cards')
1577
1578
  if (cards) cards.innerHTML = ''
1578
1579
  renderQueue()
1580
+ renderSessionPendingDesktop()
1579
1581
  updateComposerStatus()
1580
1582
  updateSessionActions()
1581
1583
  showView('view-sessions')
@@ -2010,6 +2012,39 @@ function serverLabel() {
2010
2012
  const cur = currentServerEntry()
2011
2013
  return cur ? (cur.note || cur.url) : (state.server || location.host)
2012
2014
  }
2015
+ function desktopApprovalDetail(a) {
2016
+ const lines = []
2017
+ if (a?.reason) lines.push(String(a.reason))
2018
+ if (a?.arguments !== undefined) lines.push(safeJson(a.arguments))
2019
+ if (a?.callId) lines.push(`callId: ${a.callId}`)
2020
+ return lines.join('\n') || t('ds.approvalReason', { reason: '' })
2021
+ }
2022
+ function renderSessionPendingDesktop() {
2023
+ const box = $('session-pending')
2024
+ if (!box) return
2025
+ const sessionId = state.current
2026
+ const items = [
2027
+ ...state.approvals.filter(a => a.sessionId === sessionId).map(a => ({ kind: 'approval', item: a })),
2028
+ ...state.questions.filter(q => q.sessionId === sessionId).map(q => ({ kind: 'question', item: q }))
2029
+ ]
2030
+ box.classList.toggle('hidden', !sessionId || !items.length)
2031
+ if (!sessionId || !items.length) { box.innerHTML = ''; return }
2032
+ box.innerHTML = `<div class="ds-session-pending-head"><span>${esc(t('ds.attention'))}</span><span>${items.length}</span></div><div class="ds-session-pending-list">${items.map(({ kind, item }) => {
2033
+ if (kind === 'approval') return `<article class="ds-notif-card" data-session-approval="${esc(item.approvalId)}">
2034
+ <div class="ds-notif-head">🔐 ${t('ds.approvalTitle')}</div>
2035
+ <div class="ds-notif-title">${esc(item.toolName || t('ds.toolDefault'))}</div>
2036
+ <div class="ds-notif-body">${esc(desktopApprovalDetail(item))}</div>
2037
+ <div class="ds-notif-actions"><button type="button" class="ds-btn allow" data-session-approve="1">${t('ds.allow')}</button><button type="button" class="ds-btn reject" data-session-approve="0">${t('ds.reject')}</button></div>
2038
+ </article>`
2039
+ return `<article class="ds-notif-card question" data-session-question="${esc(item.rpcId)}">
2040
+ <div class="ds-notif-head">❓ ${t('ds.questionNotify')}</div>
2041
+ <div class="ds-notif-title">${esc((item.questions || []).map(question => question.question).filter(Boolean).join(' / '))}</div>
2042
+ <div class="ds-notif-actions"><button type="button" class="ds-btn" data-session-answer>${t('ds.submit')}</button></div>
2043
+ </article>`
2044
+ }).join('')}</div>`
2045
+ box.querySelectorAll('[data-session-approve]').forEach(button => button.addEventListener('click', () => approveApproval(button.closest('[data-session-approval]')?.dataset.sessionApproval || '', button.dataset.sessionApprove === '1')))
2046
+ box.querySelectorAll('[data-session-answer]').forEach(button => button.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === button.closest('[data-session-question]')?.dataset.sessionQuestion))))
2047
+ }
2013
2048
  function renderNotifStack() {
2014
2049
  const stack = $('notif-stack')
2015
2050
  const items = [
@@ -2019,7 +2054,7 @@ function renderNotifStack() {
2019
2054
  stack.innerHTML = items.map(it => {
2020
2055
  if (it.kind === 'approval') {
2021
2056
  const a = it.a
2022
- const reason = a.reason || a.arguments ? safeJson(a.arguments ?? a.reason ?? '') : ''
2057
+ const reason = desktopApprovalDetail(a)
2023
2058
  return `<div class="ds-notif-card" data-approval="${esc(a.approvalId)}" tabindex="0">
2024
2059
  <div class="ds-notif-head">🔐 ${t('ds.approvalTitle')} · ${esc(serverLabel())} · ${fmtTime(a.time || Date.now())}</div>
2025
2060
  <div class="ds-notif-title">${esc(a.toolName || t('ds.toolDefault'))}</div>
@@ -2050,6 +2085,7 @@ function renderNotifStack() {
2050
2085
  stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
2051
2086
  if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
2052
2087
  }))
2088
+ renderSessionPendingDesktop()
2053
2089
  renderOverviewDesktop()
2054
2090
  }
2055
2091
  async function approveApproval(id, allow) {
@@ -2708,7 +2744,7 @@ function renderOverviewDesktop() {
2708
2744
  $('ds-overview-attention-list').innerHTML = pending.length ? pending.slice(0, 4).map(({ kind, item }) => {
2709
2745
  const title = titleOf(state.byId.get(item.sessionId))
2710
2746
  if (kind === 'approval') return `<div class="ds-overview-attention-item" data-ds-overview-approval="${esc(item.approvalId)}">
2711
- <span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(item.reason || t('ds.approvalReason', { reason: '' }))} · ${esc(title)}</span></span>
2747
+ <span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(desktopApprovalDetail(item))} · ${esc(title)}</span></span>
2712
2748
  <span class="ds-overview-actions"><button class="ds-btn allow" data-ds-overview-approve="1">${t('ds.allow')}</button><button class="ds-btn reject" data-ds-overview-approve="0">${t('ds.reject')}</button></span>
2713
2749
  </div>`
2714
2750
  return `<button type="button" class="ds-overview-attention-item question" data-ds-overview-question="${esc(item.rpcId)}">
package/public/index.html CHANGED
@@ -104,6 +104,8 @@
104
104
 
105
105
  <div id="queue-dock" class="queue-dock hidden" aria-live="polite"></div>
106
106
 
107
+ <section id="session-pending" class="session-pending hidden" aria-live="polite"></section>
108
+
107
109
  <div class="section-head small">
108
110
  <span data-i18n="session.history">对话</span>
109
111
  <span id="history-hint" class="muted"></span>
package/public/styles.css CHANGED
@@ -424,6 +424,13 @@ body.in-session .view {
424
424
  .queue-dock-item:last-child { border-bottom: 0; }
425
425
  .queue-dock-preview { flex: 1; min-width: 0; color: var(--dsr-text); font-size: 13px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
426
426
  .queue-dock-action { flex: 0 0 auto; }
427
+ .session-pending { flex:0 0 auto; margin:0 0 10px; padding:10px; border:1px solid var(--dsr-warning-line); border-radius:13px; background:var(--dsr-warning-soft); }
428
+ .session-pending-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; color:var(--dsr-text); font-size:12px; font-weight:750; }
429
+ .session-pending-list { display:flex; flex-direction:column; gap:8px; }
430
+ .session-pending .pending-card { min-width:0; background:var(--dsr-panel); }
431
+ .session-pending .pc-title { line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
432
+ .session-pending .pc-desc { line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere; }
433
+ .session-pending .goal-actions { justify-content:flex-end; }
427
434
  .md-table-wrap { max-width: 100%; overflow-x: auto; margin: 8px 0; }
428
435
  .md-table-wrap table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .94em; }
429
436
  .md-table-wrap th, .md-table-wrap td { padding: 6px 9px; border: 1px solid var(--dsr-line); text-align: left; white-space: nowrap; }
@@ -711,16 +718,17 @@ body.in-session .main { padding-bottom: 0; }
711
718
  .overview-section-meta { color:var(--dsr-muted); font-size:11px; font-weight:500; }
712
719
  .overview-attention-list, .overview-session-list { display:flex; flex-direction:column; gap:7px; }
713
720
  .overview-attention-item, .overview-session-item { width:100%; display:flex; align-items:center; gap:10px; min-width:0; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:13px; background:var(--dsr-panel); color:var(--dsr-text); text-align:left; }
721
+ .overview-attention-item { align-items:flex-start; }
714
722
  button.overview-attention-item, button.overview-session-item { cursor:pointer; }
715
723
  .overview-attention-item { border-left:3px solid var(--dsr-warning); }
716
724
  .overview-attention-item.question { border-left-color:var(--dsr-accent-2); }
717
725
  .overview-item-mark { flex:0 0 auto; width:25px; height:25px; display:grid; place-items:center; border-radius:8px; background:var(--dsr-warning-soft); color:var(--dsr-warning); font-size:12px; }
718
726
  .overview-attention-item.question .overview-item-mark { background:var(--dsr-accent-2-soft); color:var(--dsr-accent-2); }
719
727
  .overview-item-copy { min-width:0; flex:1; }
720
- .overview-item-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12.5px; font-weight:650; }
721
- .overview-item-desc { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-top:2px; color:var(--dsr-muted); font-size:11px; }
728
+ .overview-item-title { font-size:12.5px; font-weight:650; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
729
+ .overview-item-desc { margin-top:3px; color:var(--dsr-muted); font-size:11px; line-height:1.45; white-space:normal; overflow-wrap:anywhere; }
722
730
  .overview-item-arrow { color:var(--dsr-muted); font-size:18px; }
723
- .overview-item-actions { flex:0 0 auto; display:flex; gap:4px; }
731
+ .overview-item-actions { flex:0 0 auto; display:flex; flex-wrap:wrap; justify-content:flex-end; gap:4px; }
724
732
  .overview-item-actions .mini-btn { padding:4px 7px; font-size:10px; }
725
733
  .overview-metric-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
726
734
  .overview-metric { min-width:0; padding:11px 12px; border:1px solid var(--dsr-line); border-radius:13px; background:var(--dsr-panel); }
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.19",
2
+ "version": "0.6.20",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "d79c732efa12a8f303a89fd9e707fdcd4c0e30a29515b222157ae48a6c07260b",
5
- "releasedAt": "2026-08-30T04:41:52.453Z",
6
- "notes": "0.6.19:全面优化手机端、桌面端、管理页和插件页的布局、动效与响应式体验,并支持减少动态效果偏好;重做蓝色、草原、音乐厅等主题的明暗层级和文字图标对比度,新增利落的黑白配色;统一主页图标配色并修正设置齿轮中心孔偏移;目标面板支持收起为始终可见的侧边入口,保留运行状态、可随时恢复,并按会话与目标在本地记忆。",
4
+ "sha256": "a5793085029414356a0a4faf411eb3c99bc934cdbf3ae387cc9c4c06738cf0c1",
5
+ "releasedAt": "2026-08-30T14:51:29.621Z",
6
+ "notes": "0.6.20:适配 DSH 0.1.2-alpha.1 的新版客户端模块、认证、斜杠 RPC 与 remote.mux 实时协议,同时保留旧版 DSH 的自动回退;修复斜杠命令因 commands.execute 参数变化触发 aborted 报错,以及新版历史、工作区、模型、目标和子代理等接口不兼容问题;审批与用户提问现在会在所属对话内完整显示并可直接处理,主页仍保留全局待办入口,长工具参数、原因和调用标识不再被截断。",
7
7
  "history": [
8
+ {
9
+ "version": "0.6.20",
10
+ "notes": "0.6.20:适配 DSH 0.1.2-alpha.1 的新版客户端模块、认证、斜杠 RPC 与 remote.mux 实时协议,同时保留旧版 DSH 的自动回退;修复斜杠命令因 commands.execute 参数变化触发 aborted 报错,以及新版历史、工作区、模型、目标和子代理等接口不兼容问题;审批与用户提问现在会在所属对话内完整显示并可直接处理,主页仍保留全局待办入口,长工具参数、原因和调用标识不再被截断。"
11
+ },
8
12
  {
9
13
  "version": "0.6.19",
10
14
  "notes": "0.6.19:全面优化手机端、桌面端、管理页和插件页的布局、动效与响应式体验,并支持减少动态效果偏好;重做蓝色、草原、音乐厅等主题的明暗层级和文字图标对比度,新增利落的黑白配色;统一主页图标配色并修正设置齿轮中心孔偏移;目标面板支持收起为始终可见的侧边入口,保留运行状态、可随时恢复,并按会话与目标在本地记忆。"
@@ -40,10 +44,6 @@
40
44
  {
41
45
  "version": "0.6.11",
42
46
  "notes": "0.6.11 正式版:新增按 DSH 工作区筛选、切换和创建会话,文件页支持切换工作区并直接预览常见文本与 Markdown;修复工作区筛选未正确收敛会话、工作区位于默认文件根外时无法查看,以及新会话工作区名称与完整路径显示异常;新增投票公告及隐私化汇总;统一手机端应用内选择抽屉;周末全天按谷时并修复重复峰谷提醒;插件侧栏图标采用亮色背景并随插件自身四套皮肤切换配色;加强网关安装、连接与故障排查引导。"
43
- },
44
- {
45
- "version": "0.6.10",
46
- "notes": "0.6.10 正式版:修复 DSH/网关重启后事件通道不自动恢复、启动阶段网卡读取异常、同源状态误报、双通道总览卡在 3/4、图片后实时回复不可见及子代理重复;新增完整用户链路与重启生命周期测试;公网反馈切换到安全的 HTTPS 临时入口并加强来源 IP 防伪造、节流与隐私保护。"
47
47
  }
48
48
  ]
49
49
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.19"
2
+ "version": "0.6.20"
3
3
  }