dsh-remote 0.8.16 → 0.8.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/README.zh.md +4 -2
- package/lib/client.js +187 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](https://github.com/topics/dsh-plugin)
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+
|
|
11
13
|
**Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).**
|
|
12
14
|
|
|
13
15
|
Manage several SSH machines, then pick a **remote workspace** (or a **local** one) and let the agent operate right there without leaving the harness — listing files, reading code, running builds & commands over the remote host, and keeping that remote directory mirrored into a real local workspace object.
|
|
@@ -18,7 +20,7 @@ The harness Web UI intentionally binds `127.0.0.1` (the CLI rejects `--host 0.0.
|
|
|
18
20
|
|
|
19
21
|
Settings → **远程工作区** — a multi-machine SSH registry (add / edit / delete / set-current, password stored locally):
|
|
20
22
|
|
|
21
|
-
<img src="
|
|
23
|
+
<img src="docs/ui-settings-panel.png" alt="dsh-remote settings — multi-machine registry (light theme, host scrubbed)" width="720"/>
|
|
22
24
|
|
|
23
25
|
The native **"Add workspace" / "Select workspace"** flow — a centered modal, two tabs, opens on **本机 (local)**; switch to **远程 (remote)**:
|
|
24
26
|
|
|
@@ -26,7 +28,7 @@ The native **"Add workspace" / "Select workspace"** flow — a centered modal, t
|
|
|
26
28
|
|
|
27
29
|
Real capture (host scrubbed to a placeholder):
|
|
28
30
|
|
|
29
|
-
<img src="
|
|
31
|
+
<img src="docs/ui-picker-panel.png" alt="dsh-remote workspace picker — real dialog; 本机 (local) tab; 远程 machine select + prefilled root path + autocomplete" width="720"/>
|
|
30
32
|
|
|
31
33
|
---
|
|
32
34
|
|
package/README.zh.md
CHANGED
|
@@ -28,6 +28,8 @@ Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构
|
|
|
28
28
|
[](LICENSE)
|
|
29
29
|
[](https://github.com/topics/dsh-plugin)
|
|
30
30
|
|
|
31
|
+

|
|
32
|
+
|
|
31
33
|
**为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)打造的远程工作助手。**
|
|
32
34
|
|
|
33
35
|
维护多台 SSH 机器,然后在「选择工作区」时选一个**远程工作区**(或**本地工作区**),Agent 就能在不离开 harness 的情况下直接操作——列文件、读代码、在远程主机上跑构建/命令,并把远程目录镜像成一个真实的本地工作区对象。
|
|
@@ -38,7 +40,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
38
40
|
|
|
39
41
|
设置 → **远程工作区** —— 多机 SSH 列表(增/删/改/设为当前,密码本地保存、不回显):
|
|
40
42
|
|
|
41
|
-
<img src="
|
|
43
|
+
<img src="docs/ui-settings-panel.png" alt="dsh-remote 设置页 — 多机列表(浅色主题,主机已打码)" width="720"/>
|
|
42
44
|
|
|
43
45
|
原生 **「Add workspace / 选择工作区」** 流程 —— **居中弹窗**、两个 tab,**默认落在「本机」**;切到 **「远程」**:
|
|
44
46
|
|
|
@@ -46,7 +48,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
46
48
|
|
|
47
49
|
真实截图(机器已打码为占位):
|
|
48
50
|
|
|
49
|
-
<img src="
|
|
51
|
+
<img src="docs/ui-picker-panel.png" alt="dsh-remote 工作区选择 — 真实弹窗;默认本机 tab;远程:机器下拉 + 预填根路径 + 自动补全" width="720"/>
|
|
50
52
|
|
|
51
53
|
---
|
|
52
54
|
|
package/lib/client.js
CHANGED
|
@@ -1491,11 +1491,177 @@ window.__ModuleLoader__.load({
|
|
|
1491
1491
|
React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }),
|
|
1492
1492
|
)
|
|
1493
1493
|
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
|
|
1498
|
-
|
|
1494
|
+
// Issue #32: the remote tree's expansion list travels on the TAB (`meta`),
|
|
1495
|
+
// never on better-sidebar's session-shared `expanded` array. Best-effort:
|
|
1496
|
+
// an older sidebar without tab meta simply forgets the expansion on reload.
|
|
1497
|
+
const TAB_EXPANDED_META = 'remoteExpanded'
|
|
1498
|
+
/** The tab's persisted expansion list, or null when the tab records none
|
|
1499
|
+
* (so a caller can tell "never expanded" from "expanded then collapsed
|
|
1500
|
+
* everything"). */
|
|
1501
|
+
function readTabExpanded(tab) {
|
|
1502
|
+
try {
|
|
1503
|
+
const meta = tab && tab.meta
|
|
1504
|
+
if (!meta || typeof meta !== 'object' || !(TAB_EXPANDED_META in meta)) return null
|
|
1505
|
+
const list = meta[TAB_EXPANDED_META]
|
|
1506
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === 'string') : []
|
|
1507
|
+
} catch { return null }
|
|
1508
|
+
}
|
|
1509
|
+
/** Fallback store when the sidebar predates tab meta (no persistence then,
|
|
1510
|
+
* but the tree still opens and closes within the page's lifetime). */
|
|
1511
|
+
const tabExpandedFallback = new Map()
|
|
1512
|
+
function tabExpandedOf(tab) {
|
|
1513
|
+
const fromMeta = readTabExpanded(tab)
|
|
1514
|
+
if (fromMeta !== null) return fromMeta
|
|
1515
|
+
const id = tab && tab.id
|
|
1516
|
+
return (id && tabExpandedFallback.get(id)) || []
|
|
1517
|
+
}
|
|
1518
|
+
function toggleTabExpanded(ctx, tab, path) {
|
|
1519
|
+
const cur = tabExpandedOf(tab)
|
|
1520
|
+
const next = cur.includes(path) ? cur.filter((p) => p !== path) : [...cur, path]
|
|
1521
|
+
const id = tab && tab.id
|
|
1522
|
+
if (id) tabExpandedFallback.set(id, next)
|
|
1523
|
+
try {
|
|
1524
|
+
if (!ctx || !tab || !id || typeof ctx.get !== 'function') return next
|
|
1525
|
+
const bs = ctx.get('betterSidebar')
|
|
1526
|
+
if (!bs || typeof bs.updateTab !== 'function') return next
|
|
1527
|
+
const meta = tab.meta && typeof tab.meta === 'object' ? tab.meta : {}
|
|
1528
|
+
bs.updateTab(id, { meta: { ...meta, [TAB_EXPANDED_META]: next } })
|
|
1529
|
+
} catch { /* expansion memory is best-effort */ }
|
|
1530
|
+
return next
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Issue #32 healing, kept pure so it is directly testable: an older
|
|
1534
|
+
// dsh-remote pushed REMOTE paths into better-sidebar's session-shared
|
|
1535
|
+
// `expanded` set through onToggleDir. That set is PERSISTED (localStorage
|
|
1536
|
+
// `dsh-sidebar:v1:<sessionId>`) and the built-in local Files tree loads
|
|
1537
|
+
// every entry through a LOCAL fs.realpath, so those remote paths kept
|
|
1538
|
+
// failing with ENOENT until the set was cleaned.
|
|
1539
|
+
//
|
|
1540
|
+
// Return exactly the entries this tree owns: the current remote root and
|
|
1541
|
+
// anything under it. That range is remote-only by construction (the root
|
|
1542
|
+
// comes from resolve-mirror), so a legitimate local directory cannot
|
|
1543
|
+
// match. A LOCAL MIRROR path is never evicted — on a host whose own home is
|
|
1544
|
+
// an ancestor of the remote root (a macOS host driving a macOS remote) the
|
|
1545
|
+
// mirror under $DSH_HOME/remote-workspaces would otherwise fall inside it.
|
|
1546
|
+
//
|
|
1547
|
+
// On a WINDOWS host the sweep is broader and complete: a POSIX-absolute
|
|
1548
|
+
// entry (`/home/...`) can never be a valid local path there (Windows paths
|
|
1549
|
+
// are drive- or UNC-rooted), which is exactly why the bug only bit Windows.
|
|
1550
|
+
// That also cleans pollution left by an earlier remote root, which no
|
|
1551
|
+
// per-root rule could find.
|
|
1552
|
+
function isPosixAbsolutePath(p) {
|
|
1553
|
+
return typeof p === 'string' && p.startsWith('/') && !p.startsWith('//')
|
|
1554
|
+
}
|
|
1555
|
+
function windowsHost() {
|
|
1556
|
+
try {
|
|
1557
|
+
const nav = typeof navigator !== 'undefined' ? navigator : null
|
|
1558
|
+
if (!nav) return false
|
|
1559
|
+
if (nav.userAgentData && typeof nav.userAgentData.platform === 'string') {
|
|
1560
|
+
return /win/i.test(nav.userAgentData.platform)
|
|
1561
|
+
}
|
|
1562
|
+
if (typeof nav.platform === 'string' && nav.platform) return /win/i.test(nav.platform)
|
|
1563
|
+
return typeof nav.userAgent === 'string' && /windows/i.test(nav.userAgent)
|
|
1564
|
+
} catch { return false }
|
|
1565
|
+
}
|
|
1566
|
+
function strayRemoteSharedPaths(root, shared, hostIsWindows = windowsHost()) {
|
|
1567
|
+
if (!Array.isArray(shared) || !shared.length) return []
|
|
1568
|
+
let prefix = ''
|
|
1569
|
+
if (typeof root === 'string' && root !== '' && root !== '/') prefix = root.replace(/[\\/]+$/, '')
|
|
1570
|
+
const isMirror = (p) => p.includes('/remote-workspaces/') || p.includes('\\remote-workspaces\\')
|
|
1571
|
+
return shared.filter((p) => {
|
|
1572
|
+
if (typeof p !== 'string' || p === '' || isMirror(p)) return false
|
|
1573
|
+
if (prefix !== '' && (p === prefix || p === root ||
|
|
1574
|
+
p.startsWith(prefix + '/') || p.startsWith(prefix + '\\'))) return true
|
|
1575
|
+
return hostIsWindows && isPosixAbsolutePath(p)
|
|
1576
|
+
})
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Issue #32: the remote tree must NOT use better-sidebar's `expanded` /
|
|
1580
|
+
// `onToggleDir` props. That pair is the SESSION-shared expansion set, which
|
|
1581
|
+
// the built-in local Files tree also reads and loads as local directories
|
|
1582
|
+
// through /sidebar/api fs.tree. Feeding it remote paths (/home/...) made the
|
|
1583
|
+
// core tree run a LOCAL fs.realpath on them; on win32 a POSIX path counts as
|
|
1584
|
+
// absolute (drive-rooted), so it resolved to `D:\home\...` and failed with
|
|
1585
|
+
// ENOENT. Own the expansion here and persist it on the tab instead.
|
|
1586
|
+
//
|
|
1587
|
+
// Because that shared set is PERSISTED, an already-affected user stays
|
|
1588
|
+
// broken across reloads, so the stray entries are also migrated eagerly (see
|
|
1589
|
+
// migratePollutedSidebarState) and healed again from the tab (below).
|
|
1590
|
+
const SIDEBAR_STATE_PREFIX = 'dsh-sidebar:v1:'
|
|
1591
|
+
/**
|
|
1592
|
+
* Strip remote paths a pre-fix dsh-remote left in PERSISTED sidebar state,
|
|
1593
|
+
* before the built-in Files tree loads them. Only entries that cannot be a
|
|
1594
|
+
* valid local path on this host are removed (see strayRemoteSharedPaths).
|
|
1595
|
+
* Best-effort: any storage failure just leaves the tab-level healing to do
|
|
1596
|
+
* the job on the next load.
|
|
1597
|
+
*
|
|
1598
|
+
* @param storage - a Storage-like object (window.localStorage).
|
|
1599
|
+
* @param hostIsWindows - overrides host detection (tests).
|
|
1600
|
+
* @returns how many stray entries were removed.
|
|
1601
|
+
*/
|
|
1602
|
+
function migratePollutedSidebarState(storage, hostIsWindows = windowsHost()) {
|
|
1603
|
+
if (!storage || typeof storage.length !== 'number') return 0
|
|
1604
|
+
let cleaned = 0
|
|
1605
|
+
try {
|
|
1606
|
+
const keys = []
|
|
1607
|
+
for (let i = 0; i < storage.length; i++) {
|
|
1608
|
+
const key = storage.key(i)
|
|
1609
|
+
if (key && key.startsWith(SIDEBAR_STATE_PREFIX)) keys.push(key)
|
|
1610
|
+
}
|
|
1611
|
+
for (const key of keys) {
|
|
1612
|
+
const raw = storage.getItem(key)
|
|
1613
|
+
if (typeof raw !== 'string' || raw === '') continue
|
|
1614
|
+
let parsed
|
|
1615
|
+
try { parsed = JSON.parse(raw) } catch { continue }
|
|
1616
|
+
if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.expanded)) continue
|
|
1617
|
+
const stray = strayRemoteSharedPaths('', parsed.expanded, hostIsWindows)
|
|
1618
|
+
if (!stray.length) continue
|
|
1619
|
+
const drop = new Set(stray)
|
|
1620
|
+
parsed.expanded = parsed.expanded.filter((p) => !drop.has(p))
|
|
1621
|
+
storage.setItem(key, JSON.stringify(parsed))
|
|
1622
|
+
cleaned += stray.length
|
|
1623
|
+
}
|
|
1624
|
+
} catch { /* storage unavailable: the tab-level healing still runs */ }
|
|
1625
|
+
return cleaned
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// `evictStray` heals state an older dsh-remote already polluted: the tree
|
|
1629
|
+
// calls it once it knows its remote root, and it drops exactly those paths
|
|
1630
|
+
// from the shared set. It is a plain function (not an effect) so the rule is
|
|
1631
|
+
// directly testable.
|
|
1632
|
+
function RemoteExplorerTabOwned(props) {
|
|
1633
|
+
const [expanded, setExpanded] = React.useState(tabExpandedOf(props.tab))
|
|
1634
|
+
const sharedExpanded = Array.isArray(props.expanded) ? props.expanded : []
|
|
1635
|
+
const evictShared = typeof props.onToggleDir === 'function' ? props.onToggleDir : null
|
|
1636
|
+
// Heal without needing the remote root: on a Windows host a POSIX-absolute
|
|
1637
|
+
// entry can never be a valid local path, so the sweep is complete there.
|
|
1638
|
+
// (The root-scoped sweep for the session's own remote tree runs inside the
|
|
1639
|
+
// tab, which is where the resolved root becomes known.)
|
|
1640
|
+
const sharedKey = sharedExpanded.join('\u0000')
|
|
1641
|
+
React.useEffect(() => {
|
|
1642
|
+
if (!evictShared) return
|
|
1643
|
+
const stray = strayRemoteSharedPaths('', sharedKey ? sharedKey.split('\u0000') : [])
|
|
1644
|
+
for (const p of stray) evictShared(p)
|
|
1645
|
+
}, [sharedKey, evictShared])
|
|
1646
|
+
// Explicitly drop the framework's session-shared pair from the child.
|
|
1647
|
+
const owned = { ...props }
|
|
1648
|
+
delete owned.expanded
|
|
1649
|
+
delete owned.onToggleDir
|
|
1650
|
+
return React.createElement(RemoteExplorerTab, {
|
|
1651
|
+
...owned,
|
|
1652
|
+
evictStray: (root) => {
|
|
1653
|
+
if (!evictShared || !sharedExpanded.length) return []
|
|
1654
|
+
const stray = strayRemoteSharedPaths(root, sharedExpanded)
|
|
1655
|
+
for (const p of stray) evictShared(p)
|
|
1656
|
+
return stray
|
|
1657
|
+
},
|
|
1658
|
+
expanded,
|
|
1659
|
+
onToggleDir: (path) => setExpanded(toggleTabExpanded(props.ctx, props.tab, path)),
|
|
1660
|
+
})
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Tree-style remote file explorer. Data is listed live from the remote host
|
|
1664
|
+
// over /dsh-remote/ls, rooted at this session's remote workspace dir.
|
|
1499
1665
|
function RemoteExplorerTab(props) {
|
|
1500
1666
|
const [status, setStatus] = React.useState(null)
|
|
1501
1667
|
const [root, setRoot] = React.useState('') // remote root path for this session
|
|
@@ -1627,6 +1793,13 @@ window.__ModuleLoader__.load({
|
|
|
1627
1793
|
React.useEffect(() => { if (props.visible) refreshStatus({ visible: true }) }, [props.visible])
|
|
1628
1794
|
// Load dirs as they get expanded (keep already-loaded content).
|
|
1629
1795
|
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir, { keep: true }) }, [root, expanded, loadDir])
|
|
1796
|
+
// Issue #32 healing: once the remote root is known, drop the paths an
|
|
1797
|
+
// older dsh-remote left in better-sidebar's PERSISTED session-shared
|
|
1798
|
+
// expansion set (the built-in local tree was loading them through a local
|
|
1799
|
+
// fs.realpath and failing). See strayRemoteSharedPaths().
|
|
1800
|
+
React.useEffect(() => {
|
|
1801
|
+
if (root && typeof props.evictStray === 'function') props.evictStray(root)
|
|
1802
|
+
}, [root, props.evictStray])
|
|
1630
1803
|
// ── Context-menu portal: append to document.body so the sidebar's
|
|
1631
1804
|
// overflow/transform cannot clip it. Pure DOM, no React portal needed.
|
|
1632
1805
|
React.useEffect(() => {
|
|
@@ -1917,6 +2090,13 @@ window.__ModuleLoader__.load({
|
|
|
1917
2090
|
function registerSidebarIntegration(ctx) {
|
|
1918
2091
|
const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
|
|
1919
2092
|
if (!bs || typeof bs.registerTab !== 'function') return
|
|
2093
|
+
// Issue #32 migration: drop the remote paths an older dsh-remote left in
|
|
2094
|
+
// PERSISTED sidebar state, BEFORE the built-in Files tree loads them
|
|
2095
|
+
// through its local fs guard. Runs once per activation; a failure is
|
|
2096
|
+
// harmless because the tab-level healing repeats it.
|
|
2097
|
+
try {
|
|
2098
|
+
migratePollutedSidebarState(typeof window !== 'undefined' ? window.localStorage : null)
|
|
2099
|
+
} catch { /* best-effort migration */ }
|
|
1920
2100
|
const disposers = []
|
|
1921
2101
|
try {
|
|
1922
2102
|
disposers.push(bs.registerTab({
|
|
@@ -1925,7 +2105,8 @@ window.__ModuleLoader__.load({
|
|
|
1925
2105
|
icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
|
|
1926
2106
|
order: 55,
|
|
1927
2107
|
single: true,
|
|
1928
|
-
|
|
2108
|
+
// Issue #32: own the expansion state (see RemoteExplorerTabOwned).
|
|
2109
|
+
component: RemoteExplorerTabOwned,
|
|
1929
2110
|
}))
|
|
1930
2111
|
disposers.push(bs.registerTab({
|
|
1931
2112
|
id: SIDEBAR_FILE_ID,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.17",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 21 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|