dsh-remote 0.8.16 → 0.8.18

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 CHANGED
@@ -8,6 +8,8 @@
8
8
  [![license](https://img.shields.io/github/license/flymysql/dsh-remote)](LICENSE)
9
9
  [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7a3ef3)](https://github.com/topics/dsh-plugin)
10
10
 
11
+ ![dsh-remote — make any SSH machine a real DSH workspace](docs/cover.png)
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="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-settings-panel.png" alt="dsh-remote settings — multi-machine registry (light theme, host scrubbed)" width="720"/>
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="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-picker-panel.png" alt="dsh-remote workspace picker — real dialog; 本机 (local) tab; 远程 machine select + prefilled root path + autocomplete" width="720"/>
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
 
@@ -72,10 +74,8 @@ core or require a listening Web server:
72
74
  `sidebar.right.pane.tab` seat. It reuses the existing explorer/editor and
73
75
  gives remote files their own session-scoped resource addresses, rather than
74
76
  sending remote paths to the local Files viewer.
75
- - When the core Web-server row is explicitly disabled (the official Desktop
76
- composition), the bundled `dsh-better-sidebar` row stays disabled. Web hosts
77
- retain the existing `/dsh-remote/*` routes and sidebar composition, including
78
- the standalone-sidebar deduplication guard.
77
+ - `dsh-better-sidebar` is not bundled. Web hosts may install it separately;
78
+ official Desktop uses the native right-sidebar integration instead.
79
79
 
80
80
  Validation so far covers Host startup, Desktop IPC JSON requests, read-only
81
81
  SSH connection/list/read, and opening the settings/import UI. Native file-tab
@@ -97,48 +97,29 @@ build allowlist or automatically approve dependency scripts.
97
97
  dsh plugin add dsh-remote # add the bundle
98
98
  ```
99
99
 
100
- One command installs everything: since **v0.7.2** the sidebar
101
- ([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar)) is a
102
- hard dependency and is mounted automatically — the 🌐 remote-file explorer and
103
- remote file viewer show up in the sidebar with no extra step. If you already
104
- have the sidebar installed on its own, the embedded copy backs off (no double
105
- mount) — regardless of whether the standalone bundle is listed **before or
106
- after** `dsh-remote` in `dsh.profile.bundles` (order-independent guard since
107
- 0.8.7; earlier versions crashed boot with `duplicate prefix route
108
- "/sidebar/api"` when the standalone bundle came after `dsh-remote`).
109
-
110
- > **Upgrading from ≤0.8.6 with a standalone sidebar?** You may keep the
111
- > standalone `dsh-better-sidebar` bundle (any order) — 0.8.7+ no longer
112
- > crashes. Or remove it from `bundles` and let dsh-remote mount the embedded
113
- > copy (version ^0.18.1 since 0.8.15 — earlier releases pinned 0.14.x, whose
114
- > `import { settingsNamespace } from "@deepseek-ai/dsh-settings"` broke once
115
- > dsh-settings 0.1.2-alpha.2 made that symbol private; issue #29).
116
-
117
- > **Harness requirement of the embedded sidebar (0.8.15+): `dsh ≥ 0.1.2-rc.1`.**
118
- > `dsh-better-sidebar` 0.18.x imports `SessionLogOffset` from
119
- > `@deepseek-ai/dsh-session`, which only exists from 0.1.2-rc.1 on. On an older
120
- > harness (0.1.0-rc.x) that import fails and the loader aborts the whole plugin
121
- > tree, so dsh does not start at all. Verified: 0.8.14 + sidebar 0.14.0 boots on
122
- > 0.1.0-rc.8, while 0.8.15 + sidebar 0.18.1 does not; both boot on 0.1.2-rc.1.
123
- > On an older harness either stay on **0.8.14**, or keep the host half by
124
- > disabling the embedded sidebar row in your profile's `cordis.patch.yml`
125
- > (verified working on 0.1.0-rc.8 — the `rw_*` tools keep working, the sidebar
126
- > UI is what you give up):
127
- >
128
- > ```yaml
129
- > - id: dsh-remote-sidebar
130
- > disabled: true
131
- > ```
132
- >
133
- > (and do not list a standalone `dsh-better-sidebar` bundle either).
134
-
135
- > **Requires the profile's pnpm linker to be `hoisted`** (the DSH profile
136
- > default, `nodeLinker: hoisted` in `pnpm-workspace.yaml`). The loader resolves
137
- > plugin packages from the profile root, so the sidebar must be reachable in
138
- > the top-level `node_modules`. If your `pnpm-workspace.yaml` was rewritten
139
- > without `nodeLinker: hoisted`, add it back (`nodeLinker: hoisted`) and run
140
- > `pnpm install` once — otherwise the embedded sidebar row fails with
141
- > `Cannot find package 'dsh-better-sidebar'`.
100
+ Since **v0.8.18**, `dsh-remote` installs and mounts only itself. The Web sidebar
101
+ ([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar)) is
102
+ optional and is no longer a dependency or an automatically mounted row. This
103
+ keeps the SSH tools and settings UI independent from a particular sidebar
104
+ implementation.
105
+
106
+ To add the optional Web remote-file explorer/editor, install both bundles:
107
+
108
+ ```bash
109
+ dsh plugin add dsh-remote
110
+ dsh plugin add dsh-better-sidebar
111
+ ```
112
+
113
+ When the standalone sidebar service is present, `dsh-remote` discovers it
114
+ dynamically and registers its remote explorer/editor tabs. Without it, all
115
+ `rw_*` tools, the settings UI, sync, audit log, and port forwarding continue to
116
+ work. Official Desktop uses its native right-sidebar seats and does not need
117
+ `dsh-better-sidebar`.
118
+
119
+ > **Upgrading from 0.7.2–0.8.17:** upgrading to 0.8.18 removes the embedded
120
+ > sidebar dependency and mount. Install `dsh-better-sidebar` separately only if
121
+ > you still want that Web UI. Any old profile override for
122
+ > `id: dsh-remote-sidebar` can be removed because that row no longer exists.
142
123
 
143
124
  (or `npm install dsh-remote` + add `- id: dsh-remote / name: dsh-remote` in `cordis.patch.yml`).
144
125
 
package/README.zh.md CHANGED
@@ -13,8 +13,8 @@
13
13
  `dsh-app:` 通道承载请求,鉴权仍由宿主负责,不启动 Web Server。
14
14
  - 通过 `sidebarRightTabs` 和 `sidebar.right.pane.tab` 提供原生“远程文件”入口,
15
15
  复用原来的文件树与编辑器,不把远端路径传给本地文件预览器。
16
- - 官方 Desktop 显式禁用 Web Server 时,不挂载内置 `dsh-better-sidebar`;
17
- Web 版仍保留原路由、侧栏及独立安装时的去重逻辑。
16
+ - `dsh-better-sidebar` 不再内置;Web 版可以单独安装,官方 Desktop 则使用
17
+ 原生右侧栏集成。
18
18
 
19
19
  已验证 Host 启动、IPC 请求、真实 SSH 的只读连接/目录列表/文本读取,以及设置页和
20
20
  测试 SSH 配置的导入。文件标签的完整 UI 操作、编辑/同步、多机器并行会话,以及旧 Web
@@ -28,6 +28,8 @@ Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构
28
28
  [![license](https://img.shields.io/github/license/flymysql/dsh-remote)](LICENSE)
29
29
  [![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-7a)](https://github.com/topics/dsh-plugin)
30
30
 
31
+ ![dsh-remote —— 把任意 SSH 机器变成真正的 DSH 工作区](docs/cover.png)
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="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-settings-panel.png" alt="dsh-remote 设置页 — 多机列表(浅色主题,主机已打码)" width="720"/>
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="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-picker-panel.png" alt="dsh-remote 工作区选择 — 真实弹窗;默认本机 tab;远程:机器下拉 + 预填根路径 + 自动补全" width="720"/>
51
+ <img src="docs/ui-picker-panel.png" alt="dsh-remote 工作区选择 — 真实弹窗;默认本机 tab;远程:机器下拉 + 预填根路径 + 自动补全" width="720"/>
50
52
 
51
53
  ---
52
54
 
@@ -74,38 +76,25 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
74
76
  dsh plugin add dsh-remote # 添加 bundle
75
77
  ```
76
78
 
77
- 一条命令装齐:从 **v0.7.2** 起,侧边栏
78
- ([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar))是
79
- **硬依赖并自动挂载** —— 装完 dsh-remote 后,侧边栏里的「🌐 远程文件」目录树和
80
- 远程文件查看器即可直接用,无需额外步骤。如果你已单独安装过该侧边栏,内嵌副本
81
- 会自动退避(不会重复挂载)。
82
-
83
- > **内嵌侧边栏版本**:0.8.15 起依赖范围为 `^0.18.1`。0.8.14 及更早锁在 `^0.14.0`,
84
- > 而 0.14.0~0.17.1 仍然 `import { settingsNamespace } from "@deepseek-ai/dsh-settings"`,
85
- > 该导出自 dsh-settings 0.1.2-alpha.2 起被移除,静态导入失败会让整个插件树加载失败
86
- > (issue #29)。
87
-
88
- > **⚠ 内嵌侧边栏对 harness 的要求(0.8.15+):`dsh ≥ 0.1.2-rc.1`**。
89
- > `dsh-better-sidebar` 0.18.x 会 `import { SessionLogOffset } from "@deepseek-ai/dsh-session"`,
90
- > 该导出从 0.1.2-rc.1 才有。在更老的 harness(0.1.0-rc.x)上这个静态导入会失败,
91
- > loader 因此判定整个插件树加载失败 —— **dsh 直接起不来**。实测:`0.8.14 + 侧边栏 0.14.0`
92
- > 在 0.1.0-rc.8 上可以正常启动,`0.8.15 + 侧边栏 0.18.1` 不行;两者在 0.1.2-rc.1 上都正常。
93
- > 老 harness 上要么**留在 0.8.14**,要么在 profile 的 `cordis.patch.yml` 里关掉内嵌侧边栏行
94
- > 以保住 host 半(已在 0.1.0-rc.8 实测可启动,`rw_*` 工具照常可用,放弃的只是侧边栏 UI):
95
- >
96
- > ```yaml
97
- > - id: dsh-remote-sidebar
98
- > disabled: true
99
- > ```
100
- >
101
- > (同时不要再单独列出 `dsh-better-sidebar` bundle。)
102
-
103
- > **要求 profile 的 pnpm linker 为 `hoisted`**(DSH profile 默认,
104
- > `pnpm-workspace.yaml` 里 `nodeLinker: hoisted`)。loader 从 profile 根解析
105
- > 插件包,侧边栏必须能在顶层 `node_modules` 被解析到。如果你的
106
- > `pnpm-workspace.yaml` 被重写丢掉了 `nodeLinker: hoisted`,请补回并执行一次
107
- > `pnpm install` —— 否则内嵌侧边栏行会报
108
- > `Cannot find package 'dsh-better-sidebar'`。
79
+ 从 **v0.8.18** 起,`dsh-remote` 只安装并挂载自身。Web 侧边栏
80
+ ([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar))
81
+ 改为可选,不再是依赖,也不会被自动挂载。这样 SSH 工具和设置页不再被某个侧边栏
82
+ 实现的版本/API 变化拖垮。
83
+
84
+ 如需 Web 版远程文件浏览/编辑,请显式安装两个 bundle:
85
+
86
+ ```bash
87
+ dsh plugin add dsh-remote
88
+ dsh plugin add dsh-better-sidebar
89
+ ```
90
+
91
+ 独立侧边栏 service 存在时,`dsh-remote` 会动态发现它并注册远程文件 tab;
92
+ 不安装时,`rw_*` 工具、设置页、同步、审计日志和端口转发均照常工作。
93
+ 官方 Desktop 使用原生右侧栏,不需要安装 `dsh-better-sidebar`。
94
+
95
+ > **从 0.7.2–0.8.17 升级:** 升到 0.8.18 后,内嵌侧边栏依赖和挂载会消失。
96
+ > 只有仍需要 Web 侧边栏 UI 时才单独安装 `dsh-better-sidebar`。旧 profile 里针对
97
+ > `id: dsh-remote-sidebar` 的覆盖可以删除,因为这行已不存在。
109
98
 
110
99
  (或 `npm install dsh-remote`,再在 `cordis.patch.yml` 加 `- id: dsh-remote / name: dsh-remote`。)
111
100
 
package/cordis.patch.yml CHANGED
@@ -1,59 +1,9 @@
1
- # dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
2
- # Applied when a profile lists this bundle; rows reference the package by name so
3
- # Node resolution finds the installed code.
1
+ # dsh-remote bundle patch: inserts only this plugin into a profile's layer stack.
4
2
  #
5
- # Since v0.7.2 dsh-remote ships dsh-better-sidebar as a hard dependency and
6
- # mounts it here, so `dsh plugin add dsh-remote` brings up the sidebar with
7
- # zero extra steps on Web. Official Desktop explicitly disables the Web server
8
- # row and uses the native right-sidebar seats instead. The sidebar
9
- # row uses a DEDICATED id (`dsh-remote-sidebar`)
10
- # and a guard so it backs off when another row mounts `dsh-better-sidebar`
11
- # (e.g. the user installed the sidebar bundle on its own, or an aggregate
12
- # bundle owns it) — two enabled mounts would both register /sidebar/api and
13
- # fail the whole plugin tree at boot.
14
- #
15
- # Order-independence (issue #12): the guard must NOT depend on
16
- # `ctx.loader.entries()` creation order. The loader creates entries in bundle
17
- # order and each row's disabled expression is evaluated at creation time, when
18
- # only rows created BEFORE it are visible — so with
19
- # `bundles: [..., "dsh-remote", "dsh-better-sidebar", ...]` (standalone sidebar
20
- # installed after dsh-remote, the normal `dsh plugin add` append order) both
21
- # rows saw no other sidebar mount and BOTH started, crashing boot with
22
- # `duplicate prefix route "/sidebar/api"`.
23
- #
24
- # Instead we scan the COMPOSED PATCH STACK — the include entry's
25
- # `config.patches` (every bundle layer in `dsh.profile.bundles` order, then the
26
- # profile/home/overlay layers) — for any OTHER row that mounts
27
- # `dsh-better-sidebar`. That data is fully materialized before any row guard
28
- # evaluates, so the outcome is identical no matter where the standalone bundle
29
- # sits in the bundle list. It is also pure data: it never reads another row's
30
- # `disabled` (reading another row's disabled expression would recursively
31
- # re-enter the loader — dsh-better-sidebar's own row carries one too, so the
32
- # two would ping-pong), so no recursion is possible. The old entries()-based
33
- # check is kept as a belt-and-suspenders fallback for rows not visible in the
34
- # patch stack (e.g. an entry injected at runtime by HMR or another plugin).
3
+ # dsh-better-sidebar is deliberately not mounted here and is not a dependency.
4
+ # Users who want that optional Web UI can install its bundle separately;
5
+ # lib/client.js discovers the betterSidebar service dynamically and adds the
6
+ # remote explorer/editor tabs when it is present.
35
7
  - insert:
36
8
  - id: dsh-remote
37
9
  name: 'dsh-remote'
38
- - id: dsh-remote-sidebar
39
- name: 'dsh-better-sidebar'
40
- disabled: !!js >-
41
- (function () {
42
- // Inspect the core row's explicit disabled flag, not service presence:
43
- // a Web server that simply has not started yet must still be awaited.
44
- // Do not evaluate another sidebar's disabled guard (issue #12).
45
- if ([...ctx.loader.entries()].some((e) => e.options.name === '@deepseek-ai/dsh-host-webserver' && e.options.disabled === true)) return true
46
- try {
47
- const inc = ctx.loader && ctx.loader.resolve && ctx.loader.resolve('include')
48
- const patches = inc && inc.subtree && inc.subtree.config && inc.subtree.config.patches
49
- if (Array.isArray(patches)) {
50
- for (const p of patches) {
51
- if (!p || !Array.isArray(p.insert)) continue
52
- for (const row of p.insert) {
53
- if (row && row.name === 'dsh-better-sidebar' && row.id !== 'dsh-remote-sidebar') return true
54
- }
55
- }
56
- }
57
- } catch (e) {}
58
- return [...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'dsh-remote-sidebar')
59
- })()
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
- // Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
1495
- // contract as the built-in local file tree (better-sidebar framework
1496
- // manages + persists the expanded set), so interaction and look match the
1497
- // local files tab. Data is listed live from the remote host over
1498
- // /dsh-remote/ls, rooted at this session's remote workspace dir.
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
- component: (props) => React.createElement(RemoteExplorerTab, props),
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,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.16",
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.",
3
+ "version": "0.8.18",
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, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",
7
7
  "homepage": "https://github.com/flymysql/dsh-remote",
@@ -66,7 +66,6 @@
66
66
  "dependencies": {
67
67
  "ssh2": "^1.16.0",
68
68
  "@deepseek-ai/schemastery": "^3.18.1",
69
- "dsh-better-sidebar": "^0.18.1",
70
69
  "iconv-lite": "^0.6.3"
71
70
  },
72
71
  "devDependencies": {