dsh-over-ssh 0.2.2 → 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.
Files changed (34) hide show
  1. package/README.md +82 -4
  2. package/agents/agent-manifest.json +7 -7
  3. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  4. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  5. package/cordis.patch.yml +10 -12
  6. package/lib/client.js +161 -60
  7. package/lib/file-references.js +136 -13
  8. package/lib/fs.js +2 -1
  9. package/lib/index.js +1245 -165
  10. package/lib/types/src/config.d.ts +44 -0
  11. package/lib/types/src/host/app/backend.d.ts +31 -0
  12. package/lib/types/src/host/app/routed-fs.d.ts +238 -0
  13. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +17 -0
  14. package/lib/types/src/host/app/transport/transport-service.d.ts +66 -11
  15. package/lib/types/src/host/domain/capability.d.ts +6 -0
  16. package/lib/types/src/host/domain/roots/route-table.d.ts +101 -0
  17. package/lib/types/src/host/domain/session/session-admin.d.ts +8 -0
  18. package/lib/types/src/host/infra/backend/poll-watch.d.ts +119 -0
  19. package/lib/types/src/host/infra/backend/remote-backend.d.ts +297 -0
  20. package/lib/types/src/host/infra/cache/budget.d.ts +68 -0
  21. package/lib/types/src/host/infra/cache/versioned.d.ts +98 -0
  22. package/lib/types/src/host/infra/transport/ssh/askpass.d.ts +132 -0
  23. package/lib/types/src/host/infra/transport/transport.d.ts +48 -0
  24. package/lib/types/src/host/integration/agent/askpass-registry.d.ts +65 -0
  25. package/lib/types/src/host/integration/fs/remote-index.d.ts +183 -0
  26. package/lib/types/src/host/integration/roots/remote-home.d.ts +68 -0
  27. package/lib/types/src/host/integration/roots/root-registry.d.ts +148 -0
  28. package/lib/types/src/index.d.ts +7 -0
  29. package/lib/types/src/local-bridge.d.ts +4 -4
  30. package/lib/types/src/wiring/connections.d.ts +261 -0
  31. package/lib/types/src/wiring/install-memo.d.ts +31 -0
  32. package/package.json +1 -5
  33. package/lib/local-bridge-refs.js +0 -16
  34. package/lib/types/src/local-bridge-refs.d.ts +0 -25
package/lib/client.js CHANGED
@@ -82,12 +82,16 @@ 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",
88
90
  sessionPanelDeletePermanent: "\u6C38\u4E45\u5220\u9664\u2026",
89
91
  sessionPanelRestore: "\u8FD8\u539F",
90
92
  sessionPanelPurge: "\u6C38\u4E45\u6E05\u9664\u2026",
93
+ sessionPanelRowRecycle: "\u56DE\u6536\u7AD9",
94
+ sessionPanelSelectedCount: "\u5DF2\u9009 {count} \u4E2A",
91
95
  sessionPanelSweep: "\u6309\u4FDD\u7559\u7B56\u7565\u6E05\u7406",
92
96
  sessionPanelRefresh: "\u5237\u65B0",
93
97
  sessionPanelClose: "\u5173\u95ED",
