@linxin666/dsh-ssh 0.4.2 → 0.4.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 (37) hide show
  1. package/lib/client.js +371 -461
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +707 -466
  4. package/lib/types/client/api.d.ts +55 -4
  5. package/lib/types/client/locales.d.ts +1 -0
  6. package/lib/types/client/native-panel.d.ts +70 -0
  7. package/lib/types/client/panel/SshPanel.d.ts +1 -3
  8. package/lib/types/client/panel/TerminalTab.d.ts +6 -1
  9. package/lib/types/client/panel/controller.d.ts +71 -4
  10. package/lib/types/engine/terminal-sessions.d.ts +67 -0
  11. package/lib/types/mount-once.d.ts +38 -7
  12. package/lib/types/protocol.d.ts +12 -1
  13. package/lib/types/routes.d.ts +2 -0
  14. package/package.json +19 -16
  15. package/src/client/api.ts +137 -24
  16. package/src/client/index.ts +31 -9
  17. package/src/client/locales.ts +2 -0
  18. package/src/client/native-panel.tsx +141 -0
  19. package/src/client/panel/SshPanel.tsx +25 -28
  20. package/src/client/panel/TerminalTab.tsx +54 -14
  21. package/src/client/panel/controller.ts +126 -7
  22. package/src/client/panel/panel.module.css +8 -128
  23. package/src/engine/terminal-sessions.ts +280 -0
  24. package/src/index.ts +4 -1
  25. package/src/mount-once.ts +107 -13
  26. package/src/protocol.ts +6 -1
  27. package/src/routes.ts +26 -107
  28. package/lib/types/client/body-mutations.d.ts +0 -45
  29. package/lib/types/client/mount.d.ts +0 -27
  30. package/lib/types/client/panel-mount-core.d.ts +0 -56
  31. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  32. package/lib/types/client/sidebar-entry.d.ts +0 -26
  33. package/src/client/body-mutations.ts +0 -133
  34. package/src/client/mount.tsx +0 -47
  35. package/src/client/panel-mount-core.ts +0 -155
  36. package/src/client/sidebar-entry-core.ts +0 -237
  37. package/src/client/sidebar-entry.ts +0 -51
@@ -1,23 +1,77 @@
1
1
  /**
2
- * SSH panel controller: the single owner of the panel's open/closed state.
2
+ * SSH panel controller: the single owner of the panel's view state.
3
3
  *
4
4
  * Framework-free (structural runtime faces, task-board core/controller.ts
5
- * style) so the DOM mounts and the React panel share one tiny subscription
5
+ * style) so the sidebar row and the React panel share one tiny subscription
6
6
  * surface. The state lives only for the browser session (no persistence).
7
+ *
8
+ * The active tab, the hosts-tab connect request and the live terminal session
9
+ * id live here rather than inside the panel components: the layout mounts a
10
+ * keyed page only while its panel is selected, so component-local state would
11
+ * reset the tab on every panel switch and lose the id a reattach needs.
7
12
  */
8
13
 
14
+ /** The panel's tab identifiers. */
15
+ export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
16
+
17
+ /** A "connect" action from the hosts tab, handed to the terminal tab. */
18
+ export interface ConnectRequest {
19
+ alias: string
20
+ /** Monotonic id so a repeated connect for the same alias still re-applies. */
21
+ nonce: number
22
+ }
23
+
24
+ /** Stable id shared by the sidebar panel row and the main-slot page. */
25
+ export const SSH_PANEL_ID = 'ssh'
26
+
9
27
  /** Immutable controller snapshot for UI subscriptions. */
10
28
  export interface PanelControllerSnapshot {
29
+ /** Whether the center column shows this panel. */
11
30
  panelOpen: boolean
31
+ /** The tab the panel is showing. */
32
+ activeTab: SshTab
33
+ /** Pending hosts-tab connect request, or null. */
34
+ connectRequest: ConnectRequest | null
35
+ /** Host-side terminal session the terminal tab should reattach to, if any. */
36
+ terminalSessionId: string | undefined
12
37
  }
