@tiphareth/dsh-hardssh 0.1.2 → 0.2.2

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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +58 -25
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -22,12 +22,14 @@ import { SshApi } from './ssh/api.ts'
22
22
  import { NS, dictionaries, type WorkspaceKey } from './locales.ts'
23
23
  import { WorkspaceManager } from './state.ts'
24
24
  import { setLanguage } from './text.ts'
25
- import { WorkspaceManagerButton } from './manager-button.tsx'
25
+ import { registerWorkspacePanel } from './workspace-panel-entry.tsx'
26
26
  import { migrateLegacySessionMemory } from './migrate.ts'
27
27
  import { DirectoryFlow } from './directory-flow.tsx'
28
+ import { connectHost } from './connect-host.ts'
28
29
  import { mountWorkspaceBadges } from './workspace-badges.ts'
29
- import { connectHost, mountWorkspaceGates } from './workspace-gate.ts'
30
+ import { makeAnchorAliasResolver, mountSessionConnectGate, type SessionGateList } from './session-connect-gate.ts'
30
31
  import { mountSshOperations } from './ssh/apply.ts'
32
+ import { createSessionSshTargetSource } from './ssh/session-target.ts'
31
33
 
32
34
  declare module '@deepseek-ai/dsh-client-ui-slots' {
33
35
  interface LocaleNamespaceMap {
@@ -37,9 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
37
39
  }
38
40
 
39
41
  // Type-only re-exports of the SSH operations surfaces (merge phase 2).
40
- export type { PanelControllerSnapshot } from './ssh/panel/controller.ts'
41
42
  export type { SshPanelProps } from './ssh/panel/SshPanel.tsx'
42
- export type { HostsTabProps } from './ssh/panel/HostsTab.tsx'
43
43
  export type { HostFormDialogProps } from './ssh/panel/HostFormDialog.tsx'
44
44
  export type { TerminalTabProps } from './ssh/panel/TerminalTab.tsx'
45
45
  export type { TransferTabProps } from './ssh/panel/TransferTab.tsx'
@@ -47,32 +47,47 @@ export type { TunnelsTabProps } from './ssh/panel/TunnelsTab.tsx'
47
47
  export type { ClusterTabProps } from './ssh/panel/ClusterTab.tsx'
48
48
  export type { SshKey } from './ssh/locales.ts'
49
49
 
50
- /** Required services: slots for the header buttons + directory-flow holes,
51
- * locale for the copy, workspaces for the native local-directory chooser. */
52
- export const inject = ['slots', 'locale', 'workspaces']
50
+ /** Required services: slots for the extension seats, locale for copy,
51
+ * workspaces for the native local-directory chooser, sessions for the
52
+ * open/new-session connection gate, and sidebarRightTabs for the SSH
53
+ * operations console type. Keep the registry explicit: without it Cordis may
54
+ * activate this client before ui-sidebar-right provides the service; the
55
+ * guarded registration then degrades silently and no HardSSH tab exists. */
56
+ export const inject = ['slots', 'locale', 'workspaces', 'sessions', 'sidebarRightTabs']
53
57
 
54
58
  /** Apply the browser half. */
55
59
  export function apply(ctx: ClientContext): void {
56
- // SSH operations surfaces (sidebar entry + operations panel), merged from
57
- // the legacy dsh-ssh client. Mounted first; its failures degrade the SSH
58
- // panel only, never the GUI.
59
- mountSshOperations(ctx)
60
-
61
- ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-hardssh: dictionaries')
62
-
63
60
  const api = new WorkspaceApi()
64
61
  const hostsApi = new SshHostsApi()
65
62
  const sshApi = new SshApi()
66
63
  const manager = new WorkspaceManager(api)
64
+ const sessionList = ctx.sessions.list
65
+ const sessions: SessionGateList = {
66
+ subscribe: (listener) => sessionList.subscribe(listener),
67
+ phase: () => sessionList.getSnapshot().phase,
68
+ currentSessionId: () => sessionList.getSnapshot().current,
69
+ sessionIds: () => sessionList.getSnapshot().ids,
70
+ sessionCwd: (id) => {
71
+ const byId = sessionList.getSnapshot().byId as unknown as
72
+ Record<string, { cwd?: string } | undefined>
73
+ return byId[id]?.cwd
74
+ },
75
+ }
76
+
77
+ // The operations console inherits its ONLY target from the selected Session's
78
+ // workspace. A local Session resolves to null and the panel renders disabled;
79
+ // individual operation tabs never own a host picker.
80
+ mountSshOperations(ctx, sshApi, createSessionSshTargetSource(sessions, manager))
81
+
82
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-hardssh: dictionaries')
67
83
 
68
84
  const disposers: Array<() => void> = []
69
85
  try {
70
- ctx.slots.inject('conversation.session.header.utilities', () => {
71
- return ctx.slots.register(
72
- { name: 'conversation.session.header.utilities', id: 'ssh-workspace-manager', order: -10, inject: () => ({ manager, sshApi }) },
73
- WorkspaceManagerButton,
74
- )
75
- })
86
+ // The SSH workspace manager is a GLOBAL surface: a left-sidebar entry row
87
+ // (sidebar.panellist) opening a center panel (main). The shell owns the
88
+ // row, its label and its active highlight; selecting it switches the
89
+ // center column, and selecting the Conversation row switches back.
90
+ registerWorkspacePanel(ctx, { manager, sshApi })
76
91
 
77
92
  // Replace the native-only directory-flow occupant (both holes) with the
78
93
  // SSH/local chooser. `pickDirectory` restores the original native local
@@ -119,14 +134,13 @@ export function apply(ctx: ClientContext): void {
119
134
 
120
135
  manager.start()
121
136
 
122
- // Decorate the sidebar workspace rows: append a remote badge to every
123
- // host workspace whose title belongs to an SSH-bound workspace. The
124
- // badge set is driven by the manager (the single owner of the workspace
125
- // list): every successful poll / mutation emits, and the snapshot is the
126
- // only data source. Best-effort: a DOM miss only logs.
137
+ // Sidebar row decoration: label every host workspace whose title belongs to
138
+ // an SSH-bound workspace with a compact `alias` badge, tinted by whether the
139
+ // server currently holds a pooled connection. The shell still exposes no
140
+ // row-decoration slot, so this stays the documented DOM extension it always
141
+ // was (MutationObserver self-heal), unlike the session list below.
127
142
  let badgesDispose: (() => void) | undefined
128
- // Live connection state (aliases with a pooled transport) → blue badge.
129
- let connectedAliases = new Set<string>()
143
+ let connectedAliases: ReadonlySet<string> = new Set<string>()
130
144
  const refreshBadges = (): void => {
131
145
  try {
132
146
  const workspaces = manager.getSnapshot().workspaces
@@ -142,20 +156,18 @@ export function apply(ctx: ClientContext): void {
142
156
  }
143
157
  }
144
158
  disposers.push(() => { badgesDispose?.() })
145
- // Same-source sync: no independent fetch or timer — the manager emits on
146
- // its 3s poll and on create/remove/rename; the first pass lands when the
147
- // manager's initial refresh resolves (start() runs before this subscribe
148
- // is registered, and its first emit happens after the synchronous body).
159
+ // Same-source sync: no independent workspace fetch — the manager emits on
160
+ // its 3s poll and on create/remove/rename.
149
161
  disposers.push(manager.subscribe(() => { refreshBadges() }))
150
162
 
151
- // Connection-state poll: keep badge colors current (same cadence as the
152
- // manager's workspace poll). Failure keeps everything gray (safe).
163
+ // Connection-state poll: badge colors AND the gate's pass cache both key off
164
+ // "which servers have a live pooled transport right now".
153
165
  const refreshConnections = async (): Promise<void> => {
154
166
  try {
155
167
  connectedAliases = new Set(await sshApi.connectedAliases())
156
168
  } catch (error) {
157
169
  console.warn('[dsh-hardssh] connection-state poll failed:', error)
158
- connectedAliases = new Set()
170
+ connectedAliases = new Set<string>()
159
171
  }
160
172
  refreshBadges()
161
173
  }
@@ -163,25 +175,16 @@ export function apply(ctx: ClientContext): void {
163
175
  disposers.push(() => { window.clearInterval(connTimer) })
164
176
  void refreshConnections()
165
177
 
166
- // Click gate: probing a workspace row must first establish the SSH
167
- // connection (host-key TOFU confirm + session password dialogs), so the
168
- // shell never opens a workspace that silently fails underneath.
169
- let gateDispose: (() => void) | undefined
170
- const refreshGates = (): void => {
171
- try {
172
- const workspaces = manager.getSnapshot().workspaces
173
- gateDispose?.()
174
- gateDispose = mountWorkspaceGates(workspaces.map((workspace) => ({
175
- id: workspace.id,
176
- title: workspace.title,
177
- alias: workspace.alias,
178
- })), sshApi)
179
- } catch (error) {
180
- console.warn('[dsh-hardssh] gate refresh failed:', error)
181
- }
182
- }
183
- disposers.push(() => { gateDispose?.() })
184
- disposers.push(manager.subscribe(() => { refreshGates() }))
178
+ // Session-open connection gate: opening a session (or creating a New
179
+ // Session) inside an SSH workspace probes the owning server first, so the
180
+ // fingerprint / session-password dialogs appear before any remote operation
181
+ // fails. Driven by the public session list — not by DOM rows — so it
182
+ // survives any sidebar restyle.
183
+ disposers.push(mountSessionConnectGate({
184
+ sessions,
185
+ aliasForCwd: makeAnchorAliasResolver(() => manager.getSnapshot().workspaces),
186
+ ensureConnected: (alias) => connectHost(sshApi, alias),
187
+ }))
185
188
  } catch (error) {
186
189
  console.warn('[dsh-hardssh] mount failed:', error)
187
190
  }
@@ -13,7 +13,14 @@ export const zh = {
13
13
  'manager.label': 'SSH 工作区',
14
14
  'manager.tooltip': '管理 SSH 工作区:绑定服务器目录,绑定后的会话在远程操作,其余会话在本机',
15
15
  'manager.title': 'SSH 工作区管理',
16
+ 'panel.workspaces': 'SSH 工作区',
17
+ 'ops.tab': 'SSH',
18
+ 'ops.guide': '打开 SSH 运维面板:终端、传输、隧道、集群',
16
19
  'manager.empty': '还没有 SSH 工作区',
20
+ 'manager.connected': '已连接',
21
+ 'manager.disconnected': '未连接',
22
+ 'manager.connectionUnknown': '状态未知',
23
+ 'manager.connectionError': '无法读取服务器连接状态:%s',
17
24
  'manager.delete': '删除',
18
25
  'manager.editServer': '编辑服务器',
19
26
  'manager.deleteServer': '删除服务器',
@@ -61,7 +68,14 @@ export const en: Record<WorkspaceKey, string> = {
61
68
  'manager.label': 'SSH workspaces',
62
69
  'manager.tooltip': 'Manage SSH workspaces: bind a server directory — sessions in a bound workspace operate remotely, all others stay local',
63
70
  'manager.title': 'SSH Workspaces',
71
+ 'panel.workspaces': 'SSH workspaces',
72
+ 'ops.tab': 'SSH',
73
+ 'ops.guide': 'Open the SSH operations panel: terminal, transfer, tunnels, cluster',
64
74
  'manager.empty': 'No SSH workspaces yet',
75
+ 'manager.connected': 'Connected',
76
+ 'manager.disconnected': 'Disconnected',
77
+ 'manager.connectionUnknown': 'Status unknown',
78
+ 'manager.connectionError': 'Could not read server connection state: %s',
65
79
  'manager.delete': 'Delete',
66
80
  'manager.editServer': 'Edit server',
67
81
  'manager.deleteServer': 'Delete server',
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Session-open connection gate: whenever the shell opens a session whose cwd
3
+ * belongs to an SSH-bound workspace, probe the owning server FIRST so the
4
+ * interactive connection dialogs appear (host-key TOFU confirm / VSCode-style
5
+ * session password) instead of the workspace silently failing underneath.
6
+ *
7
+ * This replaces the old sidebar row-click gate. The shell exposes no row slot
8
+ * for workspace rows, and the previous `[class*="projectRow"]` DOM listener was
9
+ * never a contract — the session list, by contrast, IS a public client surface
10
+ * (`ctx.sessions.list`), so the trigger is now "a session became current" (open)
11
+ * plus "a session appeared while we were watching" (New Session in an SSH
12
+ * workspace) rather than a click on a DOM node we do not own.
13
+ *
14
+ * Probing is delegated to `connectHost`, which owns the TTL pass cache and the
15
+ * in-flight coalescing: re-selecting the same workspace inside the TTL, or
16
+ * creating several sessions in it, prompts at most once.
17
+ *
18
+ * @module dsh-hardssh/client/session-connect-gate
19
+ */
20
+
21
+ /** The narrow slice of `ctx.sessions.list` this gate reads. Structural on
22
+ * purpose: the shell's `SessionId`/`SessionSummary` types stay out of the
23
+ * plugin's public surface, and the adapter at the call site does the one
24
+ * unavoidable cast. */
25
+ export interface SessionGateList {
26
+ subscribe(listener: () => void): () => void
27
+ /** Initial host list arrival; pending means an empty list is not authoritative. */
28
+ phase(): 'pending' | 'ready'
29
+ /** Currently selected session, undefined on the empty state. */
30
+ currentSessionId(): string | undefined
31
+ /** Listed session ids, in host order. */
32
+ sessionIds(): readonly string[]
33
+ /** The session's workspace cwd (the anchor path for SSH workspaces). */
34
+ sessionCwd(sessionId: string): string | undefined
35
+ }
36
+
37
+ /** Gate dependencies. */
38
+ export interface SessionConnectGateDeps {
39
+ sessions: SessionGateList
40
+ /** Alias owning the SSH workspace that CONTAINS this cwd, when bound. */
41
+ aliasForCwd(cwd: string | undefined): string | undefined
42
+ /** Probe + prompt; resolves true once the server may be used. */
43
+ ensureConnected(alias: string): Promise<boolean>
44
+ }
45
+
46
+ /** Normalize one path for anchor comparison: unified separators, no trailing
47
+ * separator, and case-folded for Windows-shaped paths (Windows anchors are
48
+ * case-insensitive; POSIX anchors are not).
49
+ *
50
+ * Deliberately a SECOND implementation of `base/ledger.ts`'s
51
+ * `normalizeAnchorPath`: the base module imports `node:fs` for its own
52
+ * persistence and therefore cannot be pulled into the browser bundle. Keep the
53
+ * two in step — the host-side duplicates were consolidated for exactly this
54
+ * reason, and this one is the documented exception. */
55
+ function normalizeAnchor(path: string): string {
56
+ const unified = path.replace(/\\/gu, '/').replace(/\/+$/u, '')
57
+ if (/^[a-zA-Z]:\//u.test(unified) || unified.startsWith('//')) return unified.toLowerCase()
58
+ return unified
59
+ }
60
+
61
+ /** True when `cwd` is the anchor itself or sits inside it. */
62
+ function isUnderAnchor(anchor: string, cwd: string): boolean {
63
+ return cwd === anchor || cwd.startsWith(`${anchor}/`)
64
+ }
65
+
66
+ /**
67
+ * Build the cwd → workspace resolver over a live workspace list. The LONGEST
68
+ * matching anchor wins, so a workspace nested under another one still routes to
69
+ * its own server.
70
+ * @param workspaces - live records carrying an anchor path.
71
+ * @returns resolver returning the owning record, or undefined when unbound.
72
+ */
73
+ export function makeAnchorWorkspaceResolver<T extends { anchorPath: string }>(
74
+ workspaces: () => ReadonlyArray<T>,
75
+ ): (cwd: string | undefined) => T | undefined {
76
+ return (cwd) => {
77
+ if (cwd === undefined || cwd === '') return undefined
78
+ const target = normalizeAnchor(cwd)
79
+ let best: T | undefined
80
+ let bestLength = -1
81
+ for (const workspace of workspaces()) {
82
+ const anchor = normalizeAnchor(workspace.anchorPath)
83
+ if (anchor === '' || !isUnderAnchor(anchor, target)) continue
84
+ if (anchor.length > bestLength) {
85
+ bestLength = anchor.length
86
+ best = workspace
87
+ }
88
+ }
89
+ return best
90
+ }
91
+ }
92
+
93
+ /** Alias-only compatibility face used by the connection gate. */
94
+ export function makeAnchorAliasResolver(
95
+ workspaces: () => ReadonlyArray<{ alias: string; anchorPath: string }>,
96
+ ): (cwd: string | undefined) => string | undefined {
97
+ const resolve = makeAnchorWorkspaceResolver(workspaces)
98
+ return (cwd) => resolve(cwd)?.alias
99
+ }
100
+
101
+ /**
102
+ * Watch the session list and probe the server behind every SSH-bound session
103
+ * the operator opens or creates.
104
+ *
105
+ * The first pass ADOPTS the existing list without probing: a GUI restore must
106
+ * not fire one connection dialog per remembered session. Only the session the
107
+ * shell actually selects (including the restored current one) and sessions
108
+ * created after that pass trigger a probe.
109
+ *
110
+ * @param deps - session list view, anchor resolver and probe.
111
+ * @returns disposer removing the subscription.
112
+ */
113
+ export function mountSessionConnectGate(deps: SessionConnectGateDeps): () => void {
114
+ const adopted = new Set<string>()
115
+ let primed = false
116
+ let lastCurrent: string | undefined
117
+
118
+ /** Probe the server owning one session's workspace (best-effort). */
119
+ const probe = (sessionId: string | undefined): void => {
120
+ if (sessionId === undefined) return
121
+ const alias = deps.aliasForCwd(deps.sessions.sessionCwd(sessionId))
122
+ if (alias === undefined) return
123
+ void deps.ensureConnected(alias).catch((error: unknown) => {
124
+ console.warn('[dsh-hardssh] session connect gate failed:', error)
125
+ })
126
+ }
127
+
128
+ const pass = (): void => {
129
+ // The session store starts `pending` with an empty list. Do not prime from
130
+ // that placeholder: the first successful host pull would otherwise make
131
+ // every restored historical Session look newly-created and probe every
132
+ // configured server at GUI startup.
133
+ if (!primed && deps.sessions.phase() !== 'ready') return
134
+
135
+ const ids = deps.sessions.sessionIds()
136
+ // One probe per session per pass: a New Session both appears and becomes
137
+ // current in the same snapshot, and probing it twice would open a second
138
+ // dialog path for a single operator action.
139
+ const probed = new Set<string>()
140
+ if (!primed) {
141
+ for (const id of ids) adopted.add(id)
142
+ primed = true
143
+ } else {
144
+ for (const id of ids) {
145
+ if (adopted.has(id)) continue
146
+ adopted.add(id)
147
+ // A session created while we watch: the New Session flow in an SSH
148
+ // workspace lands here before its first operation runs.
149
+ probed.add(id)
150
+ probe(id)
151
+ }
152
+ }
153
+ const current = deps.sessions.currentSessionId()
154
+ if (current !== lastCurrent) {
155
+ lastCurrent = current
156
+ if (current !== undefined && !probed.has(current)) probe(current)
157
+ }
158
+ }
159
+
160
+ const dispose = deps.sessions.subscribe(pass)
161
+ pass()
162
+ return dispose
163
+ }