@ljwei-stak/dsh-model-router 0.13.3 → 0.14.0-beta.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.
- package/.dsh-plugin/client.js +10411 -277
- package/.dsh-plugin/index.mjs +50 -2
- package/.dsh-plugin/official-tools-remote-service.mjs +24 -1
- package/.dsh-plugin/shared/cli-terminal-protocol.mjs +138 -0
- package/.dsh-plugin/shared/cli-terminal.mjs +419 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +21 -0
- package/.dsh-plugin/shared/router-state.mjs +28 -1
- package/CHANGELOG.md +23 -0
- package/README.md +13 -0
- package/README.zh.md +13 -0
- package/package.json +9 -2
package/.dsh-plugin/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Host receiver for the official desktop's typed one-click installer RPC. */
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
2
3
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
3
4
|
import { getOfficialTool } from './shared/official-tool-registry.mjs'
|
|
4
5
|
import { officialToolExecutionCapabilities, officialToolReadiness } from './shared/official-tool-executor.mjs'
|
|
@@ -16,6 +17,12 @@ import {
|
|
|
16
17
|
OFFICIAL_TOOLS_REMOTE_NAMESPACE,
|
|
17
18
|
} from './shared/official-tools-remote.mjs'
|
|
18
19
|
|
|
20
|
+
/** Version of the plugin code this Host process actually imported. */
|
|
21
|
+
export const HOST_PLUGIN_VERSION = (() => {
|
|
22
|
+
try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? null }
|
|
23
|
+
catch { return null }
|
|
24
|
+
})()
|
|
25
|
+
|
|
19
26
|
function errorText(error) {
|
|
20
27
|
return error instanceof Error ? error.message : String(error)
|
|
21
28
|
}
|
|
@@ -55,13 +62,29 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
|
|
|
55
62
|
/** Execute a previewed run once every listed reason was confirmed. */
|
|
56
63
|
startRun(request) { return settled(() => (this.services.startRun ?? unavailable)(request)) }
|
|
57
64
|
|
|
65
|
+
/** Terminal backend (PTY or pipe fallback), limits and live sessions. */
|
|
66
|
+
terminalInfo() { return settled(() => (this.services.terminalInfo ?? unavailable)()) }
|
|
67
|
+
|
|
68
|
+
/** Start one confirmed session: the user's shell or a fixed official CLI. */
|
|
69
|
+
terminalStart(request) { return settled(() => (this.services.terminalStart ?? unavailable)(request)) }
|
|
70
|
+
|
|
71
|
+
/** Long-poll new output from a cursor. */
|
|
72
|
+
terminalRead(request) { return settled(() => (this.services.terminalRead ?? unavailable)(request)) }
|
|
73
|
+
|
|
74
|
+
/** Forward keystrokes verbatim; never logged. */
|
|
75
|
+
terminalWrite(request) { return settled(() => (this.services.terminalWrite ?? unavailable)(request)) }
|
|
76
|
+
|
|
77
|
+
terminalResize(request) { return settled(() => (this.services.terminalResize ?? unavailable)(request)) }
|
|
78
|
+
|
|
79
|
+
terminalStop(request) { return settled(() => (this.services.terminalStop ?? unavailable)(request)) }
|
|
80
|
+
|
|
58
81
|
/** Re-probe the local fixed registry; the caller cannot supply a command. */
|
|
59
82
|
async list() {
|
|
60
83
|
const tools = await probeAllTools({ fresh: true })
|
|
61
84
|
const executionReadiness = await Promise.all(tools.map(tool => tool.installed
|
|
62
85
|
? officialToolReadiness(tool.id)
|
|
63
86
|
: Promise.resolve({ id: tool.id, ready: false, reason: 'CLI 尚未安装或版本检测失败。' })))
|
|
64
|
-
return { tools, executionCapabilities: officialToolExecutionCapabilities(), executionReadiness }
|
|
87
|
+
return { tools, executionCapabilities: officialToolExecutionCapabilities(), executionReadiness, hostVersion: HOST_PLUGIN_VERSION }
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
/** Start one serialized fixed-registry install; return immediately for UI polling. */
|
|
@@ -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
|
+
}
|