dsh-over-ssh 0.2.2 → 0.2.3

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
@@ -19,6 +19,12 @@ npm i && npm run build
19
19
  dsh plugin --profile web add /绝对路径/dsh-over-ssh
20
20
  ```
21
21
 
22
+ > **GUI 安装提示**:pnpm 会问是否允许安装脚本,列表里是 `ssh2` 与 `cpu-features`:允许后重试即可。
23
+ > 它们是 `ssh2` 传输的可选原生加速,拒绝会让 pnpm 中止本次安装(插件源码本身没有安装脚本)。
24
+ >
25
+ > 0.2.2 及更早在运行中的页面上启用插件时出现过 `2 entries did not activate`(`workingDirectory` /
26
+ > `fileReferences` 链);0.2.3 删掉了那条私有域克隆链(审计 0134),不再需要为它重启。
27
+
22
28
  **远端终端**要额外配一次 `terminalController.Config.shell`——`Config.shell` 是静态单值,连哪台只能由 wrapper 运行时决定:
23
29
 
24
30
  | 装法 | 指向 |
@@ -35,6 +41,30 @@ shell:
35
41
 
36
42
  不配也能用,只是 Web 终端永远开在本地(远程文件、远端执行、`@` 补全不受影响)。
37
43
 
44
+ ## 0.2.3 更新
45
+
46
+ > 下面这些内容相对 npm 上的 **0.2.2** 一起生效。
47
+
48
+ - **热启用不再失败**:移除私有域里对平台 `@deepseek-ai/dsh-file-reference-local` 的克隆与 `localFileReferences` 桥:
49
+ 根作用域的 `file-references` 本来就自己实现本地有界走查,那份克隆没有消费者。效果是启用插件时
50
+ 不再出现 `2 entries did not activate`(`workingDirectory` / `fileReferences` 等待链),热启用可以直接
51
+ 完成,不必为它完整重启。
52
+ - 组合层因此只剩 6 个条目(原 8 个):私有域只保留 `fs-sandbox` 克隆 + `localFs` 桥,本地写入围栏仍由
53
+ 平台沙箱执行。
54
+ - 文件引用补全复查(审计 0135):单个远端来源失败只降级该来源,不再让整个 `@` 补全失败;缓存命中后
55
+ 的取消仍按取消处理;Windows 本地根的反斜杠路径参与文件名/深度排序;符号链接目录按目录处理,只有
56
+ 指向根内的链接会被展开(根外链接只列链接本身,不把工作区外的文件带进 `@`),并带环检测与 Windows
57
+ 忽略目录大小写处理;打字快于一次走查时,同根并发调用共享一次树枚举、不再重复扫树(探针:6 次击键
58
+ = 1 次走查)。
59
+ - LSP 复查(审计 0136):`remote_lsp` 的 `server` 支持带空格的路径(引号切分,`"/opt/my tools/server" --stdio`,
60
+ 未闭合引号明确报错);宿主 `lsp.read` 每轮只取 256 KiB,不再吃满代理 2 MiB 默认块;客户端卡片的初始
61
+ 焦点与 Tab 集合跳过不可见控件。
62
+ - 回收站与锁(审计 0137):索引记录父子关系,**还原父会话会连带还原子会话**(失败留在垃圾并回报);
63
+ 会话/回收站锁加心跳,跨设备大目录 copy 超过 60 s 也不会被当成陈旧锁抢走;缺 `entryId` 的回收站条目
64
+ 现在可见但不可操作并说明原因;连接被删除后编辑对话框不再提交请求。
65
+ - 构建:删掉的宿主入口产物会被清理,不再随包发出(0.2.3 首次打包曾多带一个 `local-bridge-refs.js`)。
66
+ - 其余行为不变;远端 `@`、本地 `@`、文件工具、会话面板均未改动。
67
+
38
68
  ## 0.2.2 更新
39
69
 
40
70
  - 「添加目录」按会话作用域显示与删除,fork 子会话继承父会话的目录且立即可见。
@@ -54,10 +84,10 @@ shell:
54
84
 
55
85
  | 平台 | 字节 | sha256 |
56
86
  |---|---|---|
57
- | linux-amd64 | 4,944,032 | `d45237ef1bfec1955fb5e6a2391bd651ffeaffb89e781e1c518e9e9836eec974` |
58
- | linux-arm64 | 4,653,216 | `237a3cc9f5ae57f752f62eee4f8a57bd8c4684fd4d3fa6ab5851115baa844ccd` |
87
+ | linux-amd64 | 4,944,032 | `a1272e45a8213d4725a9eb91f9d08f1b5f460623e38730abef735c4569339d8d` |
88
+ | linux-arm64 | 4,653,216 | `1a8c20619fc335f6135a87e4f854a30a93a5d227e867fbf0eee1820f36840cf4` |
59
89
 
60
- - 代理版本:`0.2.2-e019f2d`
90
+ - 代理版本:`0.2.3-e019f2d`
61
91
  - 源码提交:`e019f2d`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
62
92
  - 清单原文:包内 `agents/agent-manifest.json`
63
93
  - 核对:`sha256sum agents/<平台>/dsh-over-ssh-agent` 与上表比对;按上面的提交检出 Go 仓后
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-11-06T16:15:01.747Z",
4
- "agentVersion": "0.2.2-e019f2d",
3
+ "generatedAt": "2026-11-07T05:49:15.687Z",
4
+ "agentVersion": "0.2.3-e019f2d",
5
5
  "sourceCommit": "e019f2d",
6
6
  "artifacts": [
7
7
  {
8
8
  "platform": "linux-amd64",
9
9
  "path": "linux-amd64/dsh-over-ssh-agent",
10
- "sha256": "d45237ef1bfec1955fb5e6a2391bd651ffeaffb89e781e1c518e9e9836eec974",
10
+ "sha256": "a1272e45a8213d4725a9eb91f9d08f1b5f460623e38730abef735c4569339d8d",
11
11
  "bytes": 4944032
12
12
  },
13
13
  {
14
14
  "platform": "linux-arm64",
15
15
  "path": "linux-arm64/dsh-over-ssh-agent",
16
- "sha256": "237a3cc9f5ae57f752f62eee4f8a57bd8c4684fd4d3fa6ab5851115baa844ccd",
16
+ "sha256": "1a8c20619fc335f6135a87e4f854a30a93a5d227e867fbf0eee1820f36840cf4",
17
17
  "bytes": 4653216
18
18
  }
19
19
  ]
package/cordis.patch.yml CHANGED
@@ -12,10 +12,14 @@
12
12
  # ctx.fs ← dsh-over-ssh/fs(多根路由)
13
13
  # ctx.fileReferences ← dsh-over-ssh/file-references(多根 @ 补全)
14
14
  #
15
- # 私有域(isolate: fs / fileReferences):
15
+ # 私有域(isolate: fs):
16
16
  # ctx.fs ← @deepseek-ai/dsh-fs-sandbox(**原版**,继续服务本地路径)
17
- # ctx.fileReferences ← @deepseek-ai/dsh-file-reference-local(**原版**)
18
- # 两个 bridge 把域内实例以 localFs / localFileReferences 暴露给根作用域。
17
+ # 一个 bridge 把域内实例以 localFs 暴露给根作用域。
18
+ #
19
+ # 文件引用**不再克隆平台实现**:根作用域的 dsh-over-ssh/file-references 自己实现了本地有界走查
20
+ # (见 integration/fs/file-reference-provider.ts),平台侧的 localFileReferences 没有任何消费者。
21
+ # 2026-11-07 审计 0134 删掉了那条克隆链:它让私有域多等一个 workingDirectory 服务,而 DSH 热启用
22
+ # 时这个依赖会短暂缺失,表现为「启用失败:2 entries did not activate」。
19
23
  #
20
24
  # 为什么是「接管」而不是「共存」:`ctx.fs` 是 Cordis 单例服务,同一隔离作用域只能有一个提供方,
21
25
  # 再注册第二个会抛 `service "fs" has been registered`。`isolate` 让原版实现能在私有域里继续跑,
@@ -24,8 +28,9 @@
24
28
  # 已实测的前提(cordis 0.2.1-rc.2):域内 `fs` 解析到域内实例;根 `fs` 不受影响;可以同时存在多个域;
25
29
  # 根作用域的消费者能注入到域内 bridge 暴露出来的 `localFs`。任何一条不成立这套组合都不成立。
26
30
  #
27
- # 这两组行必须**同时**生效或同时不生效:只禁用核心行而不启用本插件的行,会让 `ctx.fs` /
28
- # `ctx.fileReferences` 无人提供,所有文件能力一起失效。
31
+ # `ctx.fs` 与 `ctx.fileReferences` 两级都要有提供方:核心 fs-sandbox / file-reference-local 被本包
32
+ # 禁用后,根作用域由 dsh-over-ssh/fs 与 dsh-over-ssh/file-references 接管;本地路径的写入围栏由私有域
33
+ # 里那份原版 fs-sandbox 经 localFs 桥接执行。
29
34
  #
30
35
  # 还原:禁用整个 bundle。不要只删其中一行。
31
36
  #
@@ -57,19 +62,12 @@
57
62
  group: true
58
63
  isolate:
59
64
  fs: true
60
- fileReferences: true
61
65
  config:
62
66
  - id: 远程工作区-平台沙箱副本
63
67
  name: '@deepseek-ai/dsh-fs-sandbox'
64
- - id: 远程工作区-平台文件引用副本
65
- name: '@deepseek-ai/dsh-file-reference-local'
66
- # 两个 bridge 分开:file-reference-local 依赖 workingDirectory(在下游),不能和 fs 挤在同一行。
67
68
  - id: 远程工作区-桥接fs
68
69
  name: dsh-over-ssh/local-bridge
69
70
  inject: [fs]
70
- - id: 远程工作区-桥接文件引用
71
- name: dsh-over-ssh/local-bridge-refs
72
- inject: [fileReferences]
73
71
 
74
72
  # 根作用域的提供方。两个入口都是「路由型」:本地路径与远端路径由同一套表分发。
75
73
  - id: 远程工作区-路由fs
package/lib/client.js CHANGED
@@ -82,6 +82,8 @@ var OVER_SSH_DICTS = {
82
82
  sessionPanelSessions: "\u4F1A\u8BDD",
83
83
  sessionPanelBin: "\u56DE\u6536\u7AD9",
84
84
  sessionPanelBinEmpty: "\u56DE\u6536\u7AD9\u4E3A\u7A7A\u3002",
85
+ sessionPanelBinUnreadable: "\u53E6\u6709 {count} \u6761\u56DE\u6536\u7AD9\u6761\u76EE\u65E0\u6CD5\u663E\u793A\uFF08\u7F3A\u5C11\u4F1A\u8BDD\u6807\u8BC6\uFF09\uFF1B\u53EF\u91CD\u542F\u5BBF\u4E3B\u6216\u68C0\u67E5\u56DE\u6536\u7AD9\u7D22\u5F15\u3002",
86
+ sessionPanelBinRowUnaddressable: "\u8BE5\u6761\u76EE\u7F3A\u5C11\u6807\u8BC6\uFF0C\u65E0\u6CD5\u8FD8\u539F\u6216\u6E05\u9664",
85
87
  sessionPanelSelectAll: "\u5168\u9009\u53EF\u5220\u7684",
86
88
  sessionPanelClear: "\u6E05\u7A7A\u9009\u62E9",
87
89
  sessionPanelDeleteRecycle: "\u5220\u9664\u5230\u56DE\u6536\u7AD9",
@@ -159,6 +161,7 @@ var OVER_SSH_DICTS = {
159
161
  rootsErrorIdRequired: "\u8BF7\u586B\u5199\u8FDE\u63A5 id\u3002",
160
162
  rootsErrorIdInvalid: "\u8FDE\u63A5 id \u53EA\u80FD\u5305\u542B\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u70B9\u3001\u4E0B\u5212\u7EBF\u548C\u77ED\u6A2A\u7EBF\uFF0C\u4E14\u5FC5\u987B\u4EE5\u5B57\u6BCD\u6216\u6570\u5B57\u5F00\u5934\u3002",
161
163
  rootsErrorIdDuplicate: "\u8FD9\u4E2A\u8FDE\u63A5 id \u5DF2\u7ECF\u5B58\u5728\u3002",
164
+ rootsErrorConnectionMissing: "\u8BE5\u8FDE\u63A5\u5DF2\u88AB\u5220\u9664\uFF0C\u65E0\u6CD5\u4FDD\u5B58\uFF1B\u8BF7\u5173\u95ED\u7F16\u8F91\u5E76\u5237\u65B0\u5217\u8868\u3002",
162
165
  rootsErrorHostRequired: "\u8BF7\u586B\u5199\u4E3B\u673A\u540D\u3002",
163
166
  rootsErrorUserRequired: "\u8BF7\u586B\u5199\u7528\u6237\u540D\u3002",
164
167
  rootsErrorPort: "\u7AEF\u53E3\u5FC5\u987B\u662F 1 \u5230 65535 \u4E4B\u95F4\u7684\u6574\u6570\u3002",
@@ -276,6 +279,9 @@ var OVER_SSH_DICTS = {
276
279
  sessionResultWorkspaceRecycled: "\u5DF2\u628A\u8BE5\u5DE5\u4F5C\u533A {count} \u4E2A\u4F1A\u8BDD\u79FB\u5165\u56DE\u6536\u7AD9\uFF0C{size}\uFF08\u53EF\u6062\u590D\uFF09",
277
280
  sessionResultWorkspaceRecycledRefused: "\u5DF2\u628A\u8BE5\u5DE5\u4F5C\u533A {count} \u4E2A\u4F1A\u8BDD\u79FB\u5165\u56DE\u6536\u7AD9\uFF0C{size}\uFF08\u53EF\u6062\u590D\uFF1B{refused} \u4E2A\u88AB\u62D2\u7EDD\uFF09",
278
281
  sessionResultRestored: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD",
282
+ sessionResultRestoredWithSubsessions: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF0C\u5E76\u8FDE\u5E26\u8FD8\u539F {count} \u4E2A\u5B50\u4F1A\u8BDD",
283
+ sessionResultRestoredPartial: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF0C\u53E6\u6709 {failed} \u4E2A\u5B50\u4F1A\u8BDD\u4ECD\u7559\u5728\u56DE\u6536\u7AD9",
284
+ sessionResultRestoredParentBinned: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF08\u5176\u7236\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9\uFF09",
279
285
  sessionResultPurged: "\u5DF2\u6C38\u4E45\u6E05\u9664\u8BE5\u6761\u76EE",
280
286
  sessionResultSweepNone: "\u6CA1\u6709\u9700\u8981\u6E05\u7406\u7684\u6761\u76EE",
281
287
  sessionResultSweepDone: "\u5DF2\u6E05\u7406 {count} \u4E2A\u6761\u76EE",
@@ -329,6 +335,8 @@ var OVER_SSH_DICTS = {
329
335
  sessionPanelSessions: "Sessions",
330
336
  sessionPanelBin: "Recycle bin",
331
337
  sessionPanelBinEmpty: "The recycle bin is empty.",
338
+ sessionPanelBinUnreadable: "{count} more bin entr(y/ies) cannot be shown (no session id); restart the host or check the bin index.",
339
+ sessionPanelBinRowUnaddressable: "This entry has no id, so it cannot be restored or purged",
332
340
  sessionPanelSelectAll: "Select deletable",
333
341
  sessionPanelClear: "Clear selection",
334
342
  sessionPanelDeleteRecycle: "Delete to bin",
@@ -401,6 +409,7 @@ var OVER_SSH_DICTS = {
401
409
  rootsErrorIdRequired: "Enter a connection id.",
402
410
  rootsErrorIdInvalid: "A connection id may contain letters, digits, dots, underscores and dashes, and must start with a letter or a digit.",
403
411
  rootsErrorIdDuplicate: "That connection id already exists.",
412
+ rootsErrorConnectionMissing: "That connection has been deleted; close the editor and refresh.",
404
413
  rootsErrorHostRequired: "Enter a host name.",
405
414
  rootsErrorUserRequired: "Enter a user name.",
406
415
  rootsErrorPort: "The port must be an integer between 1 and 65535.",
@@ -518,6 +527,9 @@ var OVER_SSH_DICTS = {
518
527
  sessionResultWorkspaceRecycled: "Moved {count} sessions of that workspace to the recycle bin, {size} (recoverable)",
519
528
  sessionResultWorkspaceRecycledRefused: "Moved {count} sessions of that workspace to the recycle bin, {size} (recoverable; {refused} refused)",
520
529
  sessionResultRestored: "Restored that session",
530
+ sessionResultRestoredWithSubsessions: "Restored that session and {count} sub-session(s)",
531
+ sessionResultRestoredPartial: "Restored that session; {failed} sub-session(s) stayed in the bin",
532
+ sessionResultRestoredParentBinned: "Restored that session (its parent is still in the bin)",
521
533
  sessionResultPurged: "Permanently removed that entry",
522
534
  sessionResultSweepNone: "Nothing to sweep",
523
535
  sessionResultSweepDone: "Swept {count} entries",
@@ -1438,15 +1450,29 @@ function createCardPortal(options) {
1438
1450
  };
1439
1451
  }
1440
1452
  function focusFirstControl(container) {
1453
+ const all = container?.querySelectorAll?.(CARD_FOCUS_SELECTOR);
1454
+ if (all !== void 0 && all !== null) {
1455
+ for (const candidate of Array.from(all)) {
1456
+ if (!isVisibleControl(candidate)) continue;
1457
+ candidate.focus?.();
1458
+ return true;
1459
+ }
1460
+ return false;
1461
+ }
1441
1462
  const target = container?.querySelector?.(CARD_FOCUS_SELECTOR) ?? void 0;
1442
1463
  if (target === void 0 || typeof target.focus !== "function") return false;
1443
1464
  target.focus();
1444
1465
  return true;
1445
1466
  }
1467
+ function isVisibleControl(element) {
1468
+ const measurable = element.getClientRects;
1469
+ if (typeof measurable === "function") return measurable.call(element).length > 0;
1470
+ return element.hidden !== true;
1471
+ }
1446
1472
  function focusableWithin(container) {
1447
1473
  const found = container?.querySelectorAll?.(CARD_FOCUS_SELECTOR);
1448
1474
  if (found === null || found === void 0) return [];
1449
- return Array.from(found);
1475
+ return Array.from(found).filter(isVisibleControl);
1450
1476
  }
1451
1477
  function trapTab(event, container, active) {
1452
1478
  const focusables = focusableWithin(container);
@@ -2726,6 +2752,11 @@ function createRootsPanelController(options) {
2726
2752
  const editingId = state.editingId;
2727
2753
  if (editingId === void 0) return;
2728
2754
  if (!canEdit(state)) return;
2755
+ if (!(state.snapshot?.connections ?? []).some((row) => row.id === editingId)) {
2756
+ dispatch({ type: "edit-ended" });
2757
+ dispatch({ type: "refused", scope: "edit", message: { key: "rootsErrorConnectionMissing" } });
2758
+ return;
2759
+ }
2729
2760
  const editDrafts = state.editDrafts;
2730
2761
  const portText = editDrafts.port.trim();
2731
2762
  const port = portText === "" ? void 0 : Number(portText);
@@ -3869,10 +3900,10 @@ function toSessionRowView(raw) {
3869
3900
  };
3870
3901
  }
3871
3902
  function toBinRowView(raw) {
3872
- const entryId = raw["entryId"];
3903
+ const rawEntryId = raw["entryId"];
3873
3904
  const sessionId = raw["sessionId"];
3874
- if (typeof entryId !== "string" || entryId === "") return void 0;
3875
3905
  if (typeof sessionId !== "string" || sessionId === "") return void 0;
3906
+ const entryId = typeof rawEntryId === "string" ? rawEntryId : "";
3876
3907
  return {
3877
3908
  entryId,
3878
3909
  sessionId,
@@ -3887,6 +3918,8 @@ function emptySnapshot() {
3887
3918
  available: false,
3888
3919
  sessions: [],
3889
3920
  bin: [],
3921
+ binUnaddressable: 0,
3922
+ binUnreadable: 0,
3890
3923
  policy: { maxEntries: 0, maxBytes: 0, maxAgeMs: 0 },
3891
3924
  totals: { sessions: 0, bytes: 0, subsessions: 0, live: 0, running: 0 }
3892
3925
  };
@@ -3958,13 +3991,28 @@ function parseSessionSnapshot(value) {
3958
3991
  const available = body["available"] === true;
3959
3992
  const sessions = Array.isArray(body["sessions"]) ? body["sessions"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toSessionRowView(raw)].filter((row) => row !== void 0) : []) : [];
3960
3993
  const unblocked = unblockOrphanSubsessions(sessions);
3961
- const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toBinRowView(raw)].filter((row) => row !== void 0) : []) : [];
3994
+ let binUnreadable = 0;
3995
+ const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => {
3996
+ if (typeof raw !== "object" || raw === null) {
3997
+ binUnreadable += 1;
3998
+ return [];
3999
+ }
4000
+ const row = toBinRowView(raw);
4001
+ if (row === void 0) {
4002
+ binUnreadable += 1;
4003
+ return [];
4004
+ }
4005
+ return [row];
4006
+ }) : [];
4007
+ const binUnaddressable = bin.filter((row) => row.entryId === "").length;
3962
4008
  const rawPolicy = typeof body["policy"] === "object" && body["policy"] !== null ? body["policy"] : {};
3963
4009
  const numberOr = (key) => typeof rawPolicy[key] === "number" && Number.isFinite(rawPolicy[key]) ? rawPolicy[key] : 0;
3964
4010
  return {
3965
4011
  available,
3966
4012
  sessions: unblocked,
3967
4013
  bin,
4014
+ binUnaddressable,
4015
+ binUnreadable,
3968
4016
  policy: { maxEntries: numberOr("maxEntries"), maxBytes: numberOr("maxBytes"), maxAgeMs: numberOr("maxAgeMs") },
3969
4017
  totals: {
3970
4018
  sessions: unblocked.length,
@@ -4049,7 +4097,14 @@ async function deleteWorkspaceSessions(io, cwd, options = {}) {
4049
4097
  }
4050
4098
  async function restoreSession(io, entryId) {
4051
4099
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
4052
- return postAction(io, { action: "restore", entryId }, () => ({ key: "sessionResultRestored" }));
4100
+ return postAction(io, { action: "restore", entryId }, (value) => {
4101
+ const failed = typeof value["failedSubsessions"] === "number" ? value["failedSubsessions"] : 0;
4102
+ const restored = typeof value["restoredSubsessions"] === "number" ? value["restoredSubsessions"] : 0;
4103
+ if (failed > 0) return { key: "sessionResultRestoredPartial", params: { failed } };
4104
+ if (restored > 0) return { key: "sessionResultRestoredWithSubsessions", params: { count: restored } };
4105
+ if (value["parentStillBinned"] === true) return { key: "sessionResultRestoredParentBinned" };
4106
+ return { key: "sessionResultRestored" };
4107
+ });
4053
4108
  }
4054
4109
  async function purgeSession(io, entryId) {
4055
4110
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
@@ -4158,9 +4213,10 @@ var SessionRow = (0, import_react7.memo)(function SessionRow2(props) {
4158
4213
  });
4159
4214
  function renderBinRow(props) {
4160
4215
  const { row, copy, busy } = props;
4216
+ const unaddressable = row.entryId === "";
4161
4217
  return (0, import_react7.createElement)(
4162
4218
  "li",
4163
- { key: row.entryId },
4219
+ { key: unaddressable ? `unaddressable:${row.sessionId}` : row.entryId },
4164
4220
  (0, import_react7.createElement)(
4165
4221
  "div",
4166
4222
  { className: CLASS.row },
@@ -4172,20 +4228,22 @@ function renderBinRow(props) {
4172
4228
  (0, import_react7.createElement)(
4173
4229
  "span",
4174
4230
  { className: CLASS.rowActions },
4175
- Button({
4176
- variant: "secondary",
4177
- size: "compact",
4178
- disabled: busy,
4179
- onClick: () => props.onRestore(row.entryId),
4180
- children: copy.restore
4181
- }),
4182
- Button({
4183
- variant: "ghost",
4184
- size: "compact",
4185
- disabled: busy,
4186
- onClick: () => props.onPurge(row.entryId),
4187
- children: copy.purge
4188
- })
4231
+ ...unaddressable ? [(0, import_react7.createElement)("span", { className: CLASS.rowMeta, title: copy.binRowUnaddressable }, copy.binRowUnaddressable)] : [
4232
+ Button({
4233
+ variant: "secondary",
4234
+ size: "compact",
4235
+ disabled: busy,
4236
+ onClick: () => props.onRestore(row.entryId),
4237
+ children: copy.restore
4238
+ }),
4239
+ Button({
4240
+ variant: "ghost",
4241
+ size: "compact",
4242
+ disabled: busy,
4243
+ onClick: () => props.onPurge(row.entryId),
4244
+ children: copy.purge
4245
+ })
4246
+ ]
4189
4247
  )
4190
4248
  )
4191
4249
  );
@@ -4463,10 +4521,10 @@ function SessionPanel({ copy, io, close }) {
4463
4521
  (0, import_react7.createElement)("h3", { className: CLASS.blockTitle }, copy.binHeading),
4464
4522
  // Same rule as the inventory: 「回收站为空」 is a claim about an **answer**. While nothing has been read (or the
4465
4523
  // read failed) the status line above says what is happening (audit 2026-11-01b ui P1-3).
4466
- phase.kind !== "ready" ? null : snapshot.bin.length === 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binEmpty) : (0, import_react7.createElement)(
4524
+ phase.kind !== "ready" ? null : snapshot.bin.length === 0 && snapshot.binUnreadable === 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binEmpty) : (0, import_react7.createElement)(
4467
4525
  "div",
4468
4526
  { className: CLASS.block },
4469
- (0, import_react7.createElement)(
4527
+ snapshot.bin.length === 0 ? null : (0, import_react7.createElement)(
4470
4528
  "ul",
4471
4529
  { className: CLASS.list },
4472
4530
  ...snapshot.bin.map(
@@ -4479,6 +4537,7 @@ function SessionPanel({ copy, io, close }) {
4479
4537
  })
4480
4538
  )
4481
4539
  ),
4540
+ snapshot.binUnreadable > 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binUnreadable(snapshot.binUnreadable)) : null,
4482
4541
  Button({
4483
4542
  variant: "secondary",
4484
4543
  size: "compact",
@@ -6634,6 +6693,8 @@ function apply(ctx) {
6634
6693
  sessionsHeading: dict.sessionPanelSessions,
6635
6694
  binHeading: dict.sessionPanelBin,
6636
6695
  binEmpty: dict.sessionPanelBinEmpty,
6696
+ binUnreadable: (count) => resolveMessage2(dict, { key: "sessionPanelBinUnreadable", params: { count } }),
6697
+ binRowUnaddressable: dict.sessionPanelBinRowUnaddressable,
6637
6698
  selectAll: dict.sessionPanelSelectAll,
6638
6699
  clearSelection: dict.sessionPanelClear,
6639
6700
  deleteRecycle: dict.sessionPanelDeleteRecycle,
@@ -2,7 +2,7 @@
2
2
  import "@deepseek-ai/cordis";
3
3
 
4
4
  // src/host/integration/fs/file-reference-provider.ts
5
- import { readdir } from "node:fs/promises";
5
+ import { readdir, realpath, stat } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import "@deepseek-ai/cordis";
8
8
  import FileReferenceService from "@deepseek-ai/dsh-file-reference";
@@ -47,7 +47,7 @@ var DEFAULTS = {
47
47
  outputCap: 8 * 1024 * 1024
48
48
  };
49
49
  function rankMatches(paths, query, limit) {
50
- const needle = query.trim().toLowerCase();
50
+ const needle = separatorsForMatching(query.trim().toLowerCase());
51
51
  if (needle === "") {
52
52
  const top = [];
53
53
  const better = (left, right) => {
@@ -72,7 +72,8 @@ function rankMatches(paths, query, limit) {
72
72
  }
73
73
  const scored = [];
74
74
  for (const path of paths) {
75
- const lower = path.toLowerCase();
75
+ const normalized = separatorsForMatching(path);
76
+ const lower = normalized.toLowerCase();
76
77
  const base = lower.slice(lower.lastIndexOf("/") + 1);
77
78
  let score;
78
79
  if (base.startsWith(needle)) score = 0;
@@ -87,11 +88,15 @@ function rankMatches(paths, query, limit) {
87
88
  );
88
89
  return scored.slice(0, limit).map((entry) => entry.path);
89
90
  }
91
+ function separatorsForMatching(path) {
92
+ return path.includes("\\") && !path.includes("/") ? path.replaceAll("\\", "/") : path;
93
+ }
90
94
  function depth(path) {
91
95
  let segments = 0;
92
96
  let inSegment = false;
93
97
  for (let index = 0; index < path.length; index += 1) {
94
- if (path.charCodeAt(index) === 47) {
98
+ const code = path.charCodeAt(index);
99
+ if (code === 47 || code === 92) {
95
100
  inSegment = false;
96
101
  continue;
97
102
  }
@@ -153,6 +158,14 @@ function makeLocalRoot(path, options = {}) {
153
158
  function normalizeLocalPath2(path, options = {}) {
154
159
  return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
155
160
  }
161
+ function localPathContains(root, candidate, options = {}) {
162
+ const normalizedRoot = normalizeLocalPath2(root, options);
163
+ const normalizedCandidate = normalizeLocalPath2(candidate, options);
164
+ if (normalizedRoot === "/" || /^[a-z]:\/$/.test(normalizedRoot)) {
165
+ return normalizedCandidate.startsWith(normalizedRoot);
166
+ }
167
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
168
+ }
156
169
 
157
170
  // src/host/integration/session-fork-pending.ts
158
171
  var pending = /* @__PURE__ */ new Map();
@@ -248,6 +261,17 @@ var RemoteFileReferenceService = class extends FileReferenceService {
248
261
  * (`local-backend.ts:53`), so there is no change signal to hook — only a short TTL.
249
262
  */
250
263
  #localCache = /* @__PURE__ */ new Map();
264
+ /**
265
+ * 正在进行中的本地走查,按根合并。
266
+ *
267
+ * 为什么需要它:缓存只覆盖"上一次走查**已完成**"的情况。用户敲 `@mo` 时的间隔可能短于一次走查
268
+ * (10 000 条目约 68 ms),于是几次击键会各自发起一次全树走查——同一棵树在宿主事件循环上被重复
269
+ * 扫描。这里让并发的同根调用共享一次走查;查询不同不影响,因为**排序仍按每次调用自己的 query 现算**,
270
+ * 共享的只有与 query 无关的树枚举。
271
+ */
272
+ #inflight = /* @__PURE__ */ new Map();
273
+ /** 已经告警过的来源标签;一次故障只提示一次,避免每个按键都刷一行日志。 */
274
+ #warned = /* @__PURE__ */ new Set();
251
275
  constructor(ctx, options = {}) {
252
276
  super(ctx);
253
277
  this.#options = options;
@@ -279,20 +303,53 @@ var RemoteFileReferenceService = class extends FileReferenceService {
279
303
  * without an index, or a cwd that is not an absolute host path.
280
304
  */
281
305
  async list(agent, query, signal) {
306
+ signal.throwIfAborted();
282
307
  const cwd = agent.session.header.cwd ?? process.cwd();
283
308
  const registry = this.ctx.sshDevRoots;
284
309
  if (registry === void 0) return [];
285
310
  const identity = sessionIdentityOf(this.ctx, agent.session.header.id);
286
311
  const gated = identity.sessionId === void 0 ? registry.routed.tryResolve(cwd) : registry.routed.tryResolve(cwd, { session: identity });
287
- const addedTask = this.#addedRemoteCandidates(cwd, identity, query, signal);
312
+ const addedTask = this.#degrade(
313
+ "\u672C\u4F1A\u8BDD\u6DFB\u52A0\u7684\u8FDC\u7AEF\u6839",
314
+ void 0,
315
+ () => this.#addedRemoteCandidates(cwd, identity, query, signal)
316
+ );
288
317
  const ungated = gated.ok ? void 0 : registry.routed.tryResolve(cwd);
289
318
  const implicit = gated.ok || ungated?.ok === true ? void 0 : sessionLocalRoot(cwd);
290
- const namedTask = gated.ok ? this.#namedCandidates(gated.target.root, query, signal) : implicit === void 0 ? void 0 : this.#localCandidates(implicit.path, query, signal);
319
+ const namedTask = gated.ok ? this.#degrade("cwd \u547D\u540D\u7684\u6839", [], () => this.#namedCandidates(gated.target.root, query, signal)) : implicit === void 0 ? void 0 : this.#degrade("cwd \u81EA\u8EAB\u76EE\u5F55", [], () => this.#localCandidates(implicit.path, query, signal));
291
320
  const [added, named] = await Promise.all([addedTask, namedTask]);
321
+ signal.throwIfAborted();
292
322
  if (gated.ok) return this.#merge(added, named ?? []);
293
323
  if (ungated?.ok === true) return added ?? [];
294
324
  return this.#merge(added, named ?? []);
295
325
  }
326
+ /**
327
+ * Run one candidate source, degrading a failure to `fallback`.
328
+ *
329
+ * 为什么不让一次失败冒到上层:`@` 补全有两个独立来源(本会话「添加目录」的远端根、cwd 命名的根)。
330
+ * 旧写法直接 `Promise.all`,其中**任何一个**失败(连接断、远端超时、walk 抛错)都会让整个 `list()`
331
+ * 拒绝——明明本地候选已经拿到,也会一起消失。取消是例外:调用方已经取消时必须继续抛出,不能把
332
+ * "取消"伪装成"空结果"。
333
+ *
334
+ * @param label - 日志里标识来源的短名,同一来源只告警一次。
335
+ * @param fallback - 失败时返回的降级值。
336
+ * @param task - 真正的枚举任务。
337
+ * @returns 任务结果,或降级值。
338
+ */
339
+ async #degrade(label, fallback, task) {
340
+ try {
341
+ return await task();
342
+ } catch (error) {
343
+ if (isAbortError(error)) throw error;
344
+ if (!this.#warned.has(label)) {
345
+ this.#warned.add(label);
346
+ this.ctx.logger?.warn?.(
347
+ `file-references: ${label} \u53D6\u5019\u9009\u5931\u8D25\uFF0C\u672C\u6B21\u53EA\u8DF3\u8FC7\u8BE5\u6765\u6E90\uFF1A${error instanceof Error ? error.message : String(error)}`
348
+ );
349
+ }
350
+ return fallback;
351
+ }
352
+ }
296
353
  /** cwd 命名的那个根自己的候选:远端走索引,本地走有界走查。 */
297
354
  #namedCandidates(root, query, signal) {
298
355
  return root.location === "remote" && root.connectionId !== void 0 ? this.#remoteCandidates(root.connectionId, root.path, query, signal) : this.#localCandidates(root.path, query, signal);
@@ -359,6 +416,7 @@ var RemoteFileReferenceService = class extends FileReferenceService {
359
416
  * @returns the entries, from cache when the previous walk is still fresh.
360
417
  */
361
418
  async #walkCached(root, walk, signal) {
419
+ signal.throwIfAborted();
362
420
  const started = this.#now();
363
421
  const cached = this.#localCache.get(root);
364
422
  if (cached !== void 0) {
@@ -369,10 +427,33 @@ var RemoteFileReferenceService = class extends FileReferenceService {
369
427
  }
370
428
  this.#localCache.delete(root);
371
429
  }
372
- const entries = await walk(root, signal);
373
- signal.throwIfAborted();
374
- this.#rememberLocal(root, entries, started);
375
- return entries;
430
+ let shared = this.#inflight.get(root);
431
+ if (shared === void 0) {
432
+ const controller = new AbortController();
433
+ const entry = { controller, waiters: 0, promise: Promise.resolve([]) };
434
+ const promise = (async () => {
435
+ const entries = await walk(root, controller.signal);
436
+ controller.signal.throwIfAborted();
437
+ this.#rememberLocal(root, entries, started);
438
+ return entries;
439
+ })();
440
+ entry.promise = promise;
441
+ void promise.catch(() => void 0).finally(() => {
442
+ if (this.#inflight.get(root) === entry) this.#inflight.delete(root);
443
+ });
444
+ this.#inflight.set(root, entry);
445
+ shared = entry;
446
+ }
447
+ shared.waiters += 1;
448
+ try {
449
+ return await raceAbort(shared.promise, signal);
450
+ } finally {
451
+ shared.waiters -= 1;
452
+ if (shared.waiters === 0 && this.#inflight.get(root) === shared) {
453
+ this.#inflight.delete(root);
454
+ shared.controller.abort(new DOMException("no active @ completion callers", "AbortError"));
455
+ }
456
+ }
376
457
  }
377
458
  /** Store one local enumeration, evicting the least recently used root past the cap. */
378
459
  #rememberLocal(root, entries, at) {
@@ -388,6 +469,12 @@ var RemoteFileReferenceService = class extends FileReferenceService {
388
469
  const maxEntries = this.#options.maxEntries ?? LOCAL_MAX_ENTRIES;
389
470
  const maxDepth = this.#options.maxDepth ?? LOCAL_MAX_DEPTH;
390
471
  const found = [];
472
+ const visited = /* @__PURE__ */ new Set();
473
+ const rootReal = await realpath(root).catch(() => root);
474
+ const caseInsensitive = process.platform === "win32";
475
+ const withinRoot = (candidate) => localPathContains(rootReal, candidate, { caseInsensitive });
476
+ visited.add(rootReal);
477
+ const excluded = (name) => LOCAL_EXCLUDED_DIRECTORIES.includes(caseInsensitive ? name.toLowerCase() : name);
391
478
  let frontier = [{ path: root, depth: 0 }];
392
479
  while (frontier.length > 0 && found.length < maxEntries) {
393
480
  signal.throwIfAborted();
@@ -403,11 +490,27 @@ var RemoteFileReferenceService = class extends FileReferenceService {
403
490
  }
404
491
  for (const child of children) {
405
492
  if (found.length >= maxEntries) break;
406
- const isDirectory = child.isDirectory();
407
- if (isDirectory && LOCAL_EXCLUDED_DIRECTORIES.includes(child.name)) continue;
493
+ let isDirectory = child.isDirectory();
494
+ let traversable = child.isDirectory();
408
495
  const path = join(entry.path, child.name);
496
+ if (!isDirectory && child.isSymbolicLink()) {
497
+ try {
498
+ const target = await stat(path);
499
+ isDirectory = target.isDirectory();
500
+ if (isDirectory) {
501
+ const real = await realpath(path);
502
+ if (visited.has(real) || !withinRoot(real)) traversable = false;
503
+ else {
504
+ visited.add(real);
505
+ traversable = true;
506
+ }
507
+ }
508
+ } catch {
509
+ }
510
+ }
511
+ if (isDirectory && excluded(child.name)) continue;
409
512
  found.push({ path, kind: isDirectory ? "directory" : "file" });
410
- if (isDirectory) next.push({ path, depth: entry.depth + 1 });
513
+ if (traversable) next.push({ path, depth: entry.depth + 1 });
411
514
  }
412
515
  }
413
516
  frontier = next;
@@ -415,6 +518,26 @@ var RemoteFileReferenceService = class extends FileReferenceService {
415
518
  return found;
416
519
  }
417
520
  };
521
+ function raceAbort(promise, signal) {
522
+ if (signal.aborted) return Promise.reject(signal.reason ?? new DOMException("aborted", "AbortError"));
523
+ return new Promise((resolve, reject) => {
524
+ const onAbort = () => reject(signal.reason ?? new DOMException("aborted", "AbortError"));
525
+ signal.addEventListener("abort", onAbort, { once: true });
526
+ promise.then(
527
+ (value) => {
528
+ signal.removeEventListener("abort", onAbort);
529
+ resolve(value);
530
+ },
531
+ (error) => {
532
+ signal.removeEventListener("abort", onAbort);
533
+ reject(error);
534
+ }
535
+ );
536
+ });
537
+ }
538
+ function isAbortError(error) {
539
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError") || typeof error === "object" && error !== null && "name" in error && (error.name === "AbortError" || error.name === "TimeoutError");
540
+ }
418
541
  function sessionLocalRoot(cwd) {
419
542
  if (cwd === "" || !isAbsoluteLocalPath(cwd)) return void 0;
420
543
  return makeLocalRoot(cwd);
package/lib/index.js CHANGED
@@ -4372,6 +4372,7 @@ function parseSessionTrashIndex(text8) {
4372
4372
  segment: record2["segment"],
4373
4373
  originalSegment: record2["originalSegment"],
4374
4374
  title: typeof record2["title"] === "string" ? record2["title"] : void 0,
4375
+ ...typeof record2["parentSessionId"] === "string" && record2["parentSessionId"] !== "" ? { parentSessionId: record2["parentSessionId"] } : {},
4375
4376
  bytes: typeof record2["bytes"] === "number" && Number.isFinite(record2["bytes"]) ? record2["bytes"] : 0,
4376
4377
  deletedAtMs: record2["deletedAtMs"],
4377
4378
  movedBy: record2["movedBy"] === "copy" ? "copy" : "rename",
@@ -4418,6 +4419,7 @@ function makeSessionTrashEntry(input) {
4418
4419
  segment: input.segment,
4419
4420
  originalSegment: input.originalSegment,
4420
4421
  title: input.title,
4422
+ ...input.parentSessionId === void 0 ? {} : { parentSessionId: input.parentSessionId },
4421
4423
  bytes: input.bytes,
4422
4424
  deletedAtMs: input.deletedAtMs,
4423
4425
  movedBy: input.movedBy,
@@ -4693,6 +4695,7 @@ function createSessionAdminDelete(ctx) {
4693
4695
  segment: entryId,
4694
4696
  originalSegment: record2.segment,
4695
4697
  title: record2.title,
4698
+ parentSessionId: record2.parentSessionId,
4696
4699
  bytes: record2.bytes,
4697
4700
  deletedAtMs,
4698
4701
  movedBy,
@@ -4810,31 +4813,63 @@ function createSessionAdminService(deps) {
4810
4813
  if (entry === void 0) {
4811
4814
  throw new SshDevError("TRASH_NOT_FOUND", `\u56DE\u6536\u7AD9\u6761\u76EE\u4E0D\u5B58\u5728\uFF1A${entryId}`, { entryId });
4812
4815
  }
4813
- const target = joinPath(sessionsRoot, entry.projectDir, entry.originalSegment);
4814
- const occupied = await deps.stat(target) !== void 0;
4815
- const plan = planRestoreSession(entry, { occupied });
4816
- if (plan.kind === "refuse") {
4817
- throw new SshDevError("TRANSFER_CONFLICT", `\u539F\u8DEF\u5F84\u5DF2\u88AB\u5360\u7528\uFF0C\u62D2\u7EDD\u9759\u9ED8\u6362\u540D\u8FD8\u539F\uFF1A${target}`, {
4818
- entryId,
4819
- suggestedId: plan.suggestedId,
4820
- target
4821
- });
4822
- }
4823
- const from = entryDir(entry.segment);
4824
- let movedBy;
4825
- try {
4826
- movedBy = await deps.moveDir(from, target);
4827
- } catch (error) {
4828
- const landed = await deps.stat(target) !== void 0;
4829
- if (!landed) {
4830
- throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FD8\u539F\u4F1A\u8BDD\u5931\u8D25\uFF1A${entry.sessionId}`, {
4831
- entryId,
4832
- cause: error instanceof Error ? error.message : String(error)
4816
+ const restoreOne = async (item) => {
4817
+ const target = joinPath(sessionsRoot, item.projectDir, item.originalSegment);
4818
+ const occupied = await deps.stat(target) !== void 0;
4819
+ const plan = planRestoreSession(item, { occupied });
4820
+ if (plan.kind === "refuse") {
4821
+ throw new SshDevError("TRANSFER_CONFLICT", `\u539F\u8DEF\u5F84\u5DF2\u88AB\u5360\u7528\uFF0C\u62D2\u7EDD\u9759\u9ED8\u6362\u540D\u8FD8\u539F\uFF1A${target}`, {
4822
+ entryId: item.id,
4823
+ suggestedId: plan.suggestedId,
4824
+ target
4825
+ });
4826
+ }
4827
+ const from = entryDir(item.segment);
4828
+ let movedBy;
4829
+ try {
4830
+ movedBy = await deps.moveDir(from, target);
4831
+ } catch (error) {
4832
+ const landed = await deps.stat(target) !== void 0;
4833
+ if (!landed) {
4834
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FD8\u539F\u4F1A\u8BDD\u5931\u8D25\uFF1A${item.sessionId}`, {
4835
+ entryId: item.id,
4836
+ cause: error instanceof Error ? error.message : String(error)
4837
+ });
4838
+ }
4839
+ movedBy = "copy";
4840
+ }
4841
+ return { entry: item, restoredPath: target, movedBy };
4842
+ };
4843
+ const parentStillBinned = entry.parentSessionId !== void 0 && entries.some((item) => item.sessionId === entry.parentSessionId);
4844
+ const restored = await restoreOne(entry);
4845
+ const childrenOf = /* @__PURE__ */ new Map();
4846
+ for (const item of entries) {
4847
+ if (item.id === entry.id || item.parentSessionId === void 0) continue;
4848
+ const bucket = childrenOf.get(item.parentSessionId);
4849
+ if (bucket === void 0) childrenOf.set(item.parentSessionId, [item]);
4850
+ else bucket.push(item);
4851
+ }
4852
+ const restoredSubsessions = [];
4853
+ const failedSubsessions = [];
4854
+ const removedIds = /* @__PURE__ */ new Set([entry.id]);
4855
+ const queue = [...childrenOf.get(entry.sessionId) ?? []];
4856
+ while (queue.length > 0) {
4857
+ const child = queue.shift();
4858
+ if (child === void 0) break;
4859
+ if (removedIds.has(child.id)) continue;
4860
+ try {
4861
+ const childRestored = await restoreOne(child);
4862
+ restoredSubsessions.push({ sessionId: child.sessionId, restoredPath: childRestored.restoredPath });
4863
+ removedIds.add(child.id);
4864
+ queue.push(...childrenOf.get(child.sessionId) ?? []);
4865
+ } catch (error) {
4866
+ failedSubsessions.push({
4867
+ sessionId: child.sessionId,
4868
+ reason: error instanceof Error ? error.message : String(error)
4833
4869
  });
4834
4870
  }
4835
- movedBy = "copy";
4836
4871
  }
4837
- const remaining = entries.filter((item) => item.id !== entryId);
4872
+ const remaining = entries.filter((item) => !removedIds.has(item.id));
4838
4873
  try {
4839
4874
  await writeIndex(remaining);
4840
4875
  } catch (error) {
@@ -4843,7 +4878,7 @@ function createSessionAdminService(deps) {
4843
4878
  cause: error instanceof Error ? error.message : String(error)
4844
4879
  });
4845
4880
  }
4846
- return { entry, restoredPath: target, movedBy };
4881
+ return { ...restored, restoredSubsessions, failedSubsessions, parentStillBinned };
4847
4882
  });
4848
4883
  const purge = (entryId, options = {}) => {
4849
4884
  void options;
@@ -8753,9 +8788,16 @@ var AgentTransport = class extends TransportBase {
8753
8788
  state: typeof record2["state"] === "string" ? record2["state"] : "unknown"
8754
8789
  };
8755
8790
  }
8756
- /** Read a language server's output from `offset`: `lsp.read`. Same offset semantics as {@link procOutput}. */
8757
- async lspRead(id, offset = 0, signal) {
8758
- const result = await this.#rpc("lsp.read", { id, offset }, signal);
8791
+ /**
8792
+ * Read a language server's output from `offset`: `lsp.read`. Same offset semantics as {@link procOutput}.
8793
+ *
8794
+ * `maxBytes` bounds one response. The agent defaults it to its 2 MiB chunk; callers that poll (the LSP pump)
8795
+ * pass a smaller cap so a quiet server does not cost 2 MiB of transient host memory per poll. Agents older
8796
+ * than the field ignore it (Go's JSON decoder ignores unknown fields), so it is safe to always send.
8797
+ */
8798
+ async lspRead(id, offset = 0, signal, maxBytes) {
8799
+ const params = maxBytes === void 0 ? { id, offset } : { id, offset, maxBytes: Math.max(1, Math.floor(maxBytes)) };
8800
+ const result = await this.#rpc("lsp.read", params, signal);
8759
8801
  return readLspRead(result);
8760
8802
  }
8761
8803
  /** One language-server session's state: `lsp.status`. */
@@ -12613,7 +12655,7 @@ function directoryProbeCommand(root) {
12613
12655
  return `test -d ${shellQuote(root)}`;
12614
12656
  }
12615
12657
  function rankMatches(paths, query, limit) {
12616
- const needle = query.trim().toLowerCase();
12658
+ const needle = separatorsForMatching(query.trim().toLowerCase());
12617
12659
  if (needle === "") {
12618
12660
  const top = [];
12619
12661
  const better = (left, right) => {
@@ -12638,7 +12680,8 @@ function rankMatches(paths, query, limit) {
12638
12680
  }
12639
12681
  const scored = [];
12640
12682
  for (const path of paths) {
12641
- const lower = path.toLowerCase();
12683
+ const normalized = separatorsForMatching(path);
12684
+ const lower = normalized.toLowerCase();
12642
12685
  const base = lower.slice(lower.lastIndexOf("/") + 1);
12643
12686
  let score;
12644
12687
  if (base.startsWith(needle)) score = 0;
@@ -12653,11 +12696,15 @@ function rankMatches(paths, query, limit) {
12653
12696
  );
12654
12697
  return scored.slice(0, limit).map((entry) => entry.path);
12655
12698
  }
12699
+ function separatorsForMatching(path) {
12700
+ return path.includes("\\") && !path.includes("/") ? path.replaceAll("\\", "/") : path;
12701
+ }
12656
12702
  function depth2(path) {
12657
12703
  let segments = 0;
12658
12704
  let inSegment = false;
12659
12705
  for (let index = 0; index < path.length; index += 1) {
12660
- if (path.charCodeAt(index) === 47) {
12706
+ const code = path.charCodeAt(index);
12707
+ if (code === 47 || code === 92) {
12661
12708
  inSegment = false;
12662
12709
  continue;
12663
12710
  }
@@ -13763,6 +13810,8 @@ function sessionToolDefinitions(service) {
13763
13810
  sessions: { type: "array", items: { type: "string" }, description: "`list`: one formatted line per session." },
13764
13811
  entries: { type: "array", items: { type: "string" }, description: "`restore` without an id: bin entry ids." },
13765
13812
  restoredPath: { type: "string", description: "`restore` with an id: where the entry went back to." },
13813
+ restoredSubsessions: { type: "integer", description: "`restore`: sub-sessions restored together with the entry." },
13814
+ failedSubsessions: { type: "integer", description: "`restore`: sub-sessions that stayed in the bin (path occupied / move failed)." },
13766
13815
  matched: { type: "integer", description: "`delete_workspace`: how many sessions belonged to the directory." },
13767
13816
  mode: { type: "string", description: "`delete` / `delete_workspace`: the mode that was applied." },
13768
13817
  recoverable: { type: "boolean", description: "`delete`: whether the bin can bring it back." },
@@ -13798,10 +13847,16 @@ function sessionToolDefinitions(service) {
13798
13847
  }
13799
13848
  if (typeof entryId !== "string" || entryId === "") throw new Error("entryId \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u7701\u7565\u5219\u5217\u51FA\u56DE\u6536\u7AD9\uFF09");
13800
13849
  const result = await service.restore(entryId);
13850
+ const extra = [];
13851
+ if (result.restoredSubsessions.length > 0) extra.push(`\u5E76\u8FD8\u539F\u5B50\u4F1A\u8BDD ${result.restoredSubsessions.length} \u4E2A`);
13852
+ if (result.failedSubsessions.length > 0) extra.push(`${result.failedSubsessions.length} \u4E2A\u5B50\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9`);
13853
+ if (result.parentStillBinned) extra.push("\u5176\u7236\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9");
13801
13854
  return {
13802
13855
  action: "restore",
13803
13856
  restoredPath: result.restoredPath,
13804
- summary: `\u5DF2\u8FD8\u539F\u4F1A\u8BDD ${result.entry.sessionId} -> ${result.restoredPath}`
13857
+ restoredSubsessions: result.restoredSubsessions.length,
13858
+ failedSubsessions: result.failedSubsessions.length,
13859
+ summary: `\u5DF2\u8FD8\u539F\u4F1A\u8BDD ${result.entry.sessionId} -> ${result.restoredPath}${extra.length === 0 ? "" : `\uFF08${extra.join("\uFF0C")}\uFF09`}`
13805
13860
  };
13806
13861
  }
13807
13862
  const mode = record2["mode"] ?? "recycle";
@@ -16027,7 +16082,7 @@ async function serveAction(req, res, deps) {
16027
16082
 
16028
16083
  // src/host/integration/session/session-bin-lock.ts
16029
16084
  init_errors();
16030
- import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5 } from "node:fs/promises";
16085
+ import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5, utimes } from "node:fs/promises";
16031
16086
  import { dirname as dirname5 } from "node:path";
16032
16087
  var DEFAULT_STALE_LOCK_MS = 6e4;
16033
16088
  var DEFAULT_LOCK_RETRY_MS = 25;
@@ -16036,6 +16091,7 @@ function createSessionBinLock(options) {
16036
16091
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
16037
16092
  const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
16038
16093
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
16094
+ const heartbeatMs = options.heartbeatMs ?? (staleMs > 0 ? Math.max(1e3, Math.floor(staleMs / 4)) : 0);
16039
16095
  const sleep2 = options.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
16040
16096
  const now = options.now ?? (() => Date.now());
16041
16097
  const owner = options.owner ?? `${process.pid}@${hostnameOf()}`;
@@ -16116,6 +16172,17 @@ function createSessionBinLock(options) {
16116
16172
  }
16117
16173
  return true;
16118
16174
  };
16175
+ const refreshLock = async (token) => {
16176
+ try {
16177
+ const text8 = await readFile6(options.path, "utf8");
16178
+ const record2 = JSON.parse(text8);
16179
+ if (record2.token !== token) return;
16180
+ const at = new Date(now());
16181
+ await utimes(options.path, at, at);
16182
+ } catch (error) {
16183
+ options.onHeartbeatError?.(error);
16184
+ }
16185
+ };
16119
16186
  const withLock = async (_key, operation) => {
16120
16187
  const deadline = now() + timeoutMs;
16121
16188
  let token;
@@ -16132,9 +16199,17 @@ function createSessionBinLock(options) {
16132
16199
  }
16133
16200
  await sleep2(retryMs);
16134
16201
  }
16202
+ let heartbeat;
16203
+ if (token !== void 0 && heartbeatMs > 0) {
16204
+ heartbeat = setInterval(() => {
16205
+ void refreshLock(token);
16206
+ }, heartbeatMs);
16207
+ heartbeat.unref?.();
16208
+ }
16135
16209
  try {
16136
16210
  return await operation();
16137
16211
  } finally {
16212
+ if (heartbeat !== void 0) clearInterval(heartbeat);
16138
16213
  if (token !== void 0) await releaseIfOwned(token);
16139
16214
  }
16140
16215
  };
@@ -16626,7 +16701,14 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16626
16701
  return;
16627
16702
  }
16628
16703
  const result = await service.restore(entryId, options);
16629
- send(req, res, 200, { ok: true, restoredPath: result.restoredPath, movedBy: result.movedBy });
16704
+ send(req, res, 200, {
16705
+ ok: true,
16706
+ restoredPath: result.restoredPath,
16707
+ movedBy: result.movedBy,
16708
+ restoredSubsessions: result.restoredSubsessions.length,
16709
+ failedSubsessions: result.failedSubsessions.length,
16710
+ parentStillBinned: result.parentStillBinned
16711
+ });
16630
16712
  return;
16631
16713
  }
16632
16714
  case "purge": {
@@ -17018,7 +17100,7 @@ function registerPanelRoutes(env, hooks) {
17018
17100
  }
17019
17101
 
17020
17102
  // src/wiring/fs.ts
17021
- var LOCAL_WORLD_MISSING_WARNING = 'dsh-over-ssh: \u79C1\u6709\u57DF\u7684 bridge \u672A\u5C31\u4F4D\uFF08ctx.get("localFs") === undefined\uFF09\uFF1A\u672C\u5730\u8DEF\u5F84\u7684\u5199\u5165\u56F4\u680F\u9000\u56DE\u672C\u63D2\u4EF6\u81EA\u5DF1\u7684 LocalBackend\u2014\u2014\u529F\u80FD\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u56F4\u680F\u6362\u4E86\u6267\u884C\u8005\u3002\u8BF7\u68C0\u67E5 cordis.patch.yml \u91CC \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u79C1\u6709\u57DF \u7EC4\uFF08fs/fileReferences \u7684 isolate \u4E0E\u4E24\u4E2A bridge \u884C\uFF09\u662F\u5426\u88AB\u7981\u7528\u6216\u52A0\u8F7D\u5931\u8D25\u3002';
17103
+ var LOCAL_WORLD_MISSING_WARNING = 'dsh-over-ssh: \u79C1\u6709\u57DF\u7684 bridge \u672A\u5C31\u4F4D\uFF08ctx.get("localFs") === undefined\uFF09\uFF1A\u672C\u5730\u8DEF\u5F84\u7684\u5199\u5165\u56F4\u680F\u9000\u56DE\u672C\u63D2\u4EF6\u81EA\u5DF1\u7684 LocalBackend\u2014\u2014\u529F\u80FD\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u56F4\u680F\u6362\u4E86\u6267\u884C\u8005\u3002\u8BF7\u68C0\u67E5 cordis.patch.yml \u91CC \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u79C1\u6709\u57DF \u7EC4\uFF08fs \u7684 isolate \u4E0E \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u6865\u63A5fs \u884C\uFF09\u662F\u5426\u88AB\u7981\u7528\u6216\u52A0\u8F7D\u5931\u8D25\u3002';
17022
17104
  function installClientDataChannel(ctx) {
17023
17105
  try {
17024
17106
  installRemoteFace(ctx);
@@ -17849,6 +17931,10 @@ function wireRoots(env) {
17849
17931
  ctx.logger?.warn?.(
17850
17932
  `\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5DF2\u8FC7\u671F\u88AB\u56DE\u6536\uFF1A\u6301\u6709\u8005 ${JSON.stringify(event.holder)}\uFF0C\u5DF2\u8FC7\u671F ${Math.round(event.ageMs / 1e3)} \u79D2`
17851
17933
  );
17934
+ },
17935
+ // 心跳失败只警告:锁仍然有效,操作继续;但网络盘 EIO/EPERM 值得在日志里留下痕迹。
17936
+ onHeartbeatError: (error) => {
17937
+ ctx.logger?.warn?.(`\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5FC3\u8DF3\u5237\u65B0\u5931\u8D25\uFF08\u9501\u4ECD\u6301\u6709\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
17852
17938
  }
17853
17939
  });
17854
17940
  binLocks.set(key, created);
@@ -19930,6 +20016,7 @@ function parseContentLength(header) {
19930
20016
  }
19931
20017
 
19932
20018
  // src/host/integration/lsp/lsp-session.ts
20019
+ var LSP_READ_CHUNK_BYTES = 256 * 1024;
19933
20020
  var IDLE_POLL_MS = 60;
19934
20021
  var BUSY_POLL_MS = 0;
19935
20022
  var DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
@@ -20218,7 +20305,7 @@ var LspSession = class _LspSession {
20218
20305
  if (this.#stopped) return;
20219
20306
  let delay = IDLE_POLL_MS;
20220
20307
  try {
20221
- const chunk = await this.#transport.lspRead(this.#id, this.#offset, signal);
20308
+ const chunk = await this.#transport.lspRead(this.#id, this.#offset, signal, LSP_READ_CHUNK_BYTES);
20222
20309
  this.#offset = chunk.nextOffset;
20223
20310
  if (chunk.lost) {
20224
20311
  this.#failAll(new LspSessionError("\u8BED\u8A00\u670D\u52A1\u5668\u8F93\u51FA\u5DF2\u88AB\u4E22\u5F03\uFF08\u73AF\u5F62\u7F13\u51B2\u6EA2\u51FA\uFF09\uFF0C\u6D41\u4E0D\u53EF\u6062\u590D"));
@@ -21180,9 +21267,44 @@ function readArgs(args) {
21180
21267
  ...typeof record2["server"] === "string" && record2["server"] !== "" ? { server: record2["server"] } : {}
21181
21268
  };
21182
21269
  }
21270
+ function splitCommandLine(value) {
21271
+ const parts = [];
21272
+ let current = "";
21273
+ let started = false;
21274
+ let quote2;
21275
+ for (let index = 0; index < value.length; index += 1) {
21276
+ const char = value[index];
21277
+ if (quote2 !== void 0) {
21278
+ if (char === quote2) quote2 = void 0;
21279
+ else if (char === "\\" && quote2 === '"' && index + 1 < value.length) {
21280
+ current += value[index + 1];
21281
+ index += 1;
21282
+ } else current += char;
21283
+ continue;
21284
+ }
21285
+ if (char === '"' || char === "'") {
21286
+ quote2 = char;
21287
+ started = true;
21288
+ continue;
21289
+ }
21290
+ if (/\s/.test(char)) {
21291
+ if (started) {
21292
+ parts.push(current);
21293
+ current = "";
21294
+ started = false;
21295
+ }
21296
+ continue;
21297
+ }
21298
+ current += char;
21299
+ started = true;
21300
+ }
21301
+ if (quote2 !== void 0) throw new Error('server \u7684\u5F15\u53F7\u6CA1\u6709\u95ED\u5408\uFF1B\u5E26\u7A7A\u683C\u7684\u8DEF\u5F84\u8BF7\u5199\u6210 "/opt/my tools/server" --stdio');
21302
+ if (started) parts.push(current);
21303
+ return parts;
21304
+ }
21183
21305
  function parseServer(value) {
21184
21306
  if (value === void 0) return void 0;
21185
- const parts = value.split(/\s+/).filter((part) => part !== "");
21307
+ const parts = splitCommandLine(value);
21186
21308
  if (parts.length === 0) return void 0;
21187
21309
  return parts.length === 1 ? { command: parts[0] } : { command: parts[0], args: parts.slice(1) };
21188
21310
  }
@@ -21204,7 +21326,7 @@ function remoteLspToolDefinitions(deps) {
21204
21326
  column: { type: "integer", description: "Column for `hover` / `definition`, 1-based. Required for those two actions." },
21205
21327
  server: {
21206
21328
  type: "string",
21207
- description: "Executable to run, with optional arguments (`gopls`, `rust-analyzer`, `pylsp -v`). Defaults to the table entry for the file language; required when the language has no entry."
21329
+ description: 'Executable to run, with optional arguments (`gopls`, `rust-analyzer`, `pylsp -v`). Defaults to the table entry for the file language; required when the language has no entry. Quote a path that contains spaces: `"/opt/my tools/server" --stdio`.'
21208
21330
  }
21209
21331
  },
21210
21332
  output: {
@@ -21463,6 +21585,10 @@ function wireSessionAdmin(env) {
21463
21585
  ctx.logger?.warn?.(
21464
21586
  `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5DF2\u8FC7\u671F\u88AB\u56DE\u6536\uFF1A\u6301\u6709\u8005 ${JSON.stringify(event.holder)}\uFF0C\u5DF2\u8FC7\u671F ${Math.round(event.ageMs / 1e3)} \u79D2`
21465
21587
  );
21588
+ },
21589
+ // 心跳失败只警告:锁仍有效,操作继续;网络盘 EIO/EPERM 需要留下痕迹。
21590
+ onHeartbeatError: (error) => {
21591
+ ctx.logger?.warn?.(`\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5FC3\u8DF3\u5237\u65B0\u5931\u8D25\uFF08\u9501\u4ECD\u6301\u6709\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
21466
21592
  }
21467
21593
  });
21468
21594
  const sessionAdmin = createSessionAdminService({
@@ -163,6 +163,23 @@ export interface RestoreSessionResult {
163
163
  /** Absolute path the session directory was moved back to. */
164
164
  readonly restoredPath: string;
165
165
  readonly movedBy: 'rename' | 'copy';
166
+ /**
167
+ * Sub-sessions restored **together with** the requested entry.
168
+ *
169
+ * Restoring a parent used to leave its deleted children in the bin with no discoverable link back to the
170
+ * parent, so the user could never learn they were still there. They are now restored in the same call.
171
+ */
172
+ readonly restoredSubsessions: readonly {
173
+ readonly sessionId: string;
174
+ readonly restoredPath: string;
175
+ }[];
176
+ /** Sub-sessions that could not be restored (path occupied, move failed); they stay in the bin. */
177
+ readonly failedSubsessions: readonly {
178
+ readonly sessionId: string;
179
+ readonly reason: string;
180
+ }[];
181
+ /** True when the restored entry is a sub-session whose parent is still in the bin. */
182
+ readonly parentStillBinned: boolean;
166
183
  }
167
184
  /** One purge outcome. */
168
185
  export interface PurgeSessionResult {
@@ -227,6 +227,13 @@ export interface SessionTrashEntry {
227
227
  */
228
228
  readonly originalSegment: string;
229
229
  readonly title: string | undefined;
230
+ /**
231
+ * Parent session id from the header, when this deleted session was a sub-session.
232
+ *
233
+ * Optional so an index written by 0.2.2 (which did not record it) still parses; restoring a parent then
234
+ * cannot find children recorded before the field existed, which is exactly the old behaviour.
235
+ */
236
+ readonly parentSessionId?: string | undefined;
230
237
  /** Total bytes of the moved log files, for the retention policy. */
231
238
  readonly bytes: number;
232
239
  readonly deletedAtMs: number;
@@ -342,6 +349,7 @@ export declare function makeSessionTrashEntry(input: {
342
349
  readonly segment: string;
343
350
  readonly originalSegment: string;
344
351
  readonly title: string | undefined;
352
+ readonly parentSessionId?: string | undefined;
345
353
  readonly bytes: number;
346
354
  readonly deletedAtMs: number;
347
355
  readonly movedBy: 'rename' | 'copy';
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The `./local-bridge` entry: republish the private realm's stock `fs` under a name the router can inject.
3
3
  *
4
- * Composition (see `cordis.patch.yml`): a `cordis:group` with `isolate: { fs: true, fileReferences: true }`
5
- * instantiates the **stock** `fs-sandbox` a second time. Because that realm owns its own `fs` slot, the stock
6
- * sandbox keeps running and keeps enforcing the platform's write fences for local paths — this plugin no
7
- * longer re-implements them.
4
+ * Composition (see `cordis.patch.yml`): a `cordis:group` with `isolate: { fs: true }` instantiates the
5
+ * **stock** `fs-sandbox` a second time. Because that realm owns its own `fs` slot, the stock sandbox keeps
6
+ * running and keeps enforcing the platform's write fences for local paths — this plugin no longer
7
+ * re-implements them.
8
8
  *
9
9
  * The bridge is what carries that instance back out. It is provided under a **new name** (`localFs`), which is
10
10
  * not in the realm's `isolate` list, so the value reaches the root scope where the router injects it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-over-ssh",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "SSH remote development for DeepSeek Harness: connections (key/password/ProxyJump/ssh config), remote files without sync, remote terminal, remote workspaces, remote @-completion, upload/download.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -26,10 +26,6 @@
26
26
  "types": "./lib/types/src/local-bridge.d.ts",
27
27
  "default": "./lib/local-bridge.js"
28
28
  },
29
- "./local-bridge-refs": {
30
- "types": "./lib/types/src/local-bridge-refs.d.ts",
31
- "default": "./lib/local-bridge-refs.js"
32
- },
33
29
  "./package.json": "./package.json"
34
30
  },
35
31
  "dsh": {
@@ -1,16 +0,0 @@
1
- // src/local-bridge-refs.ts
2
- import "@deepseek-ai/cordis";
3
- var LocalFileReferencesBridge = class {
4
- static inject = ["fileReferences"];
5
- constructor(ctx) {
6
- const local = ctx.get("fileReferences");
7
- if (local === void 0) {
8
- ctx.logger?.warn?.("local-bridge-refs: \u9694\u79BB\u57DF\u91CC\u6CA1\u6709 fileReferences\uFF1B\u8DEF\u7531\u5668\u5C06\u6CA1\u6709\u672C\u5730\u540E\u7AEF");
9
- return;
10
- }
11
- ctx.provide("localFileReferences", local);
12
- }
13
- };
14
- export {
15
- LocalFileReferencesBridge as default
16
- };
@@ -1,25 +0,0 @@
1
- /**
2
- * The `./local-bridge-refs` entry: republish the private realm's stock `fileReferences` as `localFileReferences`.
3
- *
4
- * A **separate row** from `./local-bridge`, and that separation is load-bearing: the stock
5
- * `file-reference-local` injects `workingDirectory`, which sits downstream of the routed `fs`. If one bridge
6
- * gated both services it would have to wait for `fileReferences` too, and `fileReferences` cannot activate
7
- * until `workingDirectory` does — which cannot happen until the router it is waiting behind exists. Two rows
8
- * keep that dependency out of the way: `localFs` appears as soon as `fs` does, `localFileReferences` as soon as
9
- * `fileReferences` does.
10
- *
11
- * @module dsh-over-ssh/local-bridge-refs
12
- */
13
- import { Context } from '@deepseek-ai/cordis';
14
- import type { FileReferenceService } from '@deepseek-ai/dsh-file-reference';
15
- declare module '@deepseek-ai/cordis' {
16
- interface Context {
17
- /** The stock `file-reference-local` instance living in this plugin's private realm. */
18
- localFileReferences?: FileReferenceService;
19
- }
20
- }
21
- /** Publish the realm's `fileReferences` as `localFileReferences`. */
22
- export default class LocalFileReferencesBridge {
23
- static inject: readonly ["fileReferences"];
24
- constructor(ctx: Context);
25
- }