@@ -100,7 +104,7 @@ var OVER_SSH_DICTS = {
100
104
  sessionPanelConfirmPurgeBody: "\u5C06\u6C38\u4E45\u6E05\u9664\u56DE\u6536\u7AD9\u6761\u76EE {targets}\uFF0C\u4E0D\u53EF\u6062\u590D\u3002",
101
105
  sessionPanelConfirmCancel: "\u53D6\u6D88",
102
106
  sessionPanelConfirmAccept: "\u786E\u8BA4\u6C38\u4E45\u5220\u9664",
103
- sessionPanelDeleteWorkspace: "\u5220\u9664\u6B64\u5DE5\u4F5C\u533A\u7684\u4F1A\u8BDD\u2026",
107
+ sessionPanelDeleteWorkspace: "\u5220\u9664\u672C\u5DE5\u4F5C\u533A\u2026",
104
108
  // 必须是**模板字符串**而不是函数:字典的契约是 Record<OverSshKey, string>,渲染统一走
105
109
  // resolveMessage 的 `template.replace(...)`。写成函数会让面板在渲染时抛
106
110
  // TypeError: template.replace is not a function,整块白屏。
@@ -159,6 +163,7 @@ var OVER_SSH_DICTS = {
159
163
  rootsErrorIdRequired: "\u8BF7\u586B\u5199\u8FDE\u63A5 id\u3002",
160
164
  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
165
  rootsErrorIdDuplicate: "\u8FD9\u4E2A\u8FDE\u63A5 id \u5DF2\u7ECF\u5B58\u5728\u3002",
166
+ 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
167
  rootsErrorHostRequired: "\u8BF7\u586B\u5199\u4E3B\u673A\u540D\u3002",
163
168
  rootsErrorUserRequired: "\u8BF7\u586B\u5199\u7528\u6237\u540D\u3002",
164
169
  rootsErrorPort: "\u7AEF\u53E3\u5FC5\u987B\u662F 1 \u5230 65535 \u4E4B\u95F4\u7684\u6574\u6570\u3002",
@@ -245,8 +250,7 @@ var OVER_SSH_DICTS = {
245
250
  browseChoose: "\u7528\u8FD9\u4E2A\u76EE\u5F55",
246
251
  sessionAddOpen: "\u6DFB\u52A0\u76EE\u5F55",
247
252
  sessionAddTitle: "\u6DFB\u52A0\u76EE\u5F55\u5230\u5F53\u524D\u5DE5\u4F5C\u533A",
248
- 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",
249
- 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",
250
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",
251
255
  sessionAddPick: "\u52A0\u5165\u8FD9\u4E2A\u76EE\u5F55",
252
256
  sessionBrowse: "\u6D4F\u89C8\u2026",
@@ -254,9 +258,9 @@ var OVER_SSH_DICTS = {
254
258
  sessionRootsTitle: "\u672C\u4F1A\u8BDD\u53EF\u7528\u7684\u76EE\u5F55",
255
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",
256
260
  sessionRootsLoading: "\u6B63\u5728\u8BFB\u53D6\u8FD9\u4E2A\u4F1A\u8BDD\u7684\u76EE\u5F55\u2026",
257
- 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",
258
262
  sessionRootsWorkspaceBadge: "\u5DE5\u4F5C\u533A",
259
- 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",
260
264
  sessionRootsFailed: "\u8BFB\u53D6\u76EE\u5F55\u5217\u8868\u5931\u8D25\uFF1A{reason}",
261
265
  sessionAddDone: "\u5DF2\u52A0\u5165\uFF1A{path}\uFF08\u672A\u65B0\u5EFA\u5DE5\u4F5C\u533A\uFF09",
262
266
  sessionAddFailed: "\u52A0\u5165\u5931\u8D25\uFF1A{reason}",
@@ -276,6 +280,9 @@ var OVER_SSH_DICTS = {
276
280
  sessionResultWorkspaceRecycled: "\u5DF2\u628A\u8BE5\u5DE5\u4F5C\u533A {count} \u4E2A\u4F1A\u8BDD\u79FB\u5165\u56DE\u6536\u7AD9\uFF0C{size}\uFF08\u53EF\u6062\u590D\uFF09",
277
281
  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
282
  sessionResultRestored: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD",
283
+ sessionResultRestoredWithSubsessions: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF0C\u5E76\u8FDE\u5E26\u8FD8\u539F {count} \u4E2A\u5B50\u4F1A\u8BDD",
284
+ sessionResultRestoredPartial: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF0C\u53E6\u6709 {failed} \u4E2A\u5B50\u4F1A\u8BDD\u4ECD\u7559\u5728\u56DE\u6536\u7AD9",
285
+ sessionResultRestoredParentBinned: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF08\u5176\u7236\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9\uFF09",
279
286
  sessionResultPurged: "\u5DF2\u6C38\u4E45\u6E05\u9664\u8BE5\u6761\u76EE",
280
287
  sessionResultSweepNone: "\u6CA1\u6709\u9700\u8981\u6E05\u7406\u7684\u6761\u76EE",
281
288
  sessionResultSweepDone: "\u5DF2\u6E05\u7406 {count} \u4E2A\u6761\u76EE",
@@ -329,12 +336,16 @@ var OVER_SSH_DICTS = {
329
336
  sessionPanelSessions: "Sessions",
330
337
  sessionPanelBin: "Recycle bin",
331
338
  sessionPanelBinEmpty: "The recycle bin is empty.",
339
+ sessionPanelBinUnreadable: "{count} more bin entr(y/ies) cannot be shown (no session id); restart the host or check the bin index.",
340
+ sessionPanelBinRowUnaddressable: "This entry has no id, so it cannot be restored or purged",
332
341
  sessionPanelSelectAll: "Select deletable",
333
342
  sessionPanelClear: "Clear selection",
334
343
  sessionPanelDeleteRecycle: "Delete to bin",
335
344
  sessionPanelDeletePermanent: "Delete permanently\u2026",
336
345
  sessionPanelRestore: "Restore",
337
346
  sessionPanelPurge: "Purge\u2026",
347
+ sessionPanelRowRecycle: "Bin",
348
+ sessionPanelSelectedCount: "{count} selected",
338
349
  sessionPanelSweep: "Apply retention policy",
339
350
  sessionPanelRefresh: "Refresh",
340
351
  sessionPanelClose: "Close",
@@ -401,6 +412,7 @@ var OVER_SSH_DICTS = {
401
412
  rootsErrorIdRequired: "Enter a connection id.",
402
413
  rootsErrorIdInvalid: "A connection id may contain letters, digits, dots, underscores and dashes, and must start with a letter or a digit.",
403
414
  rootsErrorIdDuplicate: "That connection id already exists.",
415
+ rootsErrorConnectionMissing: "That connection has been deleted; close the editor and refresh.",
404
416
  rootsErrorHostRequired: "Enter a host name.",
405
417
  rootsErrorUserRequired: "Enter a user name.",
406
418
  rootsErrorPort: "The port must be an integer between 1 and 65535.",
@@ -487,8 +499,7 @@ var OVER_SSH_DICTS = {
487
499
  browseChoose: "Use this directory",
488
500
  sessionAddOpen: "Add a directory",
489
501
  sessionAddTitle: "Add a directory to the current workspace",
490
- 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.",
491
- sessionAddWorkspace: "Current workspace: {path}",
502
+ sessionAddHint: "Added to this session: its file tools and @-completion work immediately; no new workspace is created.",
492
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).",
493
504
  sessionAddPick: "Add this directory",
494
505
  sessionBrowse: "Browse\u2026",
@@ -518,6 +529,9 @@ var OVER_SSH_DICTS = {
518
529
  sessionResultWorkspaceRecycled: "Moved {count} sessions of that workspace to the recycle bin, {size} (recoverable)",
519
530
  sessionResultWorkspaceRecycledRefused: "Moved {count} sessions of that workspace to the recycle bin, {size} (recoverable; {refused} refused)",
520
531
  sessionResultRestored: "Restored that session",
532
+ sessionResultRestoredWithSubsessions: "Restored that session and {count} sub-session(s)",
533
+ sessionResultRestoredPartial: "Restored that session; {failed} sub-session(s) stayed in the bin",
534
+ sessionResultRestoredParentBinned: "Restored that session (its parent is still in the bin)",
521
535
  sessionResultPurged: "Permanently removed that entry",
522
536
  sessionResultSweepNone: "Nothing to sweep",
523
537
  sessionResultSweepDone: "Swept {count} entries",
@@ -688,6 +702,8 @@ var CLASS = {
688
702
  group: "dsh-rw-group",
689
703
  /** A workspace section's heading line (label + count). */
690
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",
691
707
  /**
692
708
  * The inventory's action bar (select all / clear / delete). Sticky: the list is long, and a bar that
693
709
  * scrolls away forces the user to scroll back up for every action.
@@ -939,6 +955,7 @@ function controlStylesheet() {
939
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}; }`,
940
956
  /* The heading is separated from the rows it labels, so a long list has a visible rhythm. */
941
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; }`,
942
959
  /**
943
960
  * The session inventory's action bar, sticky against **whatever scrolls it**.
944
961
  *
@@ -1438,15 +1455,29 @@ function createCardPortal(options) {
1438
1455
  };
1439
1456
  }
1440
1457
  function focusFirstControl(container) {
1458
+ const all = container?.querySelectorAll?.(CARD_FOCUS_SELECTOR);
1459
+ if (all !== void 0 && all !== null) {
1460
+ for (const candidate of Array.from(all)) {
1461
+ if (!isVisibleControl(candidate)) continue;
1462
+ candidate.focus?.();
1463
+ return true;
1464
+ }
1465
+ return false;
1466
+ }
1441
1467
  const target = container?.querySelector?.(CARD_FOCUS_SELECTOR) ?? void 0;
1442
1468
  if (target === void 0 || typeof target.focus !== "function") return false;
1443
1469
  target.focus();
1444
1470
  return true;
1445
1471
  }
1472
+ function isVisibleControl(element) {
1473
+ const measurable = element.getClientRects;
1474
+ if (typeof measurable === "function") return measurable.call(element).length > 0;
1475
+ return element.hidden !== true;
1476
+ }
1446
1477
  function focusableWithin(container) {
1447
1478
  const found = container?.querySelectorAll?.(CARD_FOCUS_SELECTOR);
1448
1479
  if (found === null || found === void 0) return [];
1449
- return Array.from(found);
1480
+ return Array.from(found).filter(isVisibleControl);
1450
1481
  }
1451
1482
  function trapTab(event, container, active) {
1452
1483
  const focusables = focusableWithin(container);
@@ -2726,6 +2757,11 @@ function createRootsPanelController(options) {
2726
2757
  const editingId = state.editingId;
2727
2758
  if (editingId === void 0) return;
2728
2759
  if (!canEdit(state)) return;
2760
+ if (!(state.snapshot?.connections ?? []).some((row) => row.id === editingId)) {
2761
+ dispatch({ type: "edit-ended" });
2762
+ dispatch({ type: "refused", scope: "edit", message: { key: "rootsErrorConnectionMissing" } });
2763
+ return;
2764
+ }
2729
2765
  const editDrafts = state.editDrafts;
2730
2766
  const portText = editDrafts.port.trim();
2731
2767
  const port = portText === "" ? void 0 : Number(portText);
@@ -3869,10 +3905,10 @@ function toSessionRowView(raw) {
3869
3905
  };
3870
3906
  }
3871
3907
  function toBinRowView(raw) {
3872
- const entryId = raw["entryId"];
3908
+ const rawEntryId = raw["entryId"];
3873
3909
  const sessionId = raw["sessionId"];
3874
- if (typeof entryId !== "string" || entryId === "") return void 0;
3875
3910
  if (typeof sessionId !== "string" || sessionId === "") return void 0;
3911
+ const entryId = typeof rawEntryId === "string" ? rawEntryId : "";
3876
3912
  return {
3877
3913
  entryId,
3878
3914
  sessionId,
@@ -3887,6 +3923,8 @@ function emptySnapshot() {
3887
3923
  available: false,
3888
3924
  sessions: [],
3889
3925
  bin: [],
3926
+ binUnaddressable: 0,
3927
+ binUnreadable: 0,
3890
3928
  policy: { maxEntries: 0, maxBytes: 0, maxAgeMs: 0 },
3891
3929
  totals: { sessions: 0, bytes: 0, subsessions: 0, live: 0, running: 0 }
3892
3930
  };
@@ -3949,7 +3987,8 @@ function formatStamp(ms, now = Date.now()) {
3949
3987
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
3950
3988
  }
3951
3989
  function shortId(id) {
3952
- 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);
3953
3992
  }
3954
3993
  function parseSessionSnapshot(value) {
3955
3994
  if (typeof value !== "object" || value === null) return emptySnapshot();
@@ -3958,13 +3997,28 @@ function parseSessionSnapshot(value) {
3958
3997
  const available = body["available"] === true;
3959
3998
  const sessions = Array.isArray(body["sessions"]) ? body["sessions"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toSessionRowView(raw)].filter((row) => row !== void 0) : []) : [];
3960
3999
  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) : []) : [];
4000
+ let binUnreadable = 0;
4001
+ const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => {
4002
+ if (typeof raw !== "object" || raw === null) {
4003
+ binUnreadable += 1;
4004
+ return [];
4005
+ }
4006
+ const row = toBinRowView(raw);
4007
+ if (row === void 0) {
4008
+ binUnreadable += 1;
4009
+ return [];
4010
+ }
4011
+ return [row];
4012
+ }) : [];
4013
+ const binUnaddressable = bin.filter((row) => row.entryId === "").length;
3962
4014
  const rawPolicy = typeof body["policy"] === "object" && body["policy"] !== null ? body["policy"] : {};
3963
4015
  const numberOr = (key) => typeof rawPolicy[key] === "number" && Number.isFinite(rawPolicy[key]) ? rawPolicy[key] : 0;
3964
4016
  return {
3965
4017
  available,
3966
4018
  sessions: unblocked,
3967
4019
  bin,
4020
+ binUnaddressable,
4021
+ binUnreadable,
3968
4022
  policy: { maxEntries: numberOr("maxEntries"), maxBytes: numberOr("maxBytes"), maxAgeMs: numberOr("maxAgeMs") },
3969
4023
  totals: {
3970
4024
  sessions: unblocked.length,
@@ -4049,7 +4103,14 @@ async function deleteWorkspaceSessions(io, cwd, options = {}) {
4049
4103
  }
4050
4104
  async function restoreSession(io, entryId) {
4051
4105
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
4052
- return postAction(io, { action: "restore", entryId }, () => ({ key: "sessionResultRestored" }));
4106
+ return postAction(io, { action: "restore", entryId }, (value) => {
4107
+ const failed = typeof value["failedSubsessions"] === "number" ? value["failedSubsessions"] : 0;
4108
+ const restored = typeof value["restoredSubsessions"] === "number" ? value["restoredSubsessions"] : 0;
4109
+ if (failed > 0) return { key: "sessionResultRestoredPartial", params: { failed } };
4110
+ if (restored > 0) return { key: "sessionResultRestoredWithSubsessions", params: { count: restored } };
4111
+ if (value["parentStillBinned"] === true) return { key: "sessionResultRestoredParentBinned" };
4112
+ return { key: "sessionResultRestored" };
4113
+ });
4053
4114
  }
4054
4115
  async function purgeSession(io, entryId) {
4055
4116
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
@@ -4108,7 +4169,7 @@ function badgeFor(row, copy) {
4108
4169
  return void 0;
4109
4170
  }
4110
4171
  function sessionRowTemplate(props) {
4111
- const { row, copy, checked, onToggle, pickIdPrefix } = props;
4172
+ const { row, copy, checked, onToggle, pickIdPrefix, onRecycle } = props;
4112
4173
  const badge = badgeFor(row, copy);
4113
4174
  const stamp = formatStamp(row.updatedAtMs);
4114
4175
  return (0, import_react7.createElement)(
@@ -4150,7 +4211,15 @@ function sessionRowTemplate(props) {
4150
4211
  (0, import_react7.createElement)("span", void 0, formatBytes(row.bytes))
4151
4212
  )
4152
4213
  )
4153
- )
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
4154
4223
  );
4155
4224
  }
4156
4225
  var SessionRow = (0, import_react7.memo)(function SessionRow2(props) {
@@ -4158,9 +4227,10 @@ var SessionRow = (0, import_react7.memo)(function SessionRow2(props) {
4158
4227
  });
4159
4228
  function renderBinRow(props) {
4160
4229
  const { row, copy, busy } = props;
4230
+ const unaddressable = row.entryId === "";
4161
4231
  return (0, import_react7.createElement)(
4162
4232
  "li",
4163
- { key: row.entryId },
4233
+ { key: unaddressable ? `unaddressable:${row.sessionId}` : row.entryId },
4164
4234
  (0, import_react7.createElement)(
4165
4235
  "div",
4166
4236
  { className: CLASS.row },
@@ -4172,20 +4242,22 @@ function renderBinRow(props) {
4172
4242
  (0, import_react7.createElement)(
4173
4243
  "span",
4174
4244
  { 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
- })
4245
+ ...unaddressable ? [(0, import_react7.createElement)("span", { className: CLASS.rowMeta, title: copy.binRowUnaddressable }, copy.binRowUnaddressable)] : [
4246
+ Button({
4247
+ variant: "secondary",
4248
+ size: "compact",
4249
+ disabled: busy,
4250
+ onClick: () => props.onRestore(row.entryId),
4251
+ children: copy.restore
4252
+ }),
4253
+ Button({
4254
+ variant: "ghost",
4255
+ size: "compact",
4256
+ disabled: busy,
4257
+ onClick: () => props.onPurge(row.entryId),
4258
+ children: copy.purge
4259
+ })
4260
+ ]
4189
4261
  )
4190
4262
  )
4191
4263
  );
@@ -4202,6 +4274,8 @@ function renderWorkspaceGroup(props) {
4202
4274
  { className: CLASS.groupHead },
4203
4275
  (0, import_react7.createElement)("span", { className: CLASS.rowTitle }, group.label === "" ? copy.workspaceUnknown : group.label),
4204
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),
4205
4279
  // A workspace with no directory (the platform's no-cwd bucket) has nothing to scope a delete to, so it
4206
4280
  // gets no button rather than a button that guesses.
4207
4281
  remove === void 0 ? null : Button({
@@ -4212,7 +4286,6 @@ function renderWorkspaceGroup(props) {
4212
4286
  children: copy.deleteWorkspace
4213
4287
  })
4214
4288
  ),
4215
- group.cwd === void 0 ? null : (0, import_react7.createElement)("p", { className: CLASS.hint, title: group.cwd }, group.cwd),
4216
4289
  (0, import_react7.createElement)(
4217
4290
  "ul",
4218
4291
  { className: CLASS.list },
@@ -4225,6 +4298,7 @@ function renderWorkspaceGroup(props) {
4225
4298
  copy,
4226
4299
  checked: selectedSet.has(row.id),
4227
4300
  onToggle: props.onToggle,
4301
+ ...props.onRecycle === void 0 ? {} : { onRecycle: props.onRecycle },
4228
4302
  pickIdPrefix: props.pickIdPrefix
4229
4303
  })
4230
4304
  )
@@ -4428,6 +4502,7 @@ function SessionPanel({ copy, io, close }) {
4428
4502
  children: copy.selectAll
4429
4503
  }),
4430
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)),
4431
4506
  Button({
4432
4507
  variant: "primary",
4433
4508
  size: "compact",
@@ -4453,6 +4528,7 @@ function SessionPanel({ copy, io, close }) {
4453
4528
  selected,
4454
4529
  busy,
4455
4530
  onToggle: toggle,
4531
+ onRecycle: (id) => void run(() => deleteSessions(io, [id])),
4456
4532
  onRemove: setPending,
4457
4533
  pickIdPrefix
4458
4534
  })
@@ -4463,10 +4539,10 @@ function SessionPanel({ copy, io, close }) {
4463
4539
  (0, import_react7.createElement)("h3", { className: CLASS.blockTitle }, copy.binHeading),
4464
4540
  // Same rule as the inventory: 「回收站为空」 is a claim about an **answer**. While nothing has been read (or the
4465
4541
  // 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)(
4542
+ 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
4543
  "div",
4468
4544
  { className: CLASS.block },
4469
- (0, import_react7.createElement)(
4545
+ snapshot.bin.length === 0 ? null : (0, import_react7.createElement)(
4470
4546
  "ul",
4471
4547
  { className: CLASS.list },
4472
4548
  ...snapshot.bin.map(
@@ -4479,6 +4555,7 @@ function SessionPanel({ copy, io, close }) {
4479
4555
  })
4480
4556
  )
4481
4557
  ),
4558
+ snapshot.binUnreadable > 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binUnreadable(snapshot.binUnreadable)) : null,
4482
4559
  Button({
4483
4560
  variant: "secondary",
4484
4561
  size: "compact",
@@ -5558,7 +5635,15 @@ function createSessionDirectoryIo(options) {
5558
5635
  }
5559
5636
  const all = result.value.roots;
5560
5637
  if (sessionId !== void 0 && sessionId !== "" && result.value.scopedForSession === sessionId) {
5561
- 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
+ };
5562
5647
  }
5563
5648
  if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
5564
5649
  return {
@@ -5566,7 +5651,7 @@ function createSessionDirectoryIo(options) {
5566
5651
  value: all.filter((row) => {
5567
5652
  const sessions = row.sessions ?? [];
5568
5653
  if (sessions.length > 0 && sessionId !== void 0 && sessionId !== "") return sessions.includes(sessionId);
5569
- return row.workspaces?.includes(workspacePath) === true;
5654
+ return (row.workspaces ?? []).some((candidate) => sameWorkspaceSpelling(candidate, workspacePath));
5570
5655
  })
5571
5656
  };
5572
5657
  };
@@ -5623,16 +5708,26 @@ function sessionDirectoryReducer(state, action) {
5623
5708
  const next = action.path === void 0 || action.path === "" ? void 0 : action.path;
5624
5709
  if (state.workspacePath === next) return state;
5625
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
+ };
5626
5720
  if (next === void 0) {
5627
- const { workspacePath: _cleared, ...rest } = base;
5721
+ const { workspacePath: _cleared, ...rest } = cleared;
5628
5722
  return rest;
5629
5723
  }
5630
- return { ...base, workspacePath: next };
5724
+ return { ...cleared, workspacePath: next };
5631
5725
  }
5632
5726
  case "session-known": {
5633
5727
  const next = action.sessionId === void 0 || action.sessionId === "" ? void 0 : action.sessionId;
5634
5728
  if (state.sessionId === next) return state;
5635
- 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 } : {} };
5636
5731
  if (next === void 0) {
5637
5732
  const { sessionId: _cleared, ...rest } = base;
5638
5733
  return rest;
@@ -5677,15 +5772,23 @@ function sessionDirectoryReducer(state, action) {
5677
5772
  };
5678
5773
  }
5679
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
+ }
5680
5782
  function selectSessionDirectoryView(state, copy) {
5681
5783
  const view = {
5682
5784
  visible: state.open,
5683
5785
  pending: state.pending,
5684
5786
  notice: resolveMessage2(copy, state.notice),
5685
- workspaceLine: state.workspacePath === void 0 ? copy["sessionAddWorkspaceUnknown"] : resolveMessage2(copy, { key: "sessionAddWorkspace", params: { path: state.workspacePath } }),
5686
5787
  ...state.workspacePath === void 0 ? {} : { workspacePath: state.workspacePath },
5687
5788
  ...state.added === void 0 ? {} : { added: state.added },
5688
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.
5689
5792
  roots: [
5690
5793
  ...state.workspacePath === void 0 ? [] : [{
5691
5794
  key: `workspace:${state.workspacePath}`,
@@ -5695,7 +5798,9 @@ function selectSessionDirectoryView(state, copy) {
5695
5798
  removable: false,
5696
5799
  workspace: true
5697
5800
  }],
5698
- ...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) => ({
5699
5804
  key: row.key,
5700
5805
  // A label is what the user called it; without one the path is the only honest title. A remote path alone
5701
5806
  // would hide which host it is on, so the connection name is part of the title there.
@@ -5857,22 +5962,13 @@ function SessionDirectorySection({
5857
5962
  },
5858
5963
  (0, import_react11.createElement)("h3", { id: titleId, className: CLASS.sectionTitle }, copy.sessionAddTitle),
5859
5964
  (0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionAddHint),
5860
- // Which workspace this lands in, or - when the framework seats could not answer - the fallback the
5861
- // copy states explicitly. Both are one line, so a narrow panel never has to truncate it.
5862
- (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,
5863
5968
  // The card no longer *is* a browser: it lists what this session has and offers one way in. The picker is a
5864
5969
  // step behind 「浏览…」, which also means the card no longer opens with a full directory listing (that was
5865
5970
  // the complaint: the panel showed the current directory's contents before the user asked for anything).
5866
- view.browsing ? (0, import_react11.createElement)(
5867
- "div",
5868
- { className: CLASS.block },
5869
- browser,
5870
- (0, import_react11.createElement)(
5871
- "div",
5872
- { className: CLASS.actions },
5873
- Button({ variant: "ghost", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowseBack })
5874
- )
5875
- ) : (0, import_react11.createElement)(
5971
+ view.browsing ? (0, import_react11.createElement)("div", { className: CLASS.block }, browser) : (0, import_react11.createElement)(
5876
5972
  "div",
5877
5973
  { className: CLASS.block },
5878
5974
  (0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
@@ -5911,17 +6007,18 @@ function SessionDirectorySection({
5911
6007
  )
5912
6008
  ),
5913
6009
  // 列表里只剩工作区行时,"还没有添加目录"仍然是有用的下一步提示;但它不再替换列表。
5914
- 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,
5915
- (0, import_react11.createElement)(
5916
- "div",
5917
- { className: CLASS.actions },
5918
- Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowse })
5919
- )
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
5920
6011
  ),
5921
6012
  view.added === void 0 ? null : Notice({ role: "status", tone: "info", children: resolveMessage2(copy, { key: "sessionAddDone", params: { path: view.added } }) }),
5922
6013
  view.pending ? Notice({ role: "status", tone: "info", children: copy.flowPending }) : null,
5923
6014
  view.notice === "" ? null : Notice({ role: "alert", tone: "error", children: view.notice }),
5924
- (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
+ )
5925
6022
  );
5926
6023
  return (0, import_react11.createElement)(
5927
6024
  "div",
@@ -6634,12 +6731,16 @@ function apply(ctx) {
6634
6731
  sessionsHeading: dict.sessionPanelSessions,
6635
6732
  binHeading: dict.sessionPanelBin,
6636
6733
  binEmpty: dict.sessionPanelBinEmpty,
6734
+ binUnreadable: (count) => resolveMessage2(dict, { key: "sessionPanelBinUnreadable", params: { count } }),
6735
+ binRowUnaddressable: dict.sessionPanelBinRowUnaddressable,
6637
6736
  selectAll: dict.sessionPanelSelectAll,
6638
6737
  clearSelection: dict.sessionPanelClear,
6639
6738
  deleteRecycle: dict.sessionPanelDeleteRecycle,
6640
6739
  deletePermanent: dict.sessionPanelDeletePermanent,
6641
6740
  restore: dict.sessionPanelRestore,
6642
6741
  purge: dict.sessionPanelPurge,
6742
+ rowRecycle: dict.sessionPanelRowRecycle,
6743
+ selectedCount: (count) => resolveMessage2(dict, { key: "sessionPanelSelectedCount", params: { count } }),
6643
6744
  sweep: dict.sessionPanelSweep,
6644
6745
  refresh: dict.sessionPanelRefresh,
6645
6746
  close: dict.sessionPanelClose,