dsh-over-ssh 0.2.3 → 0.2.4

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
@@ -41,6 +41,54 @@ shell:
41
41
 
42
42
  不配也能用,只是 Web 终端永远开在本地(远程文件、远端执行、`@` 补全不受影响)。
43
43
 
44
+ ## 0.2.4 更新
45
+
46
+ > 下面这些内容相对 npm 上的 **0.2.3** 一起生效。
47
+
48
+ ### 传输与内存
49
+
50
+ - transport lanes(审计 0164):interactive/bulk/compute 分 lane(`limits.transportLanes`,失败回退 interactive);job/LSP 按会话、watch 按会话(agentless 时连接级)使用 stateful lane(`limits.statefulLanes` 默认 8);transfer stat 走 bulk;Go 在飞窗口从 8 提到 16;proc/LSP/index 按会话公平配额(含后来者预留名额)。
51
+ - P6 读/解析分离:读协程只读原始帧,zstd 解压、JSON 解析与 blob 解压移出读协程;原始帧队列有界,慢盘/大 blob 不再阻塞同连接其它会话。
52
+ - 修复 P6 读错误被 `readBlob` 消费后主循环永久阻塞;大帧解析后把 parser 缓冲降回 64 KiB;`#pollListings` 快照表加 64 条上限;并发上传的 stdin drain 监听器合并。
53
+ - 修复重连 discard 期间 `releaseSession` 被忽略导致已删除会话 lane 复活;selector 抛错泄漏 lane 槽;watch 按会话分 lane 后会话删除会回收 `watch:<session>`。
54
+ - 修复浏览后端 dispose 拒绝产生未处理拒绝;`#sendCancel` 写失败遗留 pending 槽。
55
+
56
+ ### 会话作用域与「添加目录」
57
+
58
+ - 会话根按 `sessions` + `workspaces` 双重作用域过滤;带 `scopedForSession` 时,客户端仍按当前 workspace 过滤未限定行,dev 热修。
59
+ - 工作区路径比较容忍尾斜杠与 Windows 大小写差异;真正切换会话/工作区时清空旧 roots,并复位卡片的 `open`/`browsing`。
60
+ - 会话作用域修复覆盖:切会话/工作区不再显示上一条会话的目录;同 workspace 的两个会话按 session 隔离;fork 继承仍可见。
61
+
62
+ ### Agent / 子进程
63
+
64
+ - 修复取消排队请求不生效:`sessionTable.Cancel` 摘除等待队列与 blob 队列,`dispatchMu` 覆盖出队+launch 与取消。
65
+ - 修复子进程启动失败泄漏 fd:interactive `proc.start` 的 stdin,`rg`/`git status`/`git ls-files` 的 stdout,LSP stdout。
66
+ - 修复 JSON 编码尺寸估算漏算 HTML 转义(`<`/`>`/`&`/U+2028/U+2029),避免含 `<` 的大输出被误判装得下而超帧。
67
+
68
+ ### 数据正确性与工具
69
+
70
+ - `remote_read_many`:一次往返读多个文件(1..16,默认每文件 256 KiB),非 UTF-8 给 `binary`/`dataBase64` 与渲染警告;修复批量读总量上限与二进制截断一致。
71
+ - `remote_project_summary`:一次往返给 git 状态、顶层结构、包清单、README 头与 test/build/lint 建议。
72
+ - `remote_apply_patch`:一次往返多文件补丁,先全量校验,默认失败回滚。
73
+ - `remote_exec` / `remote_job`:非 UTF-8 输出附带 base64 精确字节与渲染警告;`remote_job` 不再丢 exactBytes。
74
+ - 协议 v3 zstd 跨帧压缩;`remote_grep` 支持 `offset`/`total`/`nextOffset` 分页;Tier 2 批量读走代理 `fs.readMany`。
75
+ - LSP:`server` 支持带空格路径的引号切分、`lsp.read` 每轮 256 KiB、跨块多字节字符按 `exactBytes` 解析;卡片的初始焦点与 Tab 集合跳过不可见控件。
76
+ - 文件引用补全:单来源失败只降级该来源;Windows 反斜杠路径参与排序;符号链接按目录处理,根内展开、根外只列链接;同根并发走查共享。
77
+ - 端口转发:`net.probe` 远端预检,关闭端口在 start 阶段稳定失败;IPv6 `[::1]` 归一;旧代理没有 `net.probe` 时跳过预检继续。
78
+ - 回收站与锁:父子还原、锁心跳、缺 `entryId` 行可见不可操作、已删连接编辑不再提交。
79
+ - 会话管理页:修正短 id、工作区目录内联、行内回收站按钮、已选计数。
80
+
81
+ ### 其它修复
82
+
83
+ - `remote_read_many`、`remote_exec`、`remote_job` 的二进制/截断/渲染一致性问题(审计 0160–0162)。
84
+ - `writeFile` 非 Uint8Array 参数本地立即拒绝;`repo.summary` 只读普通文件;`patchMany` 超大文件先判大小;`fs.readMany` 先校验再起协程。
85
+ - 主菜单/渲染修复:项目概览补顶层/清单/README、分页提示按 Tier 分档、`remote_grep` 真正分页、zstd 小帧只压缩一次。
86
+
87
+ ### 其它
88
+
89
+ - 组合层仍为 6 个条目;本地写入围栏由平台沙箱执行。
90
+ - 其余行为不变。
91
+
44
92
  ## 0.2.3 更新
