@ljwei-stak/dsh-model-router 0.13.2 → 0.14.0-beta.1

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.
@@ -19,6 +19,14 @@ import { routeBoundaries } from './shared/security-boundaries.mjs'
19
19
  import { createPlanFromRoutes } from './shared/harness-plan.mjs'
20
20
  import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
21
21
  import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
22
+ import { createTerminalManager } from './shared/cli-terminal.mjs'
23
+ import {
24
+ parseTerminalRead,
25
+ parseTerminalResize,
26
+ parseTerminalStart,
27
+ parseTerminalStop,
28
+ parseTerminalWrite,
29
+ } from './shared/cli-terminal-protocol.mjs'
22
30
  import {
23
31
  getOfficialTool,
24
32
  installCommandLine,
@@ -35,6 +43,7 @@ import {
35
43
  installStatus,
36
44
  installedToolIds,
37
45
  defaultRunner,
46
+ ensureNpmPrefixOnPath,
38
47
  } from './shared/official-tools-runtime.mjs'
39
48
 
40
49
  // Cordis plugin name, matching the profile entry id; unchanged by the 0.13.0 package rename.
@@ -1254,8 +1263,38 @@ export async function startWorkbenchRun(ctx, config, request = {}, options = {})
1254
1263
  }
1255
1264
 
1256
1265
  /** Host operations behind the workbench RPC; the client never supplies commands or paths. */