13
38
 
14
- /** The panel state owner the sidebar entry toggles and the view renders from. */
39
+ /**
40
+ * The layout's panel-navigation face: selecting the panel is the layout's
41
+ * business, and this is the one call that asks for it (null returns the column
42
+ * to the conversation). Optional, so a shell-less composition keeps the pure
43
+ * state transitions testable.
44
+ */
45
+ export interface PanelNavigationFace {
46
+ /** Select this panel, or null to hand the column back to the conversation. */
47
+ select(panelId: string | null): void
48
+ }
49
+
50
+ /** Controller dependencies. */
51
+ export interface PanelControllerDeps {
52
+ /** Layout panel selection; absent keeps the controller state-only. */
53
+ panel?: PanelNavigationFace
54
+ }
55
+
56
+ /** The panel state owner the sidebar row and the view both read. */
15
57
  export class PanelController {
16
58
  private panelOpen = false
59
+ private activeTab: SshTab = 'hosts'
60
+ private connectRequest: ConnectRequest | null = null
61
+ private terminalSessionId: string | undefined
17
62
  private listeners = new Set<() => void>()
63
+ /** Cached so getSnapshot is referentially stable between changes. */
64
+ private snapshot: PanelControllerSnapshot = {
65
+ panelOpen: false,
66
+ activeTab: 'hosts',
67
+ connectRequest: null,
68
+ terminalSessionId: undefined,
69
+ }
70
+
71
+ constructor(private readonly deps: PanelControllerDeps = {}) {}
18
72
 
19
73
  getSnapshot(): PanelControllerSnapshot {
20
- return { panelOpen: this.panelOpen }
74
+ return this.snapshot
21
75
  }
22
76
 
23
77
  subscribe(fn: () => void): () => void {
@@ -25,16 +79,25 @@ export class PanelController {
25
79
  return () => { this.listeners.delete(fn) }
26
80
  }
27
81
 
82
+ /**
83
+ * Show the panel. The layout owns which panel the column renders, so the
84
+ * state flip and the selection travel together; the selection is requested
85
+ * after the snapshot flips, so a subscriber never observes "open" while the
86
+ * shell still shows the conversation.
87
+ */
28
88
  open(): void {
29
89
  if (this.panelOpen) return
30
90
  this.panelOpen = true
31
- this.notify()
91
+ this.commit()
92
+ this.selectPanel(SSH_PANEL_ID)
32
93
  }
33
94
 
95
+ /** Return the column to the conversation, asking the layout explicitly. */
34
96
  close(): void {
35
97
  if (!this.panelOpen) return
36
98
  this.panelOpen = false
37
- this.notify()
99
+ this.commit()
100
+ this.selectPanel(null)
38
101
  }
39
102
 
40
103
  toggle(): void {
@@ -42,7 +105,63 @@ export class PanelController {
42
105
  else this.open()
43
106
  }
44
107
 
45
- private notify(): void {
108
+ /**
109
+ * Reflect a selection made outside this controller (the user clicked another
110
+ * sidebar row, or the layout dropped the panel id).
111
+ * @param panelId - the layout's current panel id, or null for the conversation.
112
+ */
113
+ syncPanelSelection(panelId: string | null): void {
114
+ const open = panelId === SSH_PANEL_ID
115
+ if (open === this.panelOpen) return
116
+ this.panelOpen = open
117
+ this.commit()
118
+ }
119
+
120
+ /** Switch tabs, keeping every other piece of view state. */
121
+ setActiveTab(tab: SshTab): void {
122
+ if (this.activeTab === tab) return
123
+ this.activeTab = tab
124
+ this.commit()
125
+ }
126
+
127
+ /** Hand the terminal tab a host to connect to (hosts-tab "connect" action). */
128
+ requestConnect(alias: string): void {
129
+ this.connectRequest = { alias, nonce: Date.now() }
130
+ this.activeTab = 'terminal'
131
+ this.commit()
132
+ }
133
+
134
+ /** Record the host-side session so a later page mount can reattach. */
135
+ setTerminalSession(sessionId: string): void {
136
+ if (this.terminalSessionId === sessionId) return
137
+ this.terminalSessionId = sessionId
138
+ this.commit()
139
+ }
140
+
141
+ /** Forget the session (it exited, or the user disconnected). */
142
+ clearTerminalSession(): void {
143
+ if (this.terminalSessionId === undefined) return
144
+ this.terminalSessionId = undefined
145
+ this.commit()
146
+ }
147
+
148
+ private commit(): void {
149
+ this.snapshot = {
150
+ panelOpen: this.panelOpen,
151
+ activeTab: this.activeTab,
152
+ connectRequest: this.connectRequest,
153
+ terminalSessionId: this.terminalSessionId,
154
+ }
46
155
  for (const fn of [...this.listeners]) fn()
47
156
  }
157
+
158
+ /** Ask the layout to select a panel; a shell without the face is a no-op. */
159
+ private selectPanel(panelId: string | null): void {
160
+ try {
161
+ this.deps.panel?.select(panelId)
162
+ } catch {
163
+ // The layout service throws by contract before its root entry mounts;
164
+ // the state flip above already stands and the next selection retries.
165
+ }
166
+ }
48
167
  }
@@ -1,139 +1,19 @@
1
1
  /**
2
2
  * dsh-ssh panel styles. Scoped by the plugin's own data attributes so nothing
3
3
  * leaks into the rest of the GUI; colors ride the dsh --dsw-* tokens so the
4
- * panel follows the active theme (light/dark and skins). The center-column
5
- * hide rules are attribute-scoped and must stay in this stylesheet (it is
6
- * imported by mount.tsx, so the styles load with the plugin).
4
+ * panel follows the active theme (light/dark and skins).
5
+ *
6
+ * The panel is a native main-slot page: the shell renders it only while this
7
+ * panel is selected, so this file carries no visibility rule and no rule over
8
+ * the conversation column, and the sidebar row is the shell's own panel row,
9
+ * so there is no injected entry row to style either.
7
10
  */
8
11
 
9
- /* --- center-column takeover (global rules, attribute-scoped) ----------------- */
10
-
11
- [data-pane='conversation'],
12
- [class*='centerCol'] {
13
- position: relative;
14
- }
15
-
16
- /* The panel container rides inside the conversation grid item as an extra
17
- trailing child; hidden unless the panel is active. */
18
- [data-dsh-ssh-view] {
19
- position: absolute;
20
- inset: 0;
21
- display: none;
22
- /* Above the conversation composer (z-index 7 in the 0.1.0-rc.6 shell) so the
23
- panel and its modals paint over the input card, mirroring the task board
24
- (issue #87). */
25
- z-index: 60;
26
- /* Opaque backdrop: the conversation subtree stays mounted under the panel.
27
- Even if the hide rule below were defeated by a stronger host inline
28
- style, this background keeps sticky code-block banners (host z-index 6)
29
- from showing through the active panel (issue #100). */
30
- background: var(--dsw-alias-bg-base);
31
- }
32
-
33
- /* The center column is single-occupant; the :not() guards keep the two
34
- sibling panels (task board / ssh) from fighting over visibility if both
35
- activation attributes ever coexist. */
36
- html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view] {
37
- display: block;
38
- }
39
-
40
- /* While the panel is active, the conversation content underneath is hidden
41
- (it stays mounted and stateful). The rule targets both the pane attribute
42
- and the center-column class so the chat subtree (including the code-block
43
- copy banner, issue #100) stays hidden whichever host container carries it. */
44
- html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane='conversation'] > :not([data-dsh-ssh-view]),
45
- html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol'] > :not([data-dsh-ssh-view]) {
46
- display: none !important;
47
- }
48
-
49
- /* --- sidebar entry row ------------------------------------------------------- */
50
-
51
- /* Same geometry as the shell's own panel rows (SidebarRoot .panelRow): 2px
52
- inset with a 12px radius, 8px content padding, 36px row, 14/22 type, and the
53
- primary ink — so an injected row and an official row line up column for
54
- column instead of each carrying its own box. */
55
- .entry {
56
- box-sizing: border-box;
57
- display: flex;
58
- align-items: center;
59
- gap: 8px;
60
- min-height: 36px;
61
- padding: 7px 8px;
62
- margin: 0 2px;
63
- background: transparent;
64
- border: none;
65
- border-radius: 12px;
66
- color: var(--dsw-alias-label-primary);
67
- cursor: pointer;
68
- font: inherit;
69
- font-size: 14px;
70
- line-height: 22px;
71
- text-align: left;
72
- white-space: nowrap;
73
- }
74
-
75
- .entry:hover {
76
- background: var(--dsw-alias-interactive-bg-hover);
77
- color: var(--dsw-alias-label-primary);
78
- }
79
-
80
- .entry[data-active] {
81
- background: var(--dsw-alias-interactive-bg-active);
82
- color: var(--dsw-alias-label-primary);
83
- font-weight: 600;
84
- }
85
-
86
- /* Icon box matches the shell's panel glyph exactly (16px), so the glyph column
87
- and the label column land on the official rows' x positions. */
88
- .entryIcon {
89
- display: inline-flex;
90
- align-items: center;
91
- justify-content: center;
92
- width: 16px;
93
- height: 16px;
94
- flex: none;
95
- }
96
-
97
- .entryIcon svg {
98
- display: block;
99
- width: 16px;
100
- height: 16px;
101
- }
102
-
103
- .entryLabel {
104
- overflow: hidden;
105
- text-overflow: ellipsis;
106
- }
107
-
108
- /* Collapsed rail: icon-only, centered, matching the shell's 56px rail. The
109
- shell asks its own panel rows for an 18px glyph on the rail, so the icon box
110
- and the svg step up together with the 12px rail radius. */
111
- :global([data-dsh-frame][data-sidebar-collapsed]) .entry,
112
- :global([data-sidebar-collapsed]) .entry {
113
- justify-content: center;
114
- padding: 0;
115
- width: 36px;
116
- min-height: 36px;
117
- margin: 0 auto 12px;
118
- border-radius: 12px;
119
- }
120
-
121
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon,
122
- :global([data-sidebar-collapsed]) .entryIcon,
123
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryIcon svg,
124
- :global([data-sidebar-collapsed]) .entryIcon svg {
125
- width: 18px;
126
- height: 18px;
127
- }
128
-
129
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
130
- :global([data-sidebar-collapsed]) .entryLabel {
131
- display: none;
132
- }
133
-
134
12
  /* --- panel frame -------------------------------------------------------------- */