45
93
 
46
94
  > 下面这些内容相对 npm 上的 **0.2.2** 一起生效。
@@ -84,11 +132,11 @@ shell:
84
132
 
85
133
  | 平台 | 字节 | sha256 |
86
134
  |---|---|---|
87
- | linux-amd64 | 4,944,032 | `a1272e45a8213d4725a9eb91f9d08f1b5f460623e38730abef735c4569339d8d` |
88
- | linux-arm64 | 4,653,216 | `1a8c20619fc335f6135a87e4f854a30a93a5d227e867fbf0eee1820f36840cf4` |
135
+ | linux-amd64 | 5,591,200 | `c1b5e1ffcc9c7fd26e21140090dafec28f90a50fd3c5a3edb315d0165f84dc40` |
136
+ | linux-arm64 | 5,243,040 | `524e8752ba5a63ac006d50f132f6286afa2ccfe55ed217d286e4ada361deea71` |
89
137
 
90
- - 代理版本:`0.2.3-e019f2d`
91
- - 源码提交:`e019f2d`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
138
+ - 代理版本:`0.2.4-a7e77fb`
139
+ - 源码提交:`a7e77fb`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
92
140
  - 清单原文:包内 `agents/agent-manifest.json`
93
141
  - 核对:`sha256sum agents/<平台>/dsh-over-ssh-agent` 与上表比对;按上面的提交检出 Go 仓后
94
142
  `scripts/check.sh` 可重建同样的产物。
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-11-07T05:49:15.687Z",
4
- "agentVersion": "0.2.3-e019f2d",
5
- "sourceCommit": "e019f2d",
3
+ "generatedAt": "2026-11-08T16:23:50.384Z",
4
+ "agentVersion": "0.2.4-a7e77fb",
5
+ "sourceCommit": "a7e77fb",
6
6
  "artifacts": [
7
7
  {
8
8
  "platform": "linux-amd64",
9
9
  "path": "linux-amd64/dsh-over-ssh-agent",
10
- "sha256": "a1272e45a8213d4725a9eb91f9d08f1b5f460623e38730abef735c4569339d8d",
11
- "bytes": 4944032
10
+ "sha256": "c1b5e1ffcc9c7fd26e21140090dafec28f90a50fd3c5a3edb315d0165f84dc40",
11
+ "bytes": 5591200
12
12
  },
13
13
  {
14
14
  "platform": "linux-arm64",
15
15
  "path": "linux-arm64/dsh-over-ssh-agent",
16
- "sha256": "1a8c20619fc335f6135a87e4f854a30a93a5d227e867fbf0eee1820f36840cf4",
17
- "bytes": 4653216
16
+ "sha256": "524e8752ba5a63ac006d50f132f6286afa2ccfe55ed217d286e4ada361deea71",
17
+ "bytes": 5243040
18
18
  }
19
19
  ]
20
20
  }