1257
- export function routerRemoteServices(ctx, config) {
1266
+ /**
1267
+ * One terminal manager per Host plugin fiber. Sessions run outside the
1268
+ * Harness sandbox with the Host's login environment; only metadata is kept.
1269
+ */
1270
+ export function createRouterTerminals(options = {}) {
1271
+ return createTerminalManager({
1272
+ prepare: () => ensureNpmPrefixOnPath(),
1273
+ onSessionEnd: record => { void routerStateStore().appendTerminalSession(record).catch(() => {}) },
1274
+ ...options,
1275
+ })
1276
+ }
1277
+
1278
+ async function terminalHistory(limit = 20) {
1279
+ const saved = await savedState()
1280
+ return (saved.terminalSessions ?? []).slice(-limit).reverse()
1281
+ }
1282
+
1283
+ function terminalServices(terminals) {
1284
+ if (!terminals) return {}
1258
1285
  return {
1286
+ terminalInfo: async () => ({ ...terminals.info(), history: await terminalHistory() }),
1287
+ terminalStart: async request => terminals.start(parseTerminalStart(request)),
1288
+ terminalRead: async request => terminals.read(parseTerminalRead(request)),
1289
+ terminalWrite: async request => terminals.write(parseTerminalWrite(request)),
1290
+ terminalResize: async request => terminals.resize(parseTerminalResize(request)),
1291
+ terminalStop: async request => terminals.stop(parseTerminalStop(request)),
1292
+ }
1293
+ }
1294
+
1295
+ export function routerRemoteServices(ctx, config, { terminals = null } = {}) {
1296
+ return {
1297
+ ...terminalServices(terminals),
1259
1298
  health: async fresh => {
1260
1299
  const report = await toolHealthReport({ fresh: fresh === true })
1261
1300
  const saved = await savedState()
@@ -1279,7 +1318,16 @@ export function routerRemoteServices(ctx, config) {
1279
1318
  export function apply(ctx, config = {}) {
1280
1319
  // The official Host injects typert; direct lightweight uses of apply may
1281
1320
  // supply only the model/command services and do not expose the Desktop RPC.
1282
- if (ctx.typert) registerOfficialToolsRemote(ctx, routerRemoteServices(ctx, config))
1321
+ if (ctx.typert) {
1322
+ const terminals = createRouterTerminals()
1323
+ // Plugin unload or patch reload kills every interactive session.
1324
+ ctx.effect(() => {
1325
+ const onExit = () => { terminals.disposeAll('dispose') }
1326
+ process.once('exit', onExit)
1327
+ return () => { process.off('exit', onExit); terminals.disposeAll('dispose') }
1328
+ }, 'model-router-galgame: interactive terminals')
1329
+ registerOfficialToolsRemote(ctx, routerRemoteServices(ctx, config, { terminals }))
1330
+ }
1283
1331
  ctx.on('tools/pre-execute', async (exec, next) => {
1284
1332
  const decision = await next()
1285
1333
  if (decision.kind !== 'allow') return decision
@@ -55,6 +55,22 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
55
55
  /** Execute a previewed run once every listed reason was confirmed. */
56
56
  startRun(request) { return settled(() => (this.services.startRun ?? unavailable)(request)) }
57
57
 
58
+ /** Terminal backend (PTY or pipe fallback), limits and live sessions. */
59
+ terminalInfo() { return settled(() => (this.services.terminalInfo ?? unavailable)()) }
60
+
61
+ /** Start one confirmed session: the user's shell or a fixed official CLI. */
62
+ terminalStart(request) { return settled(() => (this.services.terminalStart ?? unavailable)(request)) }
63
+
64
+ /** Long-poll new output from a cursor. */
65
+ terminalRead(request) { return settled(() => (this.services.terminalRead ?? unavailable)(request)) }
66
+
67
+ /** Forward keystrokes verbatim; never logged. */
68
+ terminalWrite(request) { return settled(() => (this.services.terminalWrite ?? unavailable)(request)) }
69
+
70
+ terminalResize(request) { return settled(() => (this.services.terminalResize ?? unavailable)(request)) }
71
+
72
+ terminalStop(request) { return settled(() => (this.services.terminalStop ?? unavailable)(request)) }
73
+
58
74
  /** Re-probe the local fixed registry; the caller cannot supply a command. */
59
75
  async list() {
60
76
  const tools = await probeAllTools({ fresh: true })
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Request contract for the workbench "官方工具终端" (interactive official CLI
3
+ * terminal). Pure data and validation: imported by the Host session manager,
4
+ * the Typert descriptors and the Desktop client.
5
+ *
6
+ * A client may only name a fixed launch target: the user's own shell or one
7
+ * registry CLI, optionally in its fixed login mode. It never supplies an
8
+ * executable, argument vector or environment. Everything the user types goes
9
+ * into that interactive process, exactly as in their own terminal, so a
10
+ * session starts only after an explicit confirmation.
11
+ */
12
+ import { getOfficialTool } from './official-tool-registry.mjs'
13
+
14
+ /** Registry CLIs that have an interactive terminal UI (ZCode is a desktop app). */
15
+ export const TERMINAL_TOOL_IDS = Object.freeze(['codex', 'claude-code', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build', 'gemini'])
16
+ export const TERMINAL_SHELL_ID = 'shell'
17
+ export const TERMINAL_TARGETS = Object.freeze([TERMINAL_SHELL_ID, ...TERMINAL_TOOL_IDS])
18
+ export const TERMINAL_MODES = Object.freeze(['interactive', 'login'])
19
+
20
+ export const TERMINAL_LIMITS = Object.freeze({
21
+ maxSessions: 4,
22
+ /** A session nobody reads (panel closed, client gone) is killed after this. */
23
+ orphanTimeoutMs: 120_000,
24
+ /** Hard lifetime of one session. */
25
+ maxLifetimeMs: 6 * 60 * 60_000,
26
+ /** Output kept per session for the client to catch up. */
27
+ bufferChars: 1_000_000,
28
+ /** Largest output slice returned by one read. */
29
+ readChars: 256_000,
30
+ /** Longest long-poll wait for new output. */
31
+ maxWaitMs: 1_000,
32
+ /** Largest single input write (a paste). */
33
+ writeChars: 65_536,
34
+ minCols: 10, maxCols: 500, minRows: 3, maxRows: 300,
35
+ })
36
+
37
+ /**
38
+ * Fixed login commands, derived from the documented tool login commands
39
+ * (LOGIN_GUIDES in tool-health.mjs). Gemini signs in from its interactive UI.
40
+ */
41
+ export const TERMINAL_LOGIN_ARGS = Object.freeze({
42
+ codex: Object.freeze(['login']),
43
+ 'claude-code': Object.freeze(['auth', 'login']),
44
+ 'kimi-code': Object.freeze(['login']),
45
+ 'minimax-code': Object.freeze(['login']),
46
+ 'mimo-code': Object.freeze(['auth', 'login']),
47
+ 'grok-build': Object.freeze(['login']),
48
+ gemini: Object.freeze([]),
49
+ })
50
+
51
+ const SESSION_ID = /^term-[a-z0-9]{6,40}$/
52
+
53
+ function plainObject(value, subject) {
54
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${subject} must be an object`)
55
+ return value
56
+ }
57
+
58
+ function sessionIdOf(value) {
59
+ if (typeof value !== 'string' || !SESSION_ID.test(value)) throw new TypeError('sessionId is invalid')
60
+ return value
61
+ }
62
+
63
+ function boundedInteger(value, min, max, subject) {
64
+ if (!Number.isInteger(value) || value < min || value > max) throw new TypeError(`${subject} must be an integer from ${min} to ${max}`)
65
+ return value
66
+ }
67
+
68
+ /** Absolute path on either platform; the Host checks that it exists. */
69
+ export function isAbsoluteDirectoryText(value) {
70
+ if (typeof value !== 'string' || !value.trim() || value.length > 4_096 || value.includes('\0')) return false
71
+ const path = value.trim()
72
+ return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path)
73
+ }
74
+
75
+ /** Display label of a launch target. */
76
+ export function terminalTargetLabel(target) {
77
+ if (target === TERMINAL_SHELL_ID) return '系统终端'
78
+ return getOfficialTool(target)?.label ?? String(target)
79
+ }
80
+
81
+ /** Command shown to the user before starting (display only; the Host builds the real launch). */
82
+ export function terminalCommandPreview(target, mode = 'interactive', platform = 'win32') {
83
+ if (target === TERMINAL_SHELL_ID) return platform === 'win32' ? 'powershell.exe -NoLogo' : '$SHELL -l'
84
+ const tool = getOfficialTool(target)
85
+ const executable = tool?.probeExecutables?.[0] ?? String(target)
86
+ const args = mode === 'login' ? TERMINAL_LOGIN_ARGS[target] ?? [] : []
87
+ return [executable, ...args].join(' ')
88
+ }
89
+
90
+ export function parseTerminalStart(value) {
91
+ const request = plainObject(value, 'terminal start request')
92
+ if (!TERMINAL_TARGETS.includes(request.target)) throw new TypeError('target must be the shell or a fixed official CLI')
93
+ const mode = request.mode === undefined ? 'interactive' : request.mode
94
+ if (!TERMINAL_MODES.includes(mode)) throw new TypeError('mode must be interactive or login')
95
+ if (mode === 'login' && request.target === TERMINAL_SHELL_ID) throw new TypeError('the shell has no login mode')
96
+ if (!isAbsoluteDirectoryText(request.cwd)) throw new TypeError('cwd must be an absolute directory path')
97
+ if (request.confirmed !== true) throw new TypeError('starting a terminal requires the user\'s confirmation')
98
+ return {
99
+ target: request.target,
100
+ mode,
101
+ cwd: request.cwd.trim(),
102
+ cols: boundedInteger(request.cols ?? 100, TERMINAL_LIMITS.minCols, TERMINAL_LIMITS.maxCols, 'cols'),
103
+ rows: boundedInteger(request.rows ?? 30, TERMINAL_LIMITS.minRows, TERMINAL_LIMITS.maxRows, 'rows'),
104
+ confirmed: true,
105
+ }
106
+ }
107
+
108
+ export function parseTerminalRead(value) {
109
+ const request = plainObject(value, 'terminal read request')
110
+ const cursor = request.cursor ?? 0
111
+ if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('cursor must be a non-negative integer')
112
+ const waitMs = request.waitMs ?? 0
113
+ return { sessionId: sessionIdOf(request.sessionId), cursor, waitMs: boundedInteger(waitMs, 0, TERMINAL_LIMITS.maxWaitMs, 'waitMs') }
114
+ }
115
+
116
+ export function parseTerminalWrite(value) {
117
+ const request = plainObject(value, 'terminal write request')
118
+ if (typeof request.data !== 'string' || request.data.length === 0 || request.data.length > TERMINAL_LIMITS.writeChars) {
119
+ throw new TypeError(`data must be 1 to ${TERMINAL_LIMITS.writeChars} characters`)
120
+ }
121
+ return { sessionId: sessionIdOf(request.sessionId), data: request.data }
122
+ }
123
+
124
+ export function parseTerminalResize(value) {
125
+ const request = plainObject(value, 'terminal resize request')
126
+ return {
127
+ sessionId: sessionIdOf(request.sessionId),
128
+ cols: boundedInteger(request.cols, TERMINAL_LIMITS.minCols, TERMINAL_LIMITS.maxCols, 'cols'),
129
+ rows: boundedInteger(request.rows, TERMINAL_LIMITS.minRows, TERMINAL_LIMITS.maxRows, 'rows'),
130
+ }
131
+ }
132
+
133
+ export function parseTerminalStop(value) {
134
+ const request = plainObject(value, 'terminal stop request')
135
+ return { sessionId: sessionIdOf(request.sessionId) }
136
+ }
137
+
138
+ export const isTerminalSessionId = value => typeof value === 'string' && SESSION_ID.test(value)
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Host session manager for the workbench "官方工具终端".
3
+ *
4
+ * Starts the user's own shell or one fixed registry CLI in a pseudo terminal
5
+ * (prebuilt @lydell/node-pty, N-API, no build tools), outside the Harness
6
+ * process sandbox and with the Host's full login environment so the CLI's own
7
+ * account login works exactly as in the user's terminal. When the native PTY
8
+ * cannot load, sessions fall back to plain pipes with documented limits.
9
+ *
10
+ * Output is buffered per session for the client's long-poll reads; input is
11
+ * forwarded verbatim and never logged or persisted. Only session metadata
12
+ * (target, directory, duration, exit code) is recorded.
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { stat } from 'node:fs/promises'
17
+ import { createRequire } from 'node:module'
18
+ import { StringDecoder } from 'node:string_decoder'
19
+ import { extname } from 'node:path'
20
+ import { getOfficialTool } from './official-tool-registry.mjs'
21
+ import {
22
+ TERMINAL_LIMITS,
23
+ TERMINAL_LOGIN_ARGS,
24
+ TERMINAL_SHELL_ID,
25
+ TERMINAL_TOOL_IDS,
26
+ terminalTargetLabel,
27
+ } from './cli-terminal-protocol.mjs'
28
+
29
+ export const PTY_PACKAGE = '@lydell/node-pty'
30
+ export const PIPE_LIMITATION = '未能加载伪终端(PTY)组件,已改用管道模式:程序看不到真实终端,全屏界面(如 codex、claude 的交互界面)可能无法显示或拒绝启动,窗口大小不会同步,方向键等按键可能无效,输入由插件回显。登录命令(如 codex login)、普通命令行和 PowerShell 一般可用。'
31
+
32
+ const REMOVE_EXITED_AFTER_MS = 60_000
33
+ const SWEEP_INTERVAL_MS = 10_000
34
+ const LOCATE_TIMEOUT_MS = 8_000
35
+
36
+ let cachedPty = null
37
+
38
+ /**
39
+ * Load the prebuilt PTY once. Returns { ok, pty } or { ok: false, error }.
40
+ * `requireFrom` is injectable for tests.
41
+ */
42
+ export function loadPtyModule({ requireFrom = createRequire(import.meta.url), fresh = false } = {}) {
43
+ if (cachedPty && !fresh) return cachedPty
44
+ try {
45
+ const pty = requireFrom(PTY_PACKAGE)
46
+ if (typeof pty?.spawn !== 'function') throw new Error(`${PTY_PACKAGE} 没有 spawn()`)
47
+ cachedPty = { ok: true, pty, package: PTY_PACKAGE }
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error)
50
+ cachedPty = { ok: false, error: message.split('\n')[0].slice(0, 400), package: PTY_PACKAGE }
51
+ }
52
+ return cachedPty
53
+ }
54
+
55
+ /**
56
+ * Environment for an interactive session: the Host's own (the user's login
57
+ * environment, including any npm prefix the plugin put on PATH), not the
58
+ * sandbox's allow-listed minimum. ELECTRON_RUN_AS_NODE is removed so Electron
59
+ * apps started from the terminal (for example `code`) open normally.
60
+ */
61
+ export function terminalEnvironment(env = process.env) {
62
+ const result = {}
63
+ for (const [key, value] of Object.entries(env)) {
64
+ if (typeof value !== 'string') continue
65
+ if (key.toUpperCase() === 'ELECTRON_RUN_AS_NODE') continue
66
+ result[key] = value
67
+ }
68
+ result.TERM = 'xterm-256color'
69
+ result.COLORTERM = 'truecolor'
70
+ return result
71
+ }
72
+
73
+ const WINDOWS_PREFERENCE = ['.exe', '.com', '.cmd', '.bat', '.ps1']
74
+
75
+ /** Pick the executable Windows would run, preferring native binaries over shims. */
76
+ export function pickWindowsExecutable(candidates) {
77
+ const usable = candidates.map(item => String(item).trim()).filter(Boolean)
78
+ for (const extension of WINDOWS_PREFERENCE) {
79
+ const found = usable.find(item => extname(item).toLowerCase() === extension)
80
+ if (found) return found
81
+ }
82
+ return null
83
+ }
84
+
85
+ const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
86
+
87
+ /**
88
+ * Build the fixed launch for one target. `locate(executable)` returns the
89
+ * candidate paths `where`/`which` printed. Returns
90
+ * { file, args, verbatim, display } where `verbatim` means `args` is one
91
+ * pre-quoted Windows command-line tail.
92
+ */
93
+ export async function resolveTerminalLaunch(target, mode = 'interactive', {
94
+ platform = process.platform, env = process.env, locate = defaultLocate,
95
+ } = {}) {
96
+ if (target === TERMINAL_SHELL_ID) {
97
+ if (platform === 'win32') {
98
+ const root = env.SystemRoot || env.SYSTEMROOT || env.windir || 'C:\\Windows'
99
+ const file = `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
100
+ return { file, args: ['-NoLogo'], verbatim: false, display: 'powershell.exe -NoLogo' }
101
+ }
102
+ const shell = env.SHELL && env.SHELL.startsWith('/') ? env.SHELL : '/bin/bash'
103
+ return { file: shell, args: ['-l'], verbatim: false, display: `${shell} -l` }
104
+ }
105
+ if (!TERMINAL_TOOL_IDS.includes(target)) throw new Error('只能启动系统终端或固定注册表中的官方 CLI。')
106
+ const tool = getOfficialTool(target)
107
+ const executable = tool.probeExecutables[0]
108
+ const args = mode === 'login' ? [...(TERMINAL_LOGIN_ARGS[target] ?? [])] : []
109
+ const display = [executable, ...args].join(' ')
110
+ const candidates = await locate(executable, { platform, env })
111
+ if (platform !== 'win32') {
112
+ const file = candidates.map(item => String(item).trim()).find(item => item.startsWith('/'))
113
+ if (!file) throw new Error(`未在 PATH 中找到 ${executable};请先在“官方工具 · 体检”中安装 ${tool.label}。`)
114
+ return { file, args, verbatim: false, display }
115
+ }
116
+ const file = pickWindowsExecutable(candidates)
117
+ if (!file) throw new Error(`未在 PATH 中找到 ${executable};请先在“官方工具 · 体检”中安装 ${tool.label}。`)
118
+ const extension = extname(file).toLowerCase()
119
+ if (extension === '.cmd' || extension === '.bat') {
120
+ // npm shims only run under cmd.exe. Every token is fixed by the registry.
121
+ const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
122
+ const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
123
+ return { file: comspec, args: tail, verbatim: true, display }
124
+ }
125
+ if (extension === '.ps1') {
126
+ const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
127
+ return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
128
+ }
129
+ return { file, args, verbatim: false, display }
130
+ }
131
+
132
+ /** Locate an executable with where.exe / which (fixed registry names only). */
133
+ export function defaultLocate(executable, { platform = process.platform, env = process.env } = {}) {
134
+ return new Promise(resolve => {
135
+ let stdout = ''
136
+ let child
137
+ try {
138
+ child = spawn(platform === 'win32' ? 'where' : 'which', platform === 'win32' ? [executable] : ['-a', executable], {
139
+ stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env, shell: false,
140
+ })
141
+ } catch { resolve([]); return }
142
+ const timer = setTimeout(() => { try { child.kill() } catch { /* gone */ } }, LOCATE_TIMEOUT_MS)
143
+ child.stdout.on('data', chunk => { stdout += chunk })
144
+ child.on('error', () => { clearTimeout(timer); resolve([]) })
145
+ child.on('close', () => { clearTimeout(timer); resolve(stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean)) })
146
+ })
147
+ }
148
+
149
+ /** PTY-backed process adapter. */
150
+ function spawnPtyProcess(pty, launch, { cwd, cols, rows, env }) {
151
+ const proc = pty.spawn(launch.file, launch.args, { name: 'xterm-256color', cols, rows, cwd, env })
152
+ return {
153
+ pid: proc.pid,
154
+ onData: listener => { proc.onData(listener) },
155
+ onExit: listener => { proc.onExit(event => listener({ exitCode: event?.exitCode ?? null, signal: event?.signal ?? null })) },
156
+ write: data => { proc.write(data) },
157
+ resize: (nextCols, nextRows) => { proc.resize(nextCols, nextRows); return true },
158
+ kill: () => { proc.kill() },
159
+ }
160
+ }
161
+
162
+ /** Pipe fallback: no TTY; the Host echoes input and maps Enter/Ctrl+C. */
163
+ export function spawnPipeProcess(launch, { cwd, env, platform = process.platform, spawnImpl = spawn }) {
164
+ const child = spawnImpl(launch.file, launch.args, {
165
+ cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false,
166
+ windowsVerbatimArguments: launch.verbatim === true,
167
+ })
168
+ const stdoutDecoder = new StringDecoder('utf8')
169
+ const stderrDecoder = new StringDecoder('utf8')
170
+ const listeners = { data: [], exit: [] }
171
+ let exited = false
172
+ const emit = text => { if (text) for (const listener of listeners.data) listener(text.replace(/(?<!\r)\n/g, '\r\n')) }
173
+ child.stdout?.on('data', chunk => emit(stdoutDecoder.write(chunk)))
174
+ child.stderr?.on('data', chunk => emit(stderrDecoder.write(chunk)))
175
+ const finish = (exitCode, signal) => {
176
+ if (exited) return
177
+ exited = true
178
+ emit(stdoutDecoder.end())
179
+ emit(stderrDecoder.end())
180
+ for (const listener of listeners.exit) listener({ exitCode, signal })
181
+ }
182
+ child.on('error', error => { emit(`\r\n[启动失败] ${error?.message ?? error}\r\n`); finish(null, null) })
183
+ child.on('close', (code, signal) => finish(code, signal))
184
+ const newline = platform === 'win32' ? '\r\n' : '\n'
185
+ const kill = () => {
186
+ if (exited) return
187
+ if (platform === 'win32' && child.pid) {
188
+ try { spawnImpl('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }) } catch { /* fall through */ }
189
+ }
190
+ try { child.kill() } catch { /* gone */ }
191
+ }
192
+ return {
193
+ pid: child.pid,
194
+ onData: listener => { listeners.data.push(listener) },
195
+ onExit: listener => { listeners.exit.push(listener) },
196
+ write: data => {
197
+ if (data === '\x03') { emit('^C\n'); kill(); return }
198
+ const text = data.replace(/\r\n?/g, '\n')
199
+ // Local echo: the child has no terminal to echo the line for us.
200
+ emit(data.replace(/\x7f/g, '\b \b').replace(/\r(?!\n)/g, '\n'))
201
+ try { child.stdin?.write(text.replace(/\n/g, newline)) } catch { /* closed */ }
202
+ },
203
+ resize: () => false,
204
+ kill,
205
+ }
206
+ }
207
+
208
+ function newSessionId(random = randomBytes) {
209
+ return `term-${random(12).toString('hex')}`
210
+ }
211
+
212
+ /**
213
+ * Create the per-Host session manager. Every dependency is injectable so the
214
+ * test suite never spawns a real terminal.
215
+ */
216
+ export function createTerminalManager({
217
+ loadPty = loadPtyModule,
218
+ spawnPipe = spawnPipeProcess,
219
+ resolveLaunch = resolveTerminalLaunch,
220
+ statPath = stat,
221
+ prepare = async () => {},
222
+ environment = () => terminalEnvironment(process.env),
223
+ onSessionEnd = () => {},
224
+ now = Date.now,
225
+ platform = process.platform,
226
+ limits = TERMINAL_LIMITS,
227
+ randomId = newSessionId,
228
+ setTimer = setTimeout,
229
+ clearTimer = clearTimeout,
230
+ setRepeating = setInterval,
231
+ clearRepeating = clearInterval,
232
+ } = {}) {
233
+ const sessions = new Map()
234
+ let sweeper = null
235
+ let disposed = false
236
+
237
+ const backendInfo = () => {
238
+ const loaded = loadPty()
239
+ return loaded.ok
240
+ ? { backend: 'pty', package: loaded.package }
241
+ : { backend: 'pipe', package: loaded.package, ptyError: loaded.error, limitation: PIPE_LIMITATION }
242
+ }
243
+
244
+ const summary = session => ({
245
+ sessionId: session.id, target: session.target, label: terminalTargetLabel(session.target), mode: session.mode,
246
+ cwd: session.cwd, backend: session.backend, display: session.display, startedAt: session.startedAt,
247
+ exited: session.exited, exitCode: session.exitCode, endReason: session.endReason,
248
+ })
249
+
250
+ const wake = session => {
251
+ const waiters = [...session.waiters]
252
+ session.waiters.clear()
253
+ for (const resolve of waiters) resolve()
254
+ }
255
+
256
+ const append = (session, text) => {
257
+ if (!text) return
258
+ session.text += text
259
+ const overflow = session.text.length - limits.bufferChars
260
+ if (overflow > 0) {
261
+ session.text = session.text.slice(overflow)
262
+ session.base += overflow
263
+ }
264
+ wake(session)
265
+ }
266
+
267
+ const finish = (session, { exitCode = null, signal = null } = {}) => {
268
+ if (session.exited) return
269
+ session.exited = true
270
+ session.exitCode = exitCode
271
+ session.signal = signal
272
+ session.finishedAt = now()
273
+ session.endReason ??= 'exit'
274
+ wake(session)
275
+ try {
276
+ onSessionEnd({
277
+ id: session.id, target: session.target, label: terminalTargetLabel(session.target), mode: session.mode,
278
+ cwd: session.cwd, backend: session.backend, startedAt: session.startedAt, finishedAt: session.finishedAt,
279
+ durationMs: Math.max(0, session.finishedAt - session.startedAt), exitCode, endReason: session.endReason,
280
+ })
281
+ } catch { /* recording must never break the terminal */ }
282
+ }
283
+
284
+ const terminate = (session, reason) => {
285
+ if (session.exited) return
286
+ session.endReason ??= reason
287
+ try { session.proc.kill() } catch { /* already gone */ }
288
+ // Some processes ignore the kill; report the session as ended regardless.
289
+ session.killTimer = setTimer(() => finish(session, { exitCode: null }), 5_000)
290
+ session.killTimer?.unref?.()
291
+ }
292
+
293
+ const sweep = () => {
294
+ const at = now()
295
+ for (const session of sessions.values()) {
296
+ if (!session.exited) {
297
+ if (at - session.lastReadAt > limits.orphanTimeoutMs) terminate(session, 'orphan')
298
+ else if (at - session.startedAt > limits.maxLifetimeMs) terminate(session, 'lifetime')
299
+ } else if (at - session.finishedAt > REMOVE_EXITED_AFTER_MS) {
300
+ sessions.delete(session.id)
301
+ }
302
+ }
303
+ if (sessions.size === 0 && sweeper !== null) { clearRepeating(sweeper); sweeper = null }
304
+ }
305
+
306
+ const ensureSweeper = () => {
307
+ if (sweeper !== null) return
308
+ sweeper = setRepeating(sweep, SWEEP_INTERVAL_MS)
309
+ sweeper?.unref?.()
310
+ }
311
+
312
+ const get = id => {
313
+ const session = sessions.get(id)
314
+ if (!session) throw new Error('终端会话不存在或已结束。')
315
+ return session
316
+ }
317
+
318
+ return {
319
+ info() {
320
+ return { ...backendInfo(), platform, limits, sessions: [...sessions.values()].map(summary) }
321
+ },
322
+
323
+ async start(request) {
324
+ if (disposed) throw new Error('插件正在卸载,无法启动终端。')
325
+ if (request?.confirmed !== true) throw new Error('启动终端前需要你的确认。')
326
+ const live = [...sessions.values()].filter(session => !session.exited).length
327
+ if (live >= limits.maxSessions) throw new Error(`最多同时运行 ${limits.maxSessions} 个终端会话,请先结束一个。`)
328
+ let info
329
+ try { info = await statPath(request.cwd) } catch { info = null }
330
+ if (!info?.isDirectory?.()) throw new Error(`工作目录不存在或不是目录:${request.cwd}`)
331
+ await prepare()
332
+ const launch = await resolveLaunch(request.target, request.mode, { platform })
333
+ const env = environment()
334
+ const backend = backendInfo()
335
+ const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env, platform }
336
+ let proc
337
+ try {
338
+ proc = backend.backend === 'pty'
339
+ ? spawnPtyProcess(loadPty().pty, launch, options)
340
+ : spawnPipe(launch, options)
341
+ } catch (error) {
342
+ throw new Error(`无法启动 ${launch.display}:${error instanceof Error ? error.message : String(error)}`)
343
+ }
344
+ const at = now()
345
+ const session = {
346
+ id: randomId(), target: request.target, mode: request.mode, cwd: request.cwd, display: launch.display,
347
+ backend: backend.backend, proc, startedAt: at, lastReadAt: at, finishedAt: null,
348
+ text: '', base: 0, waiters: new Set(), exited: false, exitCode: null, signal: null, endReason: null, killTimer: null,
349
+ }
350
+ sessions.set(session.id, session)
351
+ proc.onData(data => append(session, data))
352
+ proc.onExit(event => {
353
+ if (session.killTimer) clearTimer(session.killTimer)
354
+ finish(session, event)
355
+ })
356
+ ensureSweeper()
357
+ return { ...summary(session), pid: proc.pid ?? null, ...(backend.backend === 'pipe' ? { limitation: PIPE_LIMITATION, ptyError: backend.ptyError } : {}) }
358
+ },
359
+
360
+ async read({ sessionId, cursor = 0, waitMs = 0 }) {
361
+ const session = get(sessionId)
362
+ session.lastReadAt = now()
363
+ const end = () => session.base + session.text.length
364
+ if (cursor >= end() && !session.exited && waitMs > 0) {
365
+ await new Promise(resolve => {
366
+ const timer = setTimer(() => { session.waiters.delete(done); resolve() }, waitMs)
367
+ const done = () => { clearTimer(timer); resolve() }
368
+ session.waiters.add(done)
369
+ })
370
+ session.lastReadAt = now()
371
+ }
372
+ const dropped = cursor < session.base
373
+ const from = Math.min(Math.max(cursor, session.base), end())
374
+ const slice = session.text.slice(from - session.base, from - session.base + limits.readChars)
375
+ const next = from + slice.length
376
+ return {
377
+ sessionId, data: slice, cursor: next, dropped,
378
+ exited: session.exited && next >= end(), exitCode: session.exitCode, endReason: session.endReason,
379
+ }
380
+ },
381
+
382
+ write({ sessionId, data }) {
383
+ const session = get(sessionId)
384
+ if (session.exited) throw new Error('终端会话已结束。')
385
+ session.proc.write(data)
386
+ return { written: data.length }
387
+ },
388
+
389
+ resize({ sessionId, cols, rows }) {
390
+ const session = get(sessionId)
391
+ if (session.exited) return { resized: false }
392
+ let resized = false
393
+ try { resized = session.proc.resize(cols, rows) === true } catch { resized = false }
394
+ return { resized }
395
+ },
396
+
397
+ stop({ sessionId }) {
398
+ const session = sessions.get(sessionId)
399
+ if (!session) return { stopped: false, alreadyEnded: true }
400
+ terminate(session, 'stopped')
401
+ return { stopped: true }
402
+ },
403
+
404
+ /** Kill every session (plugin unload, Host exit). */
405
+ disposeAll(reason = 'dispose') {
406
+ disposed = true
407
+ for (const session of sessions.values()) {
408
+ if (session.exited) continue
409
+ session.endReason ??= reason
410
+ try { session.proc.kill() } catch { /* gone */ }
411
+ finish(session, { exitCode: null })
412
+ }
413
+ if (sweeper !== null) { clearRepeating(sweeper); sweeper = null }
414
+ },
415
+
416
+ /** Test hook: run the orphan/lifetime sweep now. */
417
+ sweep,
418
+ }
419
+ }