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 +33 -3
- package/agents/agent-manifest.json +4 -4
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/cordis.patch.yml +10 -12
- package/lib/client.js +83 -22
- package/lib/file-references.js +136 -13
- package/lib/index.js +162 -36
- package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +17 -0
- package/lib/types/src/host/domain/session/session-admin.d.ts +8 -0
- package/lib/types/src/local-bridge.d.ts +4 -4
- package/package.json +1 -5
- package/lib/local-bridge-refs.js +0 -16
- package/lib/types/src/local-bridge-refs.d.ts +0 -25
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 | `
|
|
58
|
-
| linux-arm64 | 4,653,216 | `
|
|
87
|
+
| linux-amd64 | 4,944,032 | `a1272e45a8213d4725a9eb91f9d08f1b5f460623e38730abef735c4569339d8d` |
|
|
88
|
+
| linux-arm64 | 4,653,216 | `1a8c20619fc335f6135a87e4f854a30a93a5d227e867fbf0eee1820f36840cf4` |
|
|
59
89
|
|
|
60
|
-
- 代理版本:`0.2.
|
|
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-
|
|
4
|
-
"agentVersion": "0.2.
|
|
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": "
|
|
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": "
|
|
16
|
+
"sha256": "1a8c20619fc335f6135a87e4f854a30a93a5d227e867fbf0eee1820f36840cf4",
|
|
17
17
|
"bytes": 4653216
|
|
18
18
|
}
|
|
19
19
|
]
|
|
Binary file
|
|
Binary file
|
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
|
|
15
|
+
# 私有域(isolate: fs):
|
|
16
16
|
# ctx.fs ← @deepseek-ai/dsh-fs-sandbox(**原版**,继续服务本地路径)
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
28
|
-
#
|
|
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
|
|
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
|
-
|
|
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 }, () =>
|
|
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
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
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,
|
package/lib/file-references.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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 (
|
|
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
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
8757
|
-
|
|
8758
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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": {
|
package/lib/local-bridge-refs.js
DELETED
|
@@ -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
|
-
}
|