135
13
 
136
14
  .view {
15
+ height: 100%;
16
+ min-height: 0;
137
17
  overflow: hidden;
138
18
  }
139
19
 
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Host-side terminal sessions for the web terminal.
3
+ *
4
+ * The PTY shell is owned here, not by the browser view: a view can detach
5
+ * (panel switch, page unmount, a dropped socket) and reattach by session id
6
+ * without tearing the remote shell down, which is what makes the terminal
7
+ * survival property the old DOM takeover used to provide. A session with no
8
+ * attached socket is reaped after an idle window, and an exited one is kept
9
+ * for a short grace window so a reattaching view still sees the final screen.
10
+ */
11
+ import { randomBytes } from 'node:crypto'
12
+ import { WebSocket } from 'ws'
13
+ import type { KeyboardInteractiveHandler } from './connection-pool.ts'
14
+ import type { ShellSession } from './pty.ts'
15
+ import type { TerminalServerFrame } from '../protocol.ts'
16
+
17
+ /** Pause the shell when an attached socket's send buffer exceeds this… */
18
+ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
19
+
20
+ /** …and resume once every attached socket drains below this. */
21
+ const BACKPRESSURE_LOW_WATER = 512 * 1024
22
+
23
+ /** Scrollback retained for a reattaching view (bytes of UTF-8 output). */
24
+ const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024
25
+
26
+ /** A detached session is reaped after this long. */
27
+ const DEFAULT_IDLE_MS = 10 * 60 * 1000
28
+
29
+ /** An exited session is kept this long for a reattaching view. */
30
+ const DEFAULT_EXIT_GRACE_MS = 60 * 1000
31
+
32
+ /** One live terminal session and the sockets currently watching it. */
33
+ interface HostedTerminal {
34
+ id: string
35
+ alias: string
36
+ shell?: ShellSession
37
+ sockets: Set<WebSocket>
38
+ /** Bounded scrollback replayed to an attaching socket. */
39
+ buffer: string[]
40
+ bufferBytes: number
41
+ exited: boolean
42
+ exit?: { code: number | null; error?: string }
43
+ /** Pending keyboard-interactive challenge, whoever attached when it arrived. */
44
+ pendingAuthFinish?: (responses: string[]) => void
45
+ reapTimer?: ReturnType<typeof setTimeout>
46
+ }
47
+
48
+ /** What the registry needs from the engine (swappable in tests). */
49
+ export interface TerminalSessionDeps {
50
+ /** Open one PTY shell for the alias (engine.openShell). */
51
+ openShell(alias: string, size: { cols: number; rows: number }, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<ShellSession>
52
+ /** @returns the current time (defaults to Date.now). */
53
+ now?: () => number
54
+ /** Bounded scrollback per session. */
55
+ maxBufferBytes?: number
56
+ /** Idle window before a detached session is reaped. */
57
+ idleMs?: number
58
+ /** Grace window an exited session is kept for reattaching views. */
59
+ exitGraceMs?: number
60
+ }
61
+
62
+ /**
63
+ * The host's terminal session table: open one session per connect, attach any
64
+ * number of sockets, and keep the shell alive across detach.
65
+ */
66
+ export class TerminalSessionRegistry {
67
+ private readonly sessions = new Map<string, HostedTerminal>()
68
+ private readonly deps: TerminalSessionDeps
69
+ private readonly maxBufferBytes: number
70
+ private readonly idleMs: number
71
+ private readonly exitGraceMs: number
72
+
73
+ constructor(deps: TerminalSessionDeps) {
74
+ this.deps = deps
75
+ this.maxBufferBytes = deps.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES
76
+ this.idleMs = deps.idleMs ?? DEFAULT_IDLE_MS
77
+ this.exitGraceMs = deps.exitGraceMs ?? DEFAULT_EXIT_GRACE_MS
78
+ }
79
+
80
+ /** Open a new session for the alias and attach the requesting socket to it. */
81
+ async open(ws: WebSocket, alias: string, size: { cols: number; rows: number }): Promise<void> {
82
+ const session: HostedTerminal = {
83
+ id: randomBytes(9).toString('hex'),
84
+ alias,
85
+ sockets: new Set(),
86
+ buffer: [],
87
+ bufferBytes: 0,
88
+ exited: false,
89
+ }
90
+ this.sessions.set(session.id, session)
91
+ this.bind(ws, session)
92
+ try {
93
+ const shell = await this.deps.openShell(alias, size, this.keyboardInteractive(session))
94
+ if (!this.sessions.has(session.id)) {
95
+ // The session was ended (or the host torn down) while it connected.
96
+ shell.close()
97
+ return
98
+ }
99
+ session.shell = shell
100
+ shell.onData = data => { this.pushOutput(session, data.toString('utf8')) }
101
+ shell.onExit = (code, error) => { this.finish(session, code, error) }
102
+ this.send(session, { type: 'ready', alias, sessionId: session.id })
103
+ } catch (error) {
104
+ this.sessions.delete(session.id)
105
+ this.send(session, { type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Attach a socket to an existing session, replaying its scrollback first.
111
+ * @returns false when the session is unknown (expired or already reaped).
112
+ */
113
+ attach(ws: WebSocket, sessionId: string, size: { cols: number; rows: number }): boolean {
114
+ const session = this.sessions.get(sessionId)
115
+ if (session === undefined) return false
116
+ this.bind(ws, session)
117
+ this.clearReap(session)
118
+ for (const chunk of session.buffer) this.sendTo(session, ws, { type: 'output', data: chunk })
119
+ this.sendTo(session, ws, { type: 'ready', alias: session.alias, sessionId: session.id })
120
+ if (session.exited) {
121
+ this.sendTo(session, ws, { type: 'exit', code: session.exit?.code ?? null, error: session.exit?.error })
122
+ // Re-arm the grace window: this socket now holds the exited session open.
123
+ this.scheduleReap(session, this.exitGraceMs)
124
+ } else {
125
+ session.shell?.resize(Math.max(2, size.cols), Math.max(1, size.rows))
126
+ session.shell?.resume()
127
+ }
128
+ return true
129
+ }
130
+
131
+ /** End every session (host teardown, plugin dispose). */
132
+ dispose(): void {
133
+ for (const session of [...this.sessions.values()]) this.end(session, false)
134
+ this.sessions.clear()
135
+ }
136
+
137
+ /** Number of live sessions (introspection for tests and diagnostics). */
138
+ size(): number {
139
+ return this.sessions.size
140
+ }
141
+
142
+ /** Wire one socket's frames and lifecycle onto a session. */
143
+ private bind(ws: WebSocket, session: HostedTerminal): void {
144
+ session.sockets.add(ws)
145
+ ws.on('message', (data) => {
146
+ let frame: { type?: string; data?: string; cols?: number; rows?: number; responses?: string[] }
147
+ try {
148
+ frame = JSON.parse(String(data)) as typeof frame
149
+ } catch {
150
+ return
151
+ }
152
+ if (frame.type === 'input') {
153
+ session.shell?.send(frame.data ?? '')
154
+ } else if (frame.type === 'resize') {
155
+ session.shell?.resize(Math.max(2, frame.cols ?? 80), Math.max(1, frame.rows ?? 24))
156
+ } else if (frame.type === 'auth_response') {
157
+ const finish = session.pendingAuthFinish
158
+ session.pendingAuthFinish = undefined
159
+ try { finish?.(frame.responses ?? []) } catch { /* the connect already failed */ }
160
+ } else if (frame.type === 'close') {
161
+ this.end(session, true)
162
+ } else if (frame.type === 'detach') {
163
+ this.detach(ws)
164
+ }
165
+ })
166
+ ws.on('close', () => { this.detach(ws) })
167
+ ws.on('error', () => { this.detach(ws) })
168
+ }
169
+
170
+ /** Remove a socket from its session, keeping the shell alive. */
171
+ private detach(ws: WebSocket): void {
172
+ for (const session of this.sessions.values()) {
173
+ if (!session.sockets.delete(ws)) continue
174
+ if (session.sockets.size === 0) {
175
+ // Nobody is watching: give the view a window to come back before the
176
+ // remote shell is torn down (or, for an exited one, before its final
177
+ // screen is dropped).
178
+ this.scheduleReap(session, session.exited ? this.exitGraceMs : this.idleMs)
179
+ }
180
+ return
181
+ }
182
+ }
183
+
184
+ /** End the session a socket is attached to (explicit close from the view). */
185
+ private end(session: HostedTerminal, notify: boolean): void {
186
+ this.clearReap(session)
187
+ if (this.sessions.delete(session.id) && notify) {
188
+ if (session.pendingAuthFinish !== undefined) {
189
+ const finish = session.pendingAuthFinish
190
+ session.pendingAuthFinish = undefined
191
+ try { finish([]) } catch { /* the connect already failed */ }
192
+ }
193
+ }
194
+ try { session.shell?.close() } catch { /* channel gone */ }
195
+ }
196
+
197
+ /** Mark a session exited and keep it briefly so a reattaching view sees it. */
198
+ private finish(session: HostedTerminal, code: number | null, error?: string): void {
199
+ if (session.exited) return
200
+ session.exited = true
201
+ session.exit = { code, error }
202
+ this.send(session, { type: 'exit', code, error })
203
+ // The view has its exit frame; close the sockets so neither the browser
204
+ // nor the host holds a dead transport open. The session record itself
205
+ // survives the grace window, so a reattach still replays the final screen.
206
+ for (const ws of [...session.sockets]) {
207
+ session.sockets.delete(ws)
208
+ try { ws.close(1000) } catch { /* already closed */ }
209
+ }
210
+ this.scheduleReap(session, this.exitGraceMs)
211
+ }
212
+
213
+ /** Append output to the replay buffer, trimmed to the byte cap. */
214
+ private pushOutput(session: HostedTerminal, data: string): void {
215
+ session.buffer.push(data)
216
+ session.bufferBytes += Buffer.byteLength(data)
217
+ while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 1) {
218
+ const dropped = session.buffer.shift()
219
+ session.bufferBytes -= Buffer.byteLength(dropped ?? '')
220
+ }
221
+ this.send(session, { type: 'output', data })
222
+ }
223
+
224
+ /** Broadcast one frame to every attached socket, honoring backpressure. */
225
+ private send(session: HostedTerminal, frame: TerminalServerFrame): void {
226
+ for (const ws of session.sockets) this.sendTo(session, ws, frame)
227
+ this.applyBackpressure(session)
228
+ }
229
+
230
+ private sendTo(session: HostedTerminal, ws: WebSocket, frame: TerminalServerFrame): void {
231
+ if (ws.readyState !== WebSocket.OPEN) return
232
+ try { ws.send(JSON.stringify(frame)) } catch { /* socket raced away */ }
233
+ }
234
+
235
+ /** Pause while any attached socket is backed up; resume once all drained. */
236
+ private applyBackpressure(session: HostedTerminal): void {
237
+ const shell = session.shell
238
+ if (shell === undefined) return
239
+ let high = false
240
+ let low = true
241
+ for (const ws of session.sockets) {
242
+ if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) high = true
243
+ if (ws.bufferedAmount > BACKPRESSURE_LOW_WATER) low = false
244
+ }
245
+ if (high) shell.pause()
246
+ else if (low) shell.resume()
247
+ }
248
+
249
+ /** Answer a keyboard-interactive challenge through whoever is attached. */
250
+ private keyboardInteractive(session: HostedTerminal): KeyboardInteractiveHandler {
251
+ return (name, instructions, _lang, prompts, finish) => {
252
+ session.pendingAuthFinish = finish
253
+ this.send(session, {
254
+ type: 'auth_prompt',
255
+ name,
256
+ instructions,
257
+ prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
258
+ })
259
+ }
260
+ }
261
+
262
+ private scheduleReap(session: HostedTerminal, delayMs: number): void {
263
+ this.clearReap(session)
264
+ const timer = setTimeout(() => {
265
+ session.reapTimer = undefined
266
+ if (session.sockets.size > 0) return
267
+ this.end(session, false)
268
+ }, delayMs)
269
+ // Never hold the host process open for an idle terminal.
270
+ ;(timer as { unref?: () => void }).unref?.()
271
+ session.reapTimer = timer
272
+ }
273
+
274
+ private clearReap(session: HostedTerminal): void {
275
+ if (session.reapTimer !== undefined) {
276
+ clearTimeout(session.reapTimer)
277
+ session.reapTimer = undefined
278
+ }
279
+ }
280
+ }
package/src/index.ts CHANGED
@@ -151,7 +151,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
151
151
  }
152
152
 
153
153
  // The /api/dsh-ssh route family + terminal upgrade.
154
- const { routes, upgrade } = makeRoutes({ store, engine })
154
+ const { routes, upgrade, terminalSessions } = makeRoutes({ store, engine })
155
155
  let disposeRoutes: (() => void) | undefined
156
156
 
157
157
  // Agent tools + their prompt sections.
@@ -200,6 +200,9 @@ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
200
200
  return () => {
201
201
  for (const dispose of disposers) dispose()
202
202
  upgradeDisposer()
203
+ // Dropping the routes also ends the terminal sessions they served:
204
+ // a live shell must not outlive the surface that can reach it.
205
+ terminalSessions.dispose()
203
206
  }
204
207
  },
205
208
  'dsh-ssh: routes',