package/lib/client.js CHANGED
@@ -90,6 +90,8 @@ var OVER_SSH_DICTS = {
90
90
  sessionPanelDeletePermanent: "\u6C38\u4E45\u5220\u9664\u2026",
91
91
  sessionPanelRestore: "\u8FD8\u539F",
92
92
  sessionPanelPurge: "\u6C38\u4E45\u6E05\u9664\u2026",
93
+ sessionPanelRowRecycle: "\u56DE\u6536\u7AD9",
94
+ sessionPanelSelectedCount: "\u5DF2\u9009 {count} \u4E2A",
93
95
  sessionPanelSweep: "\u6309\u4FDD\u7559\u7B56\u7565\u6E05\u7406",
94
96
  sessionPanelRefresh: "\u5237\u65B0",
95
97
  sessionPanelClose: "\u5173\u95ED",
@@ -102,7 +104,7 @@ var OVER_SSH_DICTS = {
102
104
  sessionPanelConfirmPurgeBody: "\u5C06\u6C38\u4E45\u6E05\u9664\u56DE\u6536\u7AD9\u6761\u76EE {targets}\uFF0C\u4E0D\u53EF\u6062\u590D\u3002",
103
105
  sessionPanelConfirmCancel: "\u53D6\u6D88",
104
106
  sessionPanelConfirmAccept: "\u786E\u8BA4\u6C38\u4E45\u5220\u9664",
105
- sessionPanelDeleteWorkspace: "\u5220\u9664\u6B64\u5DE5\u4F5C\u533A\u7684\u4F1A\u8BDD\u2026",
107
+ sessionPanelDeleteWorkspace: "\u5220\u9664\u672C\u5DE5\u4F5C\u533A\u2026",
106
108
  // 必须是**模板字符串**而不是函数:字典的契约是 Record<OverSshKey, string>,渲染统一走
107
109
  // resolveMessage 的 `template.replace(...)`。写成函数会让面板在渲染时抛
108
110
  // TypeError: template.replace is not a function,整块白屏。
@@ -248,8 +250,7 @@ var OVER_SSH_DICTS = {
248
250
  browseChoose: "\u7528\u8FD9\u4E2A\u76EE\u5F55",
249
251
  sessionAddOpen: "\u6DFB\u52A0\u76EE\u5F55",
250
252
  sessionAddTitle: "\u6DFB\u52A0\u76EE\u5F55\u5230\u5F53\u524D\u5DE5\u4F5C\u533A",
251
- sessionAddHint: "\u9009\u4E2D\u7684\u76EE\u5F55\u4F1A\u52A0\u5165\u672C\u4F1A\u8BDD\uFF1A\u672C\u4F1A\u8BDD\u7684\u6587\u4EF6\u5DE5\u5177\u4E0E @ \u8865\u5168\u7ACB\u5373\u53EF\u7528\uFF0C\u4E0D\u4F1A\u65B0\u5EFA\u5DE5\u4F5C\u533A\u3002",
252
- sessionAddWorkspace: "\u5F53\u524D\u5DE5\u4F5C\u533A\uFF1A{path}",
253
+ sessionAddHint: "\u52A0\u5165\u672C\u4F1A\u8BDD\uFF1A\u6587\u4EF6\u5DE5\u5177\u4E0E @ \u8865\u5168\u7ACB\u5373\u53EF\u7528\uFF0C\u4E0D\u4F1A\u65B0\u5EFA\u5DE5\u4F5C\u533A\u3002",
253
254
  sessionAddWorkspaceUnknown: "\u62FF\u4E0D\u5230\u5F53\u524D\u5DE5\u4F5C\u533A\u8DEF\u5F84\uFF0C\u65E0\u6CD5\u628A\u76EE\u5F55\u52A0\u5165\u672C\u4F1A\u8BDD\uFF08\u672C\u7EC4\u5408\u6CA1\u6709\u63D0\u4F9B\u4F1A\u8BDD\u6216\u5DE5\u4F5C\u533A\u670D\u52A1\u65F6\uFF0C\u8FD9\u4E2A\u5165\u53E3\u4E0D\u53EF\u7528\uFF09\u3002",
254
255
  sessionAddPick: "\u52A0\u5165\u8FD9\u4E2A\u76EE\u5F55",
255
256
  sessionBrowse: "\u6D4F\u89C8\u2026",
@@ -257,9 +258,9 @@ var OVER_SSH_DICTS = {
257
258
  sessionRootsTitle: "\u672C\u4F1A\u8BDD\u53EF\u7528\u7684\u76EE\u5F55",
258
259
  sessionRootsEmpty: "\u8FD8\u6CA1\u6709\u6DFB\u52A0\u76EE\u5F55\u3002\u70B9\u300C\u6D4F\u89C8\u2026\u300D\u9009\u4E00\u4E2A\u672C\u5730\u6216\u8FDC\u7AEF\u76EE\u5F55\u3002",
259
260
  sessionRootsLoading: "\u6B63\u5728\u8BFB\u53D6\u8FD9\u4E2A\u4F1A\u8BDD\u7684\u76EE\u5F55\u2026",
260
- sessionRootsHint: "\u672C\u5730\u76EE\u5F55\u76F4\u63A5\u8BFB\u5199\uFF1B\u8FDC\u7AEF\u76EE\u5F55\u7ECF SSH \u4EE3\u7406\u8BBF\u95EE\u3002\u7B2C\u4E00\u884C\u662F\u5F53\u524D\u5DE5\u4F5C\u533A\uFF08\u59CB\u7EC8\u53EF\u7528\u3001\u4E0D\u53EF\u5220\u9664\uFF09\uFF1B\u53EA\u6709\u4F60\u81EA\u5DF1\u52A0\u7684\u76EE\u5F55\u80FD\u5220\u9664\u3002\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u5728\u8FD9\u91CC\u2014\u2014\u8981\u7528\u5B83\uFF0C\u5148\u5728\u4F1A\u8BDD\u91CC\u6DFB\u52A0\u3002",
261
+ sessionRootsHint: "\u672C\u5730\u76F4\u63A5\u8BFB\u5199\uFF0C\u8FDC\u7AEF\u7ECF SSH \u4EE3\u7406\u3002\u53EA\u6709\u4F60\u6DFB\u52A0\u7684\u76EE\u5F55\u53EF\u5220\u9664\uFF1B\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u767B\u8BB0\u7684\u6839\u8981\u5148\u5728\u8FD9\u91CC\u6DFB\u52A0\u3002",
261
262
  sessionRootsWorkspaceBadge: "\u5DE5\u4F5C\u533A",
262
- sessionRootsWorkspaceNote: "\u4F1A\u8BDD\u7684\u9ED8\u8BA4\u8FB9\u754C\uFF1Bworkspace-write \u4E0B\u4E0E\u5DF2\u6DFB\u52A0\u76EE\u5F55\u4E00\u6837\u53EF\u5199\uFF0Cread-only \u4E0B\u90FD\u4E0D\u53EF\u5199",
263
+ sessionRootsWorkspaceNote: "\u9ED8\u8BA4\u8FB9\u754C\uFF0C\u4E0D\u53EF\u5220\u9664\uFF08workspace-write \u4E0B\u540C\u5DF2\u6DFB\u52A0\u76EE\u5F55\u4E00\u6837\u53EF\u5199\uFF09",
263
264
  sessionRootsFailed: "\u8BFB\u53D6\u76EE\u5F55\u5217\u8868\u5931\u8D25\uFF1A{reason}",
264
265
  sessionAddDone: "\u5DF2\u52A0\u5165\uFF1A{path}\uFF08\u672A\u65B0\u5EFA\u5DE5\u4F5C\u533A\uFF09",
265
266
  sessionAddFailed: "\u52A0\u5165\u5931\u8D25\uFF1A{reason}",
@@ -343,6 +344,8 @@ var OVER_SSH_DICTS = {
343
344
  sessionPanelDeletePermanent: "Delete permanently\u2026",
344
345
  sessionPanelRestore: "Restore",
345
346
  sessionPanelPurge: "Purge\u2026",
347
+ sessionPanelRowRecycle: "Bin",
348
+ sessionPanelSelectedCount: "{count} selected",
346
349
  sessionPanelSweep: "Apply retention policy",
347
350
  sessionPanelRefresh: "Refresh",
348
351
  sessionPanelClose: "Close",
@@ -496,8 +499,7 @@ var OVER_SSH_DICTS = {
496
499
  browseChoose: "Use this directory",
497
500
  sessionAddOpen: "Add a directory",
498
501
  sessionAddTitle: "Add a directory to the current workspace",
499
- sessionAddHint: "The chosen directory is added to this session (no workspace is created): this session's file tools and @-completion can use it right away.",
500
- sessionAddWorkspace: "Current workspace: {path}",
502
+ sessionAddHint: "Added to this session: its file tools and @-completion work immediately; no new workspace is created.",
501
503
  sessionAddWorkspaceUnknown: "The current workspace path is unavailable, so the directory cannot be added to this session (when the composition provides neither the session nor the workspace service, this entry cannot work at all).",
502
504
  sessionAddPick: "Add this directory",
503
505
  sessionBrowse: "Browse\u2026",
@@ -700,6 +702,8 @@ var CLASS = {
700
702
  group: "dsh-rw-group",
701
703
  /** A workspace section's heading line (label + count). */
702
704
  groupHead: "dsh-rw-group-head",
705
+ /** The workspace directory, inline in the group head: one line, ellipsized, full path in `title`. */
706
+ groupPath: "dsh-rw-group-path",
703
707
  /**
704
708
  * The inventory's action bar (select all / clear / delete). Sticky: the list is long, and a bar that
705
709
  * scrolls away forces the user to scroll back up for every action.
@@ -951,6 +955,7 @@ function controlStylesheet() {
951
955
  `.${CLASS.group} { display: flex; flex-direction: column; gap: ${SPACE.xs}; box-sizing: border-box; min-width: 0; max-width: 100%; padding: ${SPACE.sm}; background: ${TOKENS.bgLayer2}; border: 1px solid ${TOKENS.border}; border-radius: ${TOKENS.radiusMd}; }`,
952
956
  /* The heading is separated from the rows it labels, so a long list has a visible rhythm. */
953
957
  `.${CLASS.groupHead} { display: flex; flex-wrap: wrap; align-items: baseline; gap: ${SPACE.xs}; min-width: 0; padding-bottom: ${SPACE.hair}; border-bottom: 1px solid ${TOKENS.border}; }`,
958
+ `.${CLASS.groupPath} { flex: 1 1 12rem; min-width: 0; font-size: 0.75rem; line-height: 1.5; color: ${TOKENS.textTertiary}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }`,
954
959
  /**
955
960
  * The session inventory's action bar, sticky against **whatever scrolls it**.
956
961
  *
@@ -3982,7 +3987,8 @@ function formatStamp(ms, now = Date.now()) {
3982
3987
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
3983
3988
  }
3984
3989
  function shortId(id) {
3985
- return id.length <= 8 ? id : id.slice(0, 8);
3990
+ const body = id.startsWith("session-") ? id.slice("session-".length) : id;
3991
+ return body.length <= 8 ? body : body.slice(0, 8);
3986
3992
  }
3987
3993
  function parseSessionSnapshot(value) {
3988
3994
  if (typeof value !== "object" || value === null) return emptySnapshot();
@@ -4163,7 +4169,7 @@ function badgeFor(row, copy) {
4163
4169
  return void 0;
4164
4170
  }
4165
4171
  function sessionRowTemplate(props) {
4166
- const { row, copy, checked, onToggle, pickIdPrefix } = props;
4172
+ const { row, copy, checked, onToggle, pickIdPrefix, onRecycle } = props;
4167
4173
  const badge = badgeFor(row, copy);
4168
4174
  const stamp = formatStamp(row.updatedAtMs);
4169
4175
  return (0, import_react7.createElement)(
@@ -4205,7 +4211,15 @@ function sessionRowTemplate(props) {
4205
4211
  (0, import_react7.createElement)("span", void 0, formatBytes(row.bytes))
4206
4212
  )
4207
4213
  )
4208
- )
4214
+ ),
4215
+ // 便捷手段:勾选 + 顶部批量按钮对"只想删一条"的场景太重。回收站是**可恢复**的,所以这里不弹确认,
4216
+ // 直接移入回收站(永久删除仍然只在批量流程里,且带确认)。
4217
+ row.deletable && onRecycle !== void 0 ? Button({
4218
+ variant: "ghost",
4219
+ size: "compact",
4220
+ onClick: () => onRecycle(row.id),
4221
+ children: copy.rowRecycle
4222
+ }) : null
4209
4223
  );
4210
4224
  }
4211
4225
  var SessionRow = (0, import_react7.memo)(function SessionRow2(props) {
@@ -4260,6 +4274,8 @@ function renderWorkspaceGroup(props) {
4260
4274
  { className: CLASS.groupHead },
4261
4275
  (0, import_react7.createElement)("span", { className: CLASS.rowTitle }, group.label === "" ? copy.workspaceUnknown : group.label),
4262
4276
  (0, import_react7.createElement)("span", { className: CLASS.badge }, String(group.sessions.length)),
4277
+ // 目录内联在组头里(可省略号截断,完整路径留在 title),不再让组头下面多出整整一行只放路径。
4278
+ group.cwd === void 0 ? null : (0, import_react7.createElement)("span", { className: CLASS.groupPath, title: group.cwd }, group.cwd),
4263
4279
  // A workspace with no directory (the platform's no-cwd bucket) has nothing to scope a delete to, so it
4264
4280
  // gets no button rather than a button that guesses.
4265
4281
  remove === void 0 ? null : Button({
@@ -4270,7 +4286,6 @@ function renderWorkspaceGroup(props) {
4270
4286
  children: copy.deleteWorkspace
4271
4287
  })
4272
4288
  ),
4273
- group.cwd === void 0 ? null : (0, import_react7.createElement)("p", { className: CLASS.hint, title: group.cwd }, group.cwd),
4274
4289
  (0, import_react7.createElement)(
4275
4290
  "ul",
4276
4291
  { className: CLASS.list },
@@ -4283,6 +4298,7 @@ function renderWorkspaceGroup(props) {
4283
4298
  copy,
4284
4299
  checked: selectedSet.has(row.id),
4285
4300
  onToggle: props.onToggle,
4301
+ ...props.onRecycle === void 0 ? {} : { onRecycle: props.onRecycle },
4286
4302
  pickIdPrefix: props.pickIdPrefix
4287
4303
  })
4288
4304
  )
@@ -4486,6 +4502,7 @@ function SessionPanel({ copy, io, close }) {
4486
4502
  children: copy.selectAll
4487
4503
  }),
4488
4504
  Button({ variant: "ghost", size: "compact", onClick: () => setSelected([]), children: copy.clearSelection }),
4505
+ deletable.length === 0 ? null : (0, import_react7.createElement)("span", { className: CLASS.rowMeta }, copy.selectedCount(deletable.length)),
4489
4506
  Button({
4490
4507
  variant: "primary",
4491
4508
  size: "compact",
@@ -4511,6 +4528,7 @@ function SessionPanel({ copy, io, close }) {
4511
4528
  selected,
4512
4529
  busy,
4513
4530
  onToggle: toggle,
4531
+ onRecycle: (id) => void run(() => deleteSessions(io, [id])),
4514
4532
  onRemove: setPending,
4515
4533
  pickIdPrefix
4516
4534
  })
@@ -5617,7 +5635,15 @@ function createSessionDirectoryIo(options) {
5617
5635
  }
5618
5636
  const all = result.value.roots;
5619
5637
  if (sessionId !== void 0 && sessionId !== "" && result.value.scopedForSession === sessionId) {
5620
- return { ok: true, value: all };
5638
+ if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: all };
5639
+ return {
5640
+ ok: true,
5641
+ value: all.filter((row) => {
5642
+ const sessions = row.sessions ?? [];
5643
+ if (sessions.length > 0) return true;
5644
+ return (row.workspaces ?? []).some((candidate) => sameWorkspaceSpelling(candidate, workspacePath));
5645
+ })
5646
+ };
5621
5647
  }
5622
5648
  if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
5623
5649
  return {
@@ -5625,7 +5651,7 @@ function createSessionDirectoryIo(options) {
5625
5651
  value: all.filter((row) => {
5626
5652
  const sessions = row.sessions ?? [];
5627
5653
  if (sessions.length > 0 && sessionId !== void 0 && sessionId !== "") return sessions.includes(sessionId);
5628
- return row.workspaces?.includes(workspacePath) === true;
5654
+ return (row.workspaces ?? []).some((candidate) => sameWorkspaceSpelling(candidate, workspacePath));
5629
5655
  })
5630
5656
  };
5631
5657
  };
@@ -5682,16 +5708,26 @@ function sessionDirectoryReducer(state, action) {
5682
5708
  const next = action.path === void 0 || action.path === "" ? void 0 : action.path;
5683
5709
  if (state.workspacePath === next) return state;
5684
5710
  const base = withoutOptional2(state);
5711
+ const switched = state.workspacePath !== void 0 && next !== void 0 && state.workspacePath !== next;
5712
+ const cleared = {
5713
+ ...base,
5714
+ roots: [],
5715
+ // The card/browser state is also entry-shared. A move between two real workspaces must not carry
5716
+ // the previous card's open/browse state into the new one; a late initial seat (undefined -> value)
5717
+ // must NOT close a card the user already opened.
5718
+ ...switched ? { open: false, browsing: false } : {}
5719
+ };
5685
5720
  if (next === void 0) {
5686
- const { workspacePath: _cleared, ...rest } = base;
5721
+ const { workspacePath: _cleared, ...rest } = cleared;
5687
5722
  return rest;
5688
5723
  }
5689
- return { ...base, workspacePath: next };
5724
+ return { ...cleared, workspacePath: next };
5690
5725
  }
5691
5726
  case "session-known": {
5692
5727
  const next = action.sessionId === void 0 || action.sessionId === "" ? void 0 : action.sessionId;
5693
5728
  if (state.sessionId === next) return state;
5694
- const base = withoutOptional2(state);
5729
+ const switched = state.sessionId !== void 0 && next !== void 0 && state.sessionId !== next;
5730
+ const base = { ...withoutOptional2(state), roots: [], ...switched ? { open: false, browsing: false } : {} };
5695
5731
  if (next === void 0) {
5696
5732
  const { sessionId: _cleared, ...rest } = base;
5697
5733
  return rest;
@@ -5736,15 +5772,23 @@ function sessionDirectoryReducer(state, action) {
5736
5772
  };
5737
5773
  }
5738
5774
  }
5775
+ function sameWorkspaceSpelling(left, right) {
5776
+ const fold = (value) => value.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "");
5777
+ const a = fold(left);
5778
+ const b = fold(right);
5779
+ if (a === b) return true;
5780
+ return /^[A-Za-z]:\//.test(a) && /^[A-Za-z]:\//.test(b) && a.toLowerCase() === b.toLowerCase();
5781
+ }
5739
5782
  function selectSessionDirectoryView(state, copy) {
5740
5783
  const view = {
5741
5784
  visible: state.open,
5742
5785
  pending: state.pending,
5743
5786
  notice: resolveMessage2(copy, state.notice),
5744
- workspaceLine: state.workspacePath === void 0 ? copy["sessionAddWorkspaceUnknown"] : resolveMessage2(copy, { key: "sessionAddWorkspace", params: { path: state.workspacePath } }),
5745
5787
  ...state.workspacePath === void 0 ? {} : { workspacePath: state.workspacePath },
5746
5788
  ...state.added === void 0 ? {} : { added: state.added },
5747
5789
  // 工作区永远排在第一行:它是默认边界,`workspace-write` 下与已添加目录一样可写(ADR-0016)。
5790
+ // Filter again at render time: the controller is entry-scoped and shared by every session card, so a
5791
+ // stale `roots` snapshot from the previous session must not paint a row scoped to another workspace.
5748
5792
  roots: [
5749
5793
  ...state.workspacePath === void 0 ? [] : [{
5750
5794
  key: `workspace:${state.workspacePath}`,
@@ -5754,7 +5798,9 @@ function selectSessionDirectoryView(state, copy) {
5754
5798
  removable: false,
5755
5799
  workspace: true
5756
5800
  }],
5757
- ...state.roots.map((row) => ({
5801
+ ...state.roots.filter(
5802
+ (row) => row.workspaces === void 0 || row.workspaces.length === 0 || state.workspacePath === void 0 || state.workspacePath === "" || row.workspaces.some((candidate) => sameWorkspaceSpelling(candidate, state.workspacePath))
5803
+ ).map((row) => ({
5758
5804
  key: row.key,
5759
5805
  // A label is what the user called it; without one the path is the only honest title. A remote path alone
5760
5806
  // would hide which host it is on, so the connection name is part of the title there.
@@ -5916,22 +5962,13 @@ function SessionDirectorySection({
5916
5962
  },
5917
5963
  (0, import_react11.createElement)("h3", { id: titleId, className: CLASS.sectionTitle }, copy.sessionAddTitle),
5918
5964
  (0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionAddHint),
5919
- // Which workspace this lands in, or - when the framework seats could not answer - the fallback the
5920
- // copy states explicitly. Both are one line, so a narrow panel never has to truncate it.
5921
- (0, import_react11.createElement)("p", { className: CLASS.browserPath }, view.workspaceLine),
5965
+ // 工作区路径只出现一次:列表第一行就是它(带「工作区」徽标)。此前这里还有一行“当前工作区:…”,
5966
+ // 与第一行完全重复;拿不到路径时仍然要明说,否则用户不知道为什么会话加不了目录。
5967
+ view.workspacePath === void 0 ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy["sessionAddWorkspaceUnknown"]) : null,
5922
5968
  // The card no longer *is* a browser: it lists what this session has and offers one way in. The picker is a
5923
5969
  // step behind 「浏览…」, which also means the card no longer opens with a full directory listing (that was
5924
5970
  // the complaint: the panel showed the current directory's contents before the user asked for anything).
5925
- view.browsing ? (0, import_react11.createElement)(
5926
- "div",
5927
- { className: CLASS.block },
5928
- browser,
5929
- (0, import_react11.createElement)(
5930
- "div",
5931
- { className: CLASS.actions },
5932
- Button({ variant: "ghost", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowseBack })
5933
- )
5934
- ) : (0, import_react11.createElement)(
5971
+ view.browsing ? (0, import_react11.createElement)("div", { className: CLASS.block }, browser) : (0, import_react11.createElement)(
5935
5972
  "div",
5936
5973
  { className: CLASS.block },
5937
5974
  (0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
@@ -5970,17 +6007,18 @@ function SessionDirectorySection({
5970
6007
  )
5971
6008
  ),
5972
6009
  // 列表里只剩工作区行时,"还没有添加目录"仍然是有用的下一步提示;但它不再替换列表。
5973
- view.roots.length > 0 && view.roots.filter((row) => row.workspace !== true).length === 0 && !view.rootsReading && view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null,
5974
- (0, import_react11.createElement)(
5975
- "div",
5976
- { className: CLASS.actions },
5977
- Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowse })
5978
- )
6010
+ view.roots.length > 0 && view.roots.filter((row) => row.workspace !== true).length === 0 && !view.rootsReading && view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null
5979
6011
  ),
5980
6012
  view.added === void 0 ? null : Notice({ role: "status", tone: "info", children: resolveMessage2(copy, { key: "sessionAddDone", params: { path: view.added } }) }),
5981
6013
  view.pending ? Notice({ role: "status", tone: "info", children: copy.flowPending }) : null,
5982
6014
  view.notice === "" ? null : Notice({ role: "alert", tone: "error", children: view.notice }),
5983
- (0, import_react11.createElement)("div", { className: CLASS.actions }, Button({ variant: "secondary", size: "compact", onClick: actions.onClose, children: copy.close }))
6015
+ // 主操作与关闭收在底部同一行:此前「浏览…」长在列表块里、关闭单独一行,两个按钮上下分离。
6016
+ (0, import_react11.createElement)(
6017
+ "div",
6018
+ { className: CLASS.actions },
6019
+ view.browsing ? Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowseBack }) : Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowse }),
6020
+ Button({ variant: "secondary", size: "compact", onClick: actions.onClose, children: copy.close })
6021
+ )
5984
6022
  );
5985
6023
  return (0, import_react11.createElement)(
5986
6024
  "div",
@@ -6701,6 +6739,8 @@ function apply(ctx) {
6701
6739
  deletePermanent: dict.sessionPanelDeletePermanent,
6702
6740
  restore: dict.sessionPanelRestore,
6703
6741
  purge: dict.sessionPanelPurge,
6742
+ rowRecycle: dict.sessionPanelRowRecycle,
6743
+ selectedCount: (count) => resolveMessage2(dict, { key: "sessionPanelSelectedCount", params: { count } }),
6704
6744
  sweep: dict.sessionPanelSweep,
6705
6745
  refresh: dict.sessionPanelRefresh,
6706
6746
  close: dict.sessionPanelClose,
package/lib/fs.js CHANGED
@@ -787,7 +787,8 @@ function wireGitStatus(ctx) {
787
787
  if (connections === void 0 || transports === void 0) return void 0;
788
788
  const profile = connections.get(connectionId);
789
789
  if (profile === void 0) return void 0;
790
- const acquired = await transports.acquire(profile);
790
+ const lanes = ctx.get("sshDevTransportLanes");
791
+ const acquired = lanes === void 0 ? await transports.acquire(profile) : await lanes.acquire(profile, "compute");
791
792
  return acquired.transport;
792
793
  },
793
794
  resolveRoot: (connectionId, path) => {