dsh-ssh-workbench 0.0.0-stage → 1.6.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 (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Server inventory: JSON document under the harness home.
3
+ * Pure data logic; no DSH imports so it stays unit testable without the host
4
+ * runtime.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+ import { serversPath, writePrivateFile, readTextFile } from './paths.js'
8
+
9
+ const PLATFORMS = new Set(['posix', 'windows'])
10
+
11
+ function defaultShellFor(platform) {
12
+ return platform === 'windows' ? 'powershell' : 'bash'
13
+ }
14
+
15
+ function asString(value, fallback = '') {
16
+ return typeof value === 'string' ? value.trim() : fallback
17
+ }
18
+
19
+ function asStringArray(value) {
20
+ if (!Array.isArray(value)) return []
21
+ return value.map((item) => asString(item)).filter(Boolean)
22
+ }
23
+
24
+ /**
25
+ * Validate and canonicalise one stored server record. Throws on records the
26
+ * plugin could never execute, so a corrupt inventory fails loud at the
27
+ * boundary instead of deep inside an SSH call.
28
+ */
29
+ export function normalizeServer(raw, { idFactory = () => `srv_${randomUUID()}` } = {}) {
30
+ if (!raw || typeof raw !== 'object') throw new Error('server record must be an object')
31
+ const name = asString(raw.name)
32
+ const host = asString(raw.host)
33
+ if (!name) throw new Error('server.name is required')
34
+ if (!host) throw new Error(`server "${name}" needs a host`)
35
+ const port = Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536 ? raw.port : 22
36
+ const username = asString(raw.username, 'root') || 'root'
37
+ const platform = PLATFORMS.has(raw.platform) ? raw.platform : 'posix'
38
+ const authRef = normalizeAuthRef(raw.authRef, name)
39
+ return {
40
+ id: asString(raw.id, idFactory()) || idFactory(),
41
+ name,
42
+ host,
43
+ port,
44
+ username,
45
+ platform,
46
+ defaultShell: asString(raw.defaultShell, defaultShellFor(platform)) || defaultShellFor(platform),
47
+ tags: asStringArray(raw.tags),
48
+ description: asString(raw.description),
49
+ enabled: raw.enabled !== false,
50
+ facts: raw.facts && typeof raw.facts === 'object' ? { ...raw.facts } : {},
51
+ authRef,
52
+ }
53
+ }
54
+
55
+ function normalizeAuthRef(raw, serverName) {
56
+ if (!raw || typeof raw !== 'object') throw new Error(`server "${serverName}" needs authRef`)
57
+ if (raw.type === 'sshKey') {
58
+ const keyPath = asString(raw.path)
59
+ if (!keyPath) throw new Error(`server "${serverName}" sshKey authRef needs a path`)
60
+ return { type: 'sshKey', path: keyPath }
61
+ }
62
+ if (raw.type === 'password') return { type: 'password' }
63
+ throw new Error(`server "${serverName}" has unsupported authRef type`)
64
+ }
65
+
66
+ export function loadServers(file = serversPath()) {
67
+ const text = readTextFile(file)
68
+ if (!text.trim()) return []
69
+ let parsed
70
+ try {
71
+ parsed = JSON.parse(text)
72
+ } catch (error) {
73
+ throw new Error(`servers.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
74
+ }
75
+ if (!Array.isArray(parsed)) throw new Error('servers.json must be an array')
76
+ return parsed.map((record) => normalizeServer(record))
77
+ }
78
+
79
+ export function saveServers(servers, file = serversPath()) {
80
+ const normalized = servers.map((record) => normalizeServer(record))
81
+ writePrivateFile(file, `${JSON.stringify(normalized, null, 2)}\n`)
82
+ return normalized
83
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Per-session remote target binding.
3
+ *
4
+ * A session owns at most one *active* target: the managed server its native
5
+ * `bash` tool runs on. `@name` mentions in a human message are the primary
6
+ * input; the `ssh_workbench_target` tool switches the active target explicitly so a
7
+ * conversation that mentions several servers stays unambiguous.
8
+ *
9
+ * State is process-local and keyed by Session id. It is a derived cache, not a
10
+ * source of truth: the durable input is the human messages in the Session log,
11
+ * which `replayTargets()` folds back in when a Session resumes.
12
+ */
13
+ import { loadServers } from './store.js'
14
+
15
+ /** Tool name the model calls to switch targets. */
16
+ export const TARGET_TOOL_NAME = 'ssh_workbench_target'
17
+
18
+ /** Source kind recorded on the injected target-context message. */
19
+ export const TARGET_CONTEXT_KIND = 'ssh-workbench-target'
20
+
21
+ /** The sentinel that detaches the session from any remote target. */
22
+ export const LOCAL_TARGET = 'local'
23
+
24
+ /** Concatenate the text blocks of one message. */
25
+ export function extractUserText(message) {
26
+ if (!message || message.role !== 'user' || !Array.isArray(message.content)) return ''
27
+ return message.content
28
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
29
+ .map((block) => block.text)
30
+ .join('\n')
31
+ }
32
+
33
+ /** Find the latest human-authored user message in a step's messages. */
34
+ export function lastHumanMessage(messages) {
35
+ if (!Array.isArray(messages)) return undefined
36
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
37
+ const message = messages[index]
38
+ if (message && message.role === 'user' && message.source?.kind === 'user') return message
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ /**
44
+ * Match `@name` tokens against the managed inventory (name or id,
45
+ * case-insensitive). Returns matched server records in mention order, without
46
+ * duplicates. Unknown tokens are ignored.
47
+ */
48
+ export function extractServerMentions(text, servers) {
49
+ if (typeof text !== 'string' || !Array.isArray(servers) || servers.length === 0) return []
50
+ const byAlias = new Map()
51
+ for (const server of servers) {
52
+ if (!server || typeof server.name !== 'string' || server.name.length === 0) continue
53
+ byAlias.set(server.name.toLowerCase(), server)
54
+ if (typeof server.id === 'string' && server.id.length > 0) byAlias.set(server.id.toLowerCase(), server)
55
+ }
56
+ // Match known aliases directly instead of a generic `@token` regex: a CJK
57
+ // server name is often followed immediately by CJK text (`@测试机看下磁盘`),
58
+ // so only the known name length tells us where the mention ends.
59
+ const aliases = [...byAlias.keys()].sort((left, right) => right.length - left.length)
60
+ const seen = new Set()
61
+ const matched = []
62
+ for (let index = 0; index < text.length; index += 1) {
63
+ if (text[index] !== '@') continue
64
+ // Like an email address: `ops@example.com` is not a mention.
65
+ const previous = index > 0 ? text[index - 1] : ''
66
+ if (previous && /[A-Za-z0-9._-]/.test(previous)) continue
67
+ for (const alias of aliases) {
68
+ if (text.slice(index + 1, index + 1 + alias.length).toLowerCase() !== alias) continue
69
+ const tail = text[index + 1 + alias.length] ?? ''
70
+ // `@web-12` is a different token than `@web-1`; CJK continuation is fine.
71
+ if (tail && /[A-Za-z0-9._-]/.test(tail)) break
72
+ const server = byAlias.get(alias)
73
+ if (server && !seen.has(server.id ?? server.name)) {
74
+ seen.add(server.id ?? server.name)
75
+ matched.push(server)
76
+ }
77
+ break
78
+ }
79
+ }
80
+ return matched
81
+ }
82
+
83
+ function sameList(left, right) {
84
+ if (left.length !== right.length) return false
85
+ return left.every((value, index) => value === right[index])
86
+ }
87
+
88
+ function freshState() {
89
+ return { candidates: [], active: null, version: 0, injectedVersion: -1, notifiedVersion: -1, seeded: false }
90
+ }
91
+
92
+ /**
93
+ * Fold the mention rules over one session state.
94
+ *
95
+ * - one mention → that server becomes the active target;
96
+ * - several mentions → candidates are remembered, active target is cleared;
97
+ * - no mention → state is unchanged (a follow-up keeps its target).
98
+ */
99
+ export function applyMentions(state, mentions) {
100
+ if (!Array.isArray(mentions) || mentions.length === 0) return state
101
+ const names = mentions.map((server) => server.name)
102
+ if (mentions.length === 1) {
103
+ if (state.active === names[0] && sameList(state.candidates, names)) return state
104
+ return { ...state, candidates: names, active: names[0], version: state.version + 1 }
105
+ }
106
+ // Several mentions never pick a target by guesswork; the model must call the
107
+ // selector tool. The previous active target is dropped for the same reason.
108
+ const active = null
109
+ if (active === state.active && sameList(state.candidates, names)) return state
110
+ return { ...state, candidates: names, active, version: state.version + 1 }
111
+ }
112
+
113
+ /** Replay the mention rules over a Session's historical messages. */
114
+ export function replayTargets(messages, servers) {
115
+ let state = freshState()
116
+ if (Array.isArray(messages)) {
117
+ for (const message of messages) {
118
+ if (message?.role !== 'user' || message.source?.kind !== 'user') continue
119
+ state = applyMentions(state, extractServerMentions(extractUserText(message), servers))
120
+ }
121
+ }
122
+ return state
123
+ }
124
+
125
+ /** Resolve one target reference against the inventory (name or id). */
126
+ export function findTarget(servers, ref) {
127
+ const value = String(ref ?? '').trim()
128
+ if (!value) return undefined
129
+ return servers.find((server) => server.id === value || server.name === value)
130
+ }
131
+
132
+ /**
133
+ * Create the per-session target store.
134
+ * @param {{ loadInventory?: () => object[] }} [options]
135
+ */
136
+ export function createTargetStore(options = {}) {
137
+ const loadInventory = options.loadInventory ?? loadServers
138
+ /** @type {Map<string, object>} */
139
+ const sessions = new Map()
140
+
141
+ function sessionKey(session) {
142
+ if (session === undefined || session === null) return undefined
143
+ if (typeof session === 'string') return session
144
+ const id = session.id
145
+ return typeof id === 'string' && id.length > 0 ? id : undefined
146
+ }
147
+
148
+ function get(key) {
149
+ const sessionId = sessionKey(key)
150
+ if (sessionId === undefined) return undefined
151
+ return sessions.get(sessionId)
152
+ }
153
+
154
+ function ensure(key) {
155
+ const sessionId = sessionKey(key)
156
+ if (sessionId === undefined) return undefined
157
+ let state = sessions.get(sessionId)
158
+ if (state === undefined) {
159
+ state = freshState()
160
+ sessions.set(sessionId, state)
161
+ }
162
+ return state
163
+ }
164
+
165
+ /** Seed one session once from its historical messages (resume support). */
166
+ function ensureSeeded(key, messages, servers = loadInventory()) {
167
+ const state = ensure(key)
168
+ if (state === undefined || state.seeded) return state
169
+ const replayed = replayTargets(messages, servers)
170
+ const next = {
171
+ ...state,
172
+ candidates: replayed.candidates,
173
+ active: replayed.active,
174
+ version: replayed.version,
175
+ seeded: true,
176
+ }
177
+ sessions.set(sessionKey(key), next)
178
+ return next
179
+ }
180
+
181
+ function bindMentions(key, mentions) {
182
+ const state = ensure(key)
183
+ if (state === undefined) return undefined
184
+ const next = { ...applyMentions(state, mentions), seeded: true }
185
+ if (next !== state) sessions.set(sessionKey(key), next)
186
+ return next
187
+ }
188
+
189
+ /** Switch the active target explicitly. `local` detaches. */
190
+ function setActive(key, ref, servers = loadInventory()) {
191
+ const state = ensure(key)
192
+ if (state === undefined) return { ok: false, error: 'session is not addressable' }
193
+ if (String(ref).trim().toLowerCase() === LOCAL_TARGET) {
194
+ // Detaching also clears any pending @a @b ambiguity, so bash is plainly
195
+ // local again instead of staying blocked on "choose a target".
196
+ const next = { ...state, active: null, candidates: [], version: state.version + 1, seeded: true }
197
+ sessions.set(sessionKey(key), next)
198
+ return { ok: true, state: next }
199
+ }
200
+ const server = findTarget(servers, ref)
201
+ if (server === undefined) {
202
+ return { ok: false, error: `unknown server "${String(ref)}"`, state }
203
+ }
204
+ if (server.enabled === false) {
205
+ return { ok: false, error: `server "${server.name}" is disabled`, state }
206
+ }
207
+ const next = {
208
+ ...state,
209
+ active: server.name,
210
+ candidates: state.candidates.includes(server.name) ? state.candidates : [server.name],
211
+ version: state.version + 1,
212
+ seeded: true,
213
+ }
214
+ sessions.set(sessionKey(key), next)
215
+ return { ok: true, state: next, server }
216
+ }
217
+
218
+ function markInjected(key, version) {
219
+ const state = get(key)
220
+ if (state === undefined) return
221
+ if (state.injectedVersion === version) return
222
+ sessions.set(sessionKey(key), { ...state, injectedVersion: version })
223
+ }
224
+
225
+ /**
226
+ * Remember that the bash router already showed the target to the model/UI
227
+ * (one stderr line after each target change), so it is not repeated per call.
228
+ */
229
+ function markNotified(key, version) {
230
+ const state = get(key)
231
+ if (state === undefined) return
232
+ if (state.notifiedVersion === version) return
233
+ sessions.set(sessionKey(key), { ...state, notifiedVersion: version })
234
+ }
235
+
236
+ function clear(key) {
237
+ const sessionId = sessionKey(key)
238
+ if (sessionId !== undefined) sessions.delete(sessionId)
239
+ }
240
+
241
+ function clearAll() {
242
+ sessions.clear()
243
+ }
244
+
245
+ function size() {
246
+ return sessions.size
247
+ }
248
+
249
+ return { get, ensure, ensureSeeded, bindMentions, setActive, markInjected, markNotified, clear, clearAll, size, loadInventory }
250
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Interactive terminal sessions for the workbench.
3
+ *
4
+ * Remote sessions run on the existing ssh2 broker shell channel; local ones use
5
+ * the Host subprocess service's PTY (`spawnTerminal`). The browser renders them
6
+ * with xterm and polls `read`, so the transport needs no new socket protocol
7
+ * and stays on the plugin's authenticated routes.
8
+ *
9
+ * Sessions are process-local, buffered (bounded) and reclaimed after a period
10
+ * of inactivity or when their server/plugin goes away. Closing a session ends
11
+ * the remote channel or the local PTY; nothing survives a Host restart.
12
+ */
13
+ import { randomUUID } from 'node:crypto'
14
+ import { StringDecoder } from 'node:string_decoder'
15
+
16
+ const MAX_BUFFER_CHARS = 512 * 1024
17
+ const MAX_SESSIONS = 12
18
+ const MAX_WAIT_MS = 20000
19
+ const DEFAULT_IDLE_MS = 30 * 60 * 1000
20
+
21
+ function clampDimension(value, fallback, max) {
22
+ const number = Number(value)
23
+ if (!Number.isFinite(number) || number < 1) return fallback
24
+ return Math.min(Math.max(Math.floor(number), 1), max)
25
+ }
26
+
27
+ function describe(error) {
28
+ return error instanceof Error ? error.message : String(error)
29
+ }
30
+
31
+ export function createTerminalHub({ runtime, getSubprocess, getLocalCwd, log = () => {} } = {}) {
32
+ /** @type {Map<string, object>} */
33
+ const sessions = new Map()
34
+ // Slots reserved by opens that are still waiting for their transport. The
35
+ // cap must be checked atomically: `open()` awaits the transport, so counting
36
+ // only inserted sessions lets concurrent calls all pass the same check.
37
+ let pendingOpens = 0
38
+ let disposed = false
39
+
40
+ function notify(session) {
41
+ for (const wake of [...session.waiters]) {
42
+ session.waiters.delete(wake)
43
+ try { wake() } catch { /* a waiter must never break the session */ }
44
+ }
45
+ }
46
+
47
+ function append(session, text) {
48
+ if (!text) return
49
+ session.text += text
50
+ if (session.text.length > MAX_BUFFER_CHARS) {
51
+ let drop = session.text.length - MAX_BUFFER_CHARS
52
+ // Never cut a UTF-16 surrogate pair in half: a slice starting on the low
53
+ // half would leave an orphan surrogate that the client renders as U+FFFD.
54
+ const first = session.text.charCodeAt(drop)
55
+ if (first >= 0xdc00 && first <= 0xdfff) drop += 1
56
+ session.text = session.text.slice(drop)
57
+ session.base += drop
58
+ }
59
+ notify(session)
60
+ }
61
+
62
+ function finish(session, exitCode, signal) {
63
+ if (session.exited) return
64
+ append(session, session.decoder.end())
65
+ session.exited = true
66
+ session.exitCode = Number.isInteger(exitCode) ? exitCode : null
67
+ session.signal = typeof signal === 'string' ? signal : null
68
+ notify(session)
69
+ }
70
+
71
+ function snapshot(session, cursor) {
72
+ let offset = Number.isInteger(cursor) && cursor >= 0 ? cursor : 0
73
+ let reset = false
74
+ if (offset < session.base) {
75
+ reset = true
76
+ offset = session.base
77
+ }
78
+ return {
79
+ session: session.id,
80
+ cursor: session.base + session.text.length,
81
+ data: session.text.slice(offset - session.base),
82
+ reset,
83
+ exited: session.exited,
84
+ exitCode: session.exitCode,
85
+ signal: session.signal,
86
+ title: session.title,
87
+ kind: session.kind,
88
+ }
89
+ }
90
+
91
+ function publicSession(session) {
92
+ return {
93
+ session: session.id,
94
+ kind: session.kind,
95
+ title: session.title,
96
+ server: session.server?.name ?? null,
97
+ cols: session.cols,
98
+ rows: session.rows,
99
+ cwd: session.cwd,
100
+ }
101
+ }
102
+
103
+ function closeSession(session, reason) {
104
+ if (!session || session.closed) return
105
+ session.closed = true
106
+ sessions.delete(session.id)
107
+ // Mark the session exited *before* waking readers so a suspended
108
+ // `/terminal/read` long-poll returns `exited:true` immediately instead of
109
+ // holding the request until its wait timeout (or reporting the stale
110
+ // "not found" of a removed session).
111
+ session.exited = true
112
+ session.exitCode = null
113
+ session.signal = null
114
+ notify(session)
115
+ session.waiters.clear()
116
+ const handle = session.handle
117
+ session.handle = null
118
+ if (handle) {
119
+ try {
120
+ if (typeof handle.close === 'function') handle.close()
121
+ else if (typeof handle.terminate === 'function') handle.terminate()
122
+ } catch (error) {
123
+ log(`terminal close failed (${reason ?? 'close'})`, { error: describe(error) })
124
+ }
125
+ }
126
+ }
127
+
128
+ async function open({ kind, server, cols, rows, cwd } = {}) {
129
+ if (disposed) throw new Error('terminal hub is disposed')
130
+ // Reserve the slot synchronously, before the first await, so two concurrent
131
+ // opens cannot both pass the cap check and overshoot MAX_SESSIONS.
132
+ if (sessions.size + pendingOpens >= MAX_SESSIONS) {
133
+ throw new Error(`too many terminals (${MAX_SESSIONS}); close one first`)
134
+ }
135
+ pendingOpens += 1
136
+ const session = {
137
+ id: `term_${randomUUID()}`,
138
+ kind: kind === 'remote' ? 'remote' : 'local',
139
+ title: kind === 'remote' ? (server?.name ?? 'remote') : 'local',
140
+ server: server ?? null,
141
+ cwd: typeof cwd === 'string' ? cwd : undefined,
142
+ cols: clampDimension(cols, 80, 500),
143
+ rows: clampDimension(rows, 24, 300),
144
+ text: '',
145
+ base: 0,
146
+ waiters: new Set(),
147
+ exited: false,
148
+ exitCode: null,
149
+ signal: null,
150
+ closed: false,
151
+ decoder: new StringDecoder('utf8'),
152
+ handle: null,
153
+ lastUsed: Date.now(),
154
+ }
155
+ try {
156
+ if (session.kind === 'remote') {
157
+ if (!server) throw new Error('remote terminal needs a managed resource')
158
+ const handle = await runtime.shell(server, { cols: session.cols, rows: session.rows })
159
+ session.handle = handle
160
+ session.title = server.name
161
+ handle.onData((data) => {
162
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data))
163
+ append(session, session.decoder.write(buffer))
164
+ })
165
+ handle.onClose(() => finish(session, null, null))
166
+ handle.onError((error) => {
167
+ append(session, `\r\n[remote shell error: ${describe(error)}]\r\n`)
168
+ finish(session, null, null)
169
+ })
170
+ } else {
171
+ const subprocess = getSubprocess?.()
172
+ if (!subprocess || typeof subprocess.spawnTerminal !== 'function') {
173
+ throw new Error('local terminals are unavailable on this host')
174
+ }
175
+ const environment = await subprocess.terminalEnvironment?.()
176
+ // The Host PTY provider names its platform (`windows` on win32) and the
177
+ // preferred shell (ComSpec on Windows, $SHELL/login shell on POSIX).
178
+ // `-i` is a POSIX login-shell flag: cmd.exe/pwsh reject it, so only
179
+ // POSIX shells get it.
180
+ const localPlatform = environment?.platform === 'windows' || (environment?.platform === undefined && process.platform === 'win32')
181
+ ? 'windows'
182
+ : 'posix'
183
+ const shell = environment?.defaultShell || (localPlatform === 'windows' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh')
184
+ const argv = localPlatform === 'windows' ? [shell] : [shell, '-i']
185
+ const handle = await subprocess.spawnTerminal({
186
+ argv,
187
+ cwd: typeof cwd === 'string' && cwd ? cwd : getLocalCwd?.(),
188
+ cols: session.cols,
189
+ rows: session.rows,
190
+ terminalType: 'xterm-256color',
191
+ graceMs: 1000,
192
+ })
193
+ session.handle = {
194
+ write: (data) => handle.write(data),
195
+ resize: (nextCols, nextRows) => handle.resize(nextCols, nextRows),
196
+ close: () => handle.terminate(),
197
+ }
198
+ handle.output.on('data', (data) => {
199
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data))
200
+ append(session, session.decoder.write(buffer))
201
+ })
202
+ handle.done.then(
203
+ (outcome) => finish(session, outcome?.exitCode, outcome?.signal),
204
+ (error) => {
205
+ append(session, `\r\n[local terminal error: ${describe(error)}]\r\n`)
206
+ finish(session, null, null)
207
+ },
208
+ )
209
+ }
210
+ // The hub may have been disposed while the transport was connecting: do
211
+ // not leak a session (or a PTY) into a torn-down hub.
212
+ if (disposed) throw new Error('terminal hub is disposed')
213
+ } catch (error) {
214
+ closeSession(session, 'open failed')
215
+ throw error
216
+ } finally {
217
+ pendingOpens -= 1
218
+ }
219
+ sessions.set(session.id, session)
220
+ session.lastUsed = Date.now()
221
+ return publicSession(session)
222
+ }
223
+
224
+ function input(id, data) {
225
+ const session = sessions.get(id)
226
+ if (!session || session.exited || !session.handle) return false
227
+ session.lastUsed = Date.now()
228
+ session.handle.write(String(data ?? ''))
229
+ return true
230
+ }
231
+
232
+ function resize(id, cols, rows) {
233
+ const session = sessions.get(id)
234
+ if (!session || session.exited || !session.handle) return false
235
+ session.lastUsed = Date.now()
236
+ session.cols = clampDimension(cols, session.cols, 500)
237
+ session.rows = clampDimension(rows, session.rows, 300)
238
+ try {
239
+ session.handle.resize(session.cols, session.rows)
240
+ } catch (error) {
241
+ log('terminal resize failed', { error: describe(error) })
242
+ }
243
+ return true
244
+ }
245
+
246
+ async function read(id, { cursor = 0, waitMs = 0 } = {}) {
247
+ const session = sessions.get(id)
248
+ if (!session) throw new Error('terminal session not found')
249
+ session.lastUsed = Date.now()
250
+ const initial = snapshot(session, cursor)
251
+ const wait = Math.min(Math.max(Number(waitMs) || 0, 0), MAX_WAIT_MS)
252
+ if (initial.data || initial.exited || wait === 0) return initial
253
+ await new Promise((resolve) => {
254
+ const wake = () => {
255
+ clearTimeout(timer)
256
+ session.waiters.delete(wake)
257
+ resolve()
258
+ }
259
+ const timer = setTimeout(wake, wait)
260
+ timer.unref?.()
261
+ session.waiters.add(wake)
262
+ })
263
+ return snapshot(session, cursor)
264
+ }
265
+
266
+ function close(id) {
267
+ const session = sessions.get(id)
268
+ if (!session) return false
269
+ closeSession(session, 'requested')
270
+ return true
271
+ }
272
+
273
+ function closeForServer(serverId) {
274
+ let closed = 0
275
+ for (const session of [...sessions.values()]) {
276
+ if (session.server?.id !== serverId) continue
277
+ closeSession(session, 'server changed')
278
+ closed += 1
279
+ }
280
+ return closed
281
+ }
282
+
283
+ function dispose() {
284
+ disposed = true
285
+ for (const session of [...sessions.values()]) closeSession(session, 'plugin unloaded')
286
+ if (cleanup) clearInterval(cleanup)
287
+ }
288
+
289
+ const cleanup = setInterval(() => {
290
+ const cutoff = Date.now() - DEFAULT_IDLE_MS
291
+ for (const session of [...sessions.values()]) {
292
+ if (session.waiters.size > 0) continue
293
+ if (session.lastUsed < cutoff) closeSession(session, 'idle')
294
+ }
295
+ }, 60 * 1000)
296
+ cleanup.unref?.()
297
+
298
+ return { open, read, input, resize, close, closeForServer, dispose, count: () => sessions.size }
299
+ }