dsh-vscode-mode 0.11.0 → 0.13.0
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/README.md +158 -111
- package/assets/shell/dsh-open.cs +65 -149
- package/assets/shell/dsh-open.mjs +241 -0
- package/assets/shell/dsh-open.ps1 +28 -47
- package/assets/shell/dsh-open.sh +25 -66
- package/lib/client.js +13362 -11806
- package/lib/client.js.map +1 -1
- package/lib/index.js +4110 -2507
- package/lib/index.js.map +1 -1
- package/package.json +9 -8
- package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
- package/src/childEnv.ts +19 -0
- package/src/client/activePathStore.ts +28 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +14 -42
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +101 -18
- package/src/client/keybindings.ts +36 -73
- package/src/client/monaco/loader.ts +3 -1
- package/src/client/openFlow.ts +36 -28
- package/src/client/openReceipt.ts +64 -0
- package/src/client/panelCommands.ts +101 -0
- package/src/client/pdf/pdfLoader.ts +4 -2
- package/src/client/receiptState.ts +120 -0
- package/src/client/rpc.ts +30 -4
- package/src/client/shortcutRecorder.ts +161 -0
- package/src/client/shortcutsOfficial.ts +460 -0
- package/src/client/sidebar/panels/RulesPanel.ts +15 -11
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +246 -137
- package/src/client/ui/McpSettings.ts +10 -8
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +16 -16
- package/src/client/ui/ShortcutSettings.ts +274 -0
- package/src/client/ui/commandCatalog.ts +39 -43
- package/src/client/useOpenReceipt.ts +128 -0
- package/src/compat.ts +5 -3
- package/src/dap/manager.ts +46 -13
- package/src/devForm.ts +252 -92
- package/src/externalHandoff.ts +93 -52
- package/src/fileOpenSettings.ts +4 -24
- package/src/hostImport.ts +19 -0
- package/src/httpGuard.ts +101 -0
- package/src/index.ts +106 -97
- package/src/integrate.ts +56 -50
- package/src/lsp/transport.ts +84 -127
- package/src/mcp.ts +58 -9
- package/src/mcpIsolation.ts +211 -102
- package/src/mcpProject.ts +220 -133
- package/src/mcpRuntime.ts +343 -0
- package/src/openBridge.ts +69 -0
- package/src/openInbox.ts +207 -0
- package/src/paths.ts +8 -5
- package/src/processTree.ts +63 -0
- package/src/reveal.ts +5 -3
- package/src/routes.ts +156 -154
- package/src/rpc.ts +14 -13
- package/src/rules.ts +100 -44
- package/src/shared/appUrl.ts +19 -0
- package/src/shared/externalOpen.ts +71 -0
- package/src/shared/integration.ts +2 -0
- package/src/shared/keybindings.ts +256 -0
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/rules.ts +9 -1
- package/src/shared/svn.ts +6 -8
- package/src/svnPlanTool.ts +51 -0
- package/src/unityBridge.ts +26 -3
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
- package/unity/com.dsh.editor/README.md +16 -25
- package/unity/com.dsh.editor/package.json +2 -2
- package/src/client/ui/KeybindingsPanel.ts +0 -190
package/src/integrate.ts
CHANGED
|
@@ -4,17 +4,19 @@
|
|
|
4
4
|
* ~/.dsh/dsh-vscode-mode/shell/,HKCU\Software\Classes 三类菜单键(`*`/Directory/Directory\Background)。
|
|
5
5
|
* - Linux:dsh-open.sh + GNOME Files(Nautilus)右键脚本 + KDE Dolphin 服务菜单(纯文件写入)。
|
|
6
6
|
* - macOS:dsh-open.sh(Finder 快速操作由用户按配方手动创建,自动检测/移除)。
|
|
7
|
-
* 生命周期:注册成功写 marker
|
|
8
|
-
*
|
|
7
|
+
* 生命周期:注册成功写 marker;启动按 marker 升级 launcher 文件,不反复重写系统注册。
|
|
8
|
+
* 用户显式「移除注册」删 marker,后续启动不会恢复。
|
|
9
9
|
* 所有 reg/csc 调用经 subprocess 服务 argv 数组(卸载清理用 child_process 直调,teardown 可靠)。
|
|
10
10
|
* 纯函数(键路径/命令行/ini/脚本内容)可单测。
|
|
11
11
|
* 作者 ddj 2026-09-07
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { childEnv } from './childEnv.js'
|
|
14
14
|
import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
15
15
|
import { homedir } from 'node:os'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
17
|
import { assetsDirOf, dshHome, PLUGIN_ID } from './paths.js'
|
|
18
|
+
import { findProfileDir } from './devForm.js'
|
|
19
|
+
import { openIni, writeOpenConfig } from './openBridge.js'
|
|
18
20
|
import { INTEGRATION_BASE_DEFAULT, SHELL_MENU_LEAF } from './shared/integration.js'
|
|
19
21
|
import { unitySourceDir } from './unityBridge.js'
|
|
20
22
|
import type { ShellIntegrationStatus, ShellLauncherState, ShellMenuEntry } from './shared/integration.js'
|
|
@@ -53,7 +55,7 @@ export const WHALE_ICO = 'dsh-whale.ico'
|
|
|
53
55
|
interface RunOutcome { code: number | null; output: string }
|
|
54
56
|
|
|
55
57
|
/** 注册 marker 内容。 */
|
|
56
|
-
interface RegistrationMarker { baseUrl: string; at: string }
|
|
58
|
+
interface RegistrationMarker { baseUrl: string; at: string; transport?: number; profile?: string }
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
61
|
* reg.exe 路径(SystemRoot 派生;env 可注入便于测试)。
|
|
@@ -172,8 +174,8 @@ export function ps1CommandValue(ps1Path: string, token: string = FILE_TOKEN, pow
|
|
|
172
174
|
* @param baseUrl 深链基址
|
|
173
175
|
* @returns ini 文件全文
|
|
174
176
|
*/
|
|
175
|
-
export function iniContent(baseUrl: string): string {
|
|
176
|
-
return
|
|
177
|
+
export function iniContent(baseUrl: string, metadata: Record<string, string> = {}): string {
|
|
178
|
+
return openIni(baseUrl, metadata)
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
/**
|
|
@@ -186,7 +188,7 @@ export function nautilusScript(launcherPath: string): string {
|
|
|
186
188
|
return [
|
|
187
189
|
'#!/usr/bin/env bash',
|
|
188
190
|
'# 由 dsh-vscode-mode 生成:GNOME Files(Nautilus)右键脚本 → DSH 文件编辑',
|
|
189
|
-
'launcher="
|
|
191
|
+
'launcher=' + "'" + launcherPath.replace(/'/g, "'\"'\"'") + "'",
|
|
190
192
|
'args=()',
|
|
191
193
|
'while IFS= read -r line; do',
|
|
192
194
|
' [ -n "$line" ] && args+=("$line")',
|
|
@@ -251,6 +253,7 @@ async function runArgv(ctx: Ctx, argv: string[], cwd = dshHome()): Promise<RunOu
|
|
|
251
253
|
handle = sub.spawn({
|
|
252
254
|
argv,
|
|
253
255
|
cwd,
|
|
256
|
+
env: childEnv(),
|
|
254
257
|
stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
|
|
255
258
|
graceMs: 10000,
|
|
256
259
|
})
|
|
@@ -292,17 +295,17 @@ async function keyExists(ctx: Ctx, key: string): Promise<boolean> {
|
|
|
292
295
|
/** 读注册 marker(缺失/非法 → null)。 */
|
|
293
296
|
async function readMarker(home = dshHome()): Promise<RegistrationMarker | null> {
|
|
294
297
|
try {
|
|
295
|
-
const data = JSON.parse(await readFile(markerFile(home), 'utf8')) as { baseUrl?: unknown; at?: unknown }
|
|
298
|
+
const data = JSON.parse(await readFile(markerFile(home), 'utf8')) as { baseUrl?: unknown; at?: unknown; transport?: unknown; profile?: unknown }
|
|
296
299
|
if (typeof data?.baseUrl !== 'string' || !data.baseUrl) return null
|
|
297
|
-
return { baseUrl: data.baseUrl, at: typeof data.at === 'string' ? data.at : '' }
|
|
300
|
+
return { baseUrl: data.baseUrl, at: typeof data.at === 'string' ? data.at : '', transport: typeof data.transport === 'number' ? data.transport : undefined, profile: typeof data.profile === 'string' ? data.profile : undefined }
|
|
298
301
|
} catch {
|
|
299
302
|
return null
|
|
300
303
|
}
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
/** 写注册 marker(父目录已存在)。 */
|
|
304
|
-
async function saveMarker(baseUrl: string, home = dshHome()): Promise<void> {
|
|
305
|
-
const data: RegistrationMarker = { baseUrl, at: new Date().toISOString() }
|
|
307
|
+
async function saveMarker(baseUrl: string, home = dshHome(), profile?: string): Promise<void> {
|
|
308
|
+
const data: RegistrationMarker = { baseUrl, at: new Date().toISOString(), transport: 1, profile }
|
|
306
309
|
await writeFile(markerFile(home), JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
307
310
|
}
|
|
308
311
|
|
|
@@ -310,9 +313,9 @@ async function saveMarker(baseUrl: string, home = dshHome()): Promise<void> {
|
|
|
310
313
|
async function installPosix(dir: string, baseUrl: string): Promise<void> {
|
|
311
314
|
await copyFile(join(assetsDirOf(import.meta.url), 'shell', LAUNCHER_SH), join(dir, LAUNCHER_SH))
|
|
312
315
|
await chmod(join(dir, LAUNCHER_SH), 0o755)
|
|
313
|
-
await writeFile(join(dir, INI_NAME), iniContent(baseUrl), 'utf8')
|
|
314
316
|
}
|
|
315
317
|
|
|
318
|
+
|
|
316
319
|
/** 写 Nautilus 右键脚本(父目录按需创建 + 执行位)。 */
|
|
317
320
|
async function writeNautilus(home: string, launcherPath: string): Promise<void> {
|
|
318
321
|
const script = nautilusPath(home)
|
|
@@ -348,19 +351,6 @@ async function dropFinderMenu(home: string): Promise<void> {
|
|
|
348
351
|
await rm(dir, { recursive: true, force: true })
|
|
349
352
|
}
|
|
350
353
|
|
|
351
|
-
/** 卸载清理专用:直调 reg delete(subprocess 服务在 teardown 可能已卸载)。⚠️ stdio 'ignore':受管环境禁管道。 */
|
|
352
|
-
function regDeleteDirect(key: string): Promise<void> {
|
|
353
|
-
return new Promise((resolve) => {
|
|
354
|
-
try {
|
|
355
|
-
const child = spawn(regExe(), ['delete', key, '/f'], { windowsHide: true, timeout: 5000, stdio: 'ignore' })
|
|
356
|
-
child.once('close', () => resolve())
|
|
357
|
-
child.once('error', () => resolve())
|
|
358
|
-
} catch {
|
|
359
|
-
resolve()
|
|
360
|
-
}
|
|
361
|
-
})
|
|
362
|
-
}
|
|
363
|
-
|
|
364
354
|
/**
|
|
365
355
|
* 查询系统集成状态(按平台探测条目注册状态 + launcher 安装态)。
|
|
366
356
|
* @author ddj 2026年09月07号
|
|
@@ -412,7 +402,6 @@ export async function shellMenuRegister(ctx: Ctx, baseUrl: string): Promise<Shel
|
|
|
412
402
|
const dir = shellInstallDir()
|
|
413
403
|
await mkdir(dir, { recursive: true })
|
|
414
404
|
if (process.platform === 'win32') {
|
|
415
|
-
await writeFile(join(dir, INI_NAME), iniContent(baseUrl), 'utf8')
|
|
416
405
|
await installIcon(dir)
|
|
417
406
|
const compiled = await installLauncher(ctx, dir, true)
|
|
418
407
|
if (!(await readMarker())) await backupKeys(ctx, dir)
|
|
@@ -426,7 +415,8 @@ export async function shellMenuRegister(ctx: Ctx, baseUrl: string): Promise<Shel
|
|
|
426
415
|
// macOS 菜单由用户按 Automator 配方手动创建(UI 提供一键复制);这里仅安装 launcher
|
|
427
416
|
await installPosix(dir, baseUrl)
|
|
428
417
|
}
|
|
429
|
-
await
|
|
418
|
+
await writeOpenConfig(ctx, dir, baseUrl)
|
|
419
|
+
await saveMarker(baseUrl, dshHome(), findProfileDir(ctx))
|
|
430
420
|
return shellMenuStatus(ctx, baseUrl)
|
|
431
421
|
}
|
|
432
422
|
|
|
@@ -454,38 +444,40 @@ export async function shellMenuRemove(ctx: Ctx, baseUrl: string): Promise<ShellI
|
|
|
454
444
|
|
|
455
445
|
/**
|
|
456
446
|
* 系统集成生命周期(index.ts 经 ctx.effect 挂载):
|
|
457
|
-
*
|
|
458
|
-
*
|
|
447
|
+
* 启动时仅升级用户已注册的 launcher 文件与配置;保留系统注册和明确选中的 profile。
|
|
448
|
+
* 卸载时取消后续迁移步骤;移除系统注册只由用户显式操作触发。
|
|
459
449
|
* @author ddj 2026年09月07号
|
|
460
450
|
* @param ctx DSH 上下文
|
|
461
451
|
* @returns 卸载清理 disposer
|
|
462
452
|
*/
|
|
463
453
|
export function shellMenuLifecycle(ctx: Ctx): () => void {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
454
|
+
let disposed = false
|
|
455
|
+
void restoreOnLoad(ctx, () => disposed).catch((error) => console.warn('[dsh-vscode-mode] launcher migration failed:', error))
|
|
456
|
+
return () => { disposed = true }
|
|
468
457
|
}
|
|
469
458
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
/** 卸载清理:marker 存在 → 按平台移除注册痕迹(launcher 文件与 marker 保留)。 */
|
|
478
|
-
async function cleanupOnUnload(): Promise<void> {
|
|
459
|
+
/** @private @author ddj 2026年09月28号
|
|
460
|
+
* Upgrade installed assets only; OS registration remains user-owned across reloads.
|
|
461
|
+
* @param ctx Host profile context.
|
|
462
|
+
* @param disposed Whether lifecycle disposal has started.
|
|
463
|
+
*/
|
|
464
|
+
async function restoreOnLoad(ctx: Ctx, disposed: () => boolean): Promise<void> {
|
|
479
465
|
const marker = await readMarker()
|
|
480
|
-
if (!marker) return
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
466
|
+
if (!marker || disposed()) return
|
|
467
|
+
const profile = findProfileDir(ctx)
|
|
468
|
+
if (!profile || (marker.profile && marker.profile !== profile)) return
|
|
469
|
+
const dir = shellInstallDir()
|
|
470
|
+
const baseUrl = await storedOpenBase(dir, marker.baseUrl)
|
|
471
|
+
if (marker.transport !== 1) {
|
|
472
|
+
if (process.platform === 'win32') {
|
|
473
|
+
const hadExe = await fileExists(join(dir, LAUNCHER_EXE))
|
|
474
|
+
const compiled = await installLauncher(ctx, dir, true)
|
|
475
|
+
if (hadExe && !compiled) throw new Error('旧 launcher 编译升级失败,请在设置中重新注册')
|
|
476
|
+
} else await installPosix(dir, baseUrl)
|
|
488
477
|
}
|
|
478
|
+
if (disposed()) return
|
|
479
|
+
await writeOpenConfig(ctx, dir, baseUrl)
|
|
480
|
+
if (!disposed()) await saveMarker(baseUrl, dshHome(), profile)
|
|
489
481
|
}
|
|
490
482
|
|
|
491
483
|
/**
|
|
@@ -500,6 +492,7 @@ async function installLauncher(ctx: Ctx, dir: string, force: boolean): Promise<b
|
|
|
500
492
|
const exe = join(dir, LAUNCHER_EXE)
|
|
501
493
|
if (!force && await fileExists(exe)) return true
|
|
502
494
|
const shellAssets = join(assetsDirOf(import.meta.url), 'shell')
|
|
495
|
+
await copyFile(join(shellAssets, LAUNCHER_PS1), join(dir, LAUNCHER_PS1))
|
|
503
496
|
for (const candidate of cscCandidates()) {
|
|
504
497
|
if (!(await fileExists(candidate))) continue
|
|
505
498
|
try {
|
|
@@ -574,3 +567,16 @@ async function addMenuKey(ctx: Ctx, key: string, iconPath: string, command: stri
|
|
|
574
567
|
await runArgv(ctx, [reg, 'add', key, '/v', REG_VALUE_NAME_ICON, '/t', 'REG_SZ', '/d', iconPath, '/f'])
|
|
575
568
|
await runArgv(ctx, [reg, 'add', key + '\\command', '/ve', '/t', 'REG_SZ', '/d', command, '/f'])
|
|
576
569
|
}
|
|
570
|
+
|
|
571
|
+
/** @private @author ddj 2026年09月28号
|
|
572
|
+
* Preserve a manually configured public URL during launcher migration.
|
|
573
|
+
* @param dir Launcher directory.
|
|
574
|
+
* @param fallback Registered legacy address.
|
|
575
|
+
* @returns Existing base value, or the marker's address.
|
|
576
|
+
*/
|
|
577
|
+
async function storedOpenBase(dir: string, fallback: string): Promise<string> {
|
|
578
|
+
try {
|
|
579
|
+
const text = await readFile(join(dir, INI_NAME), 'utf8')
|
|
580
|
+
return /^base\s*=\s*(.+)$/im.exec(text)?.[1]?.trim() || fallback
|
|
581
|
+
} catch { return fallback }
|
|
582
|
+
}
|
package/src/lsp/transport.ts
CHANGED
|
@@ -1,180 +1,137 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* 抽象:LSP 需要持续写 stdin + 流式读 stdout,而该抽象 stdin 不可写、输出为一次性收集。
|
|
5
|
-
* 作者 ddj 2026-08-27
|
|
6
|
-
*/
|
|
7
|
-
import { spawn, type ChildProcess } from 'node:child_process'
|
|
1
|
+
/** LSP 双向 stdio 传输;外部进程使用清理后的环境及独立进程组。 */
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import type { ChildProcess, ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
8
4
|
import { createFrameParser } from './jsonrpc.js'
|
|
5
|
+
import { childEnv } from '../childEnv.js'
|
|
6
|
+
import { killTree, stopTree } from '../processTree.js'
|
|
9
7
|
|
|
10
8
|
const STDERR_CAP = 256 << 10
|
|
11
|
-
|
|
12
9
|
export interface TransportSpec {
|
|
13
10
|
argv: string[]
|
|
14
11
|
cwd?: string
|
|
15
12
|
env?: Record<string, string>
|
|
16
13
|
}
|
|
17
|
-
|
|
18
14
|
export interface Transport {
|
|
19
|
-
/** 写一行帧到 stdin(自动加换行)。 */
|
|
20
15
|
write(chunk: Buffer): boolean
|
|
21
|
-
/** 推送 stdout 解析出的原始消息(内部由帧解析器调用)。 */
|
|
22
16
|
onMessage: ((message: unknown) => void) | null
|
|
23
|
-
/** stderr 收集缓冲(有界,供诊断)。 */
|
|
24
17
|
stderr(): string
|
|
25
|
-
/** 进程退出/崩溃回调(code/signal)。 */
|
|
26
18
|
onExit: ((code: number | null, signal: string | null) => void) | null
|
|
27
|
-
/** 优雅关闭后强杀(Windows 下连带进程树)。 */
|
|
28
19
|
dispose(): void
|
|
29
|
-
/** 是否仍存活。 */
|
|
30
20
|
readonly alive: boolean
|
|
31
21
|
readonly pid: number | undefined
|
|
32
22
|
}
|
|
33
|
-
|
|
34
23
|
const running = new Set<ChildProcess>()
|
|
35
24
|
|
|
36
|
-
/**
|
|
37
|
-
export function liveChildren(): ReadonlySet<ChildProcess> {
|
|
38
|
-
return running
|
|
39
|
-
}
|
|
25
|
+
/** 当前仍归本模块管理的进程。 */
|
|
26
|
+
export function liveChildren(): ReadonlySet<ChildProcess> { return running }
|
|
40
27
|
|
|
41
28
|
/**
|
|
42
|
-
*
|
|
43
|
-
* @
|
|
44
|
-
* @
|
|
45
|
-
* @param
|
|
46
|
-
* @param
|
|
47
|
-
* @
|
|
29
|
+
* 建立传输状态与回收路径;根进程退出也立即回收剩余进程组。
|
|
30
|
+
* @private
|
|
31
|
+
* @author ddj 2026年09月28号
|
|
32
|
+
* @param child 新建子进程
|
|
33
|
+
* @param onMessage 协议消息回调
|
|
34
|
+
* @param logger 诊断回调
|
|
35
|
+
* @returns 传输句柄与错误缓冲写入器
|
|
48
36
|
*/
|
|
49
|
-
|
|
50
|
-
const { argv, cwd, env } = spec
|
|
51
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
52
|
-
cwd,
|
|
53
|
-
env: { ...process.env, ...env },
|
|
54
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
-
windowsHide: true,
|
|
56
|
-
})
|
|
57
|
-
running.add(child)
|
|
58
|
-
let stderrBuf = Buffer.alloc(0)
|
|
37
|
+
function makeTransport(child: ChildProcessWithoutNullStreams, onMessage: (message: unknown) => void, logger?: (line: string) => void) {
|
|
59
38
|
let alive = true
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (stderrBuf.length < STDERR_CAP) {
|
|
64
|
-
stderrBuf = Buffer.concat([stderrBuf, chunk], Math.min(stderrBuf.length + chunk.length, STDERR_CAP))
|
|
65
|
-
}
|
|
66
|
-
logger?.(chunk.toString('utf8'))
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
let exited = false
|
|
70
|
-
const emitExit = (code: number | null, signal: string | null): void => {
|
|
71
|
-
if (exited) return
|
|
72
|
-
exited = true
|
|
73
|
-
alive = false
|
|
74
|
-
running.delete(child)
|
|
75
|
-
transport.onExit?.(code, signal)
|
|
76
|
-
}
|
|
77
|
-
child.on('error', (error) => {
|
|
78
|
-
logger?.('spawn error: ' + String(error))
|
|
79
|
-
emitExit(null, null)
|
|
80
|
-
})
|
|
81
|
-
child.on('exit', (code, signal) => emitExit(code, signal))
|
|
82
|
-
child.on('close', (code, signal) => emitExit(code, signal))
|
|
83
|
-
|
|
39
|
+
let disposed = false
|
|
40
|
+
let stderrBuf = Buffer.alloc(0)
|
|
41
|
+
let finish: (() => void) | undefined
|
|
84
42
|
const transport: Transport = {
|
|
43
|
+
/** @public @author ddj 2026年09月28号 @param chunk 协议帧 @returns 是否可写 */
|
|
85
44
|
write(chunk: Buffer): boolean {
|
|
86
45
|
if (!alive || !child.stdin.writable) return false
|
|
87
46
|
child.stdin.write(chunk)
|
|
88
47
|
return true
|
|
89
48
|
},
|
|
90
49
|
onMessage,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
50
|
+
/** @public @author ddj 2026年09月28号 @returns 有界错误缓冲 */
|
|
51
|
+
stderr(): string { return stderrBuf.toString('utf8') },
|
|
94
52
|
onExit: null,
|
|
53
|
+
/** @public @author ddj 2026年09月28号 @description 停止整个进程树,根退出不取消回收。 */
|
|
95
54
|
dispose(): void {
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
child
|
|
99
|
-
const timer = setTimeout(() => {
|
|
100
|
-
if (!alive) return
|
|
101
|
-
alive = false
|
|
102
|
-
running.delete(child)
|
|
103
|
-
try {
|
|
104
|
-
if (process.platform === 'win32') {
|
|
105
|
-
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
106
|
-
} else {
|
|
107
|
-
child.kill('SIGKILL')
|
|
108
|
-
}
|
|
109
|
-
} catch (error) {
|
|
110
|
-
logger?.('force kill error: ' + String(error))
|
|
111
|
-
}
|
|
112
|
-
}, 1500)
|
|
113
|
-
timer.unref?.()
|
|
114
|
-
},
|
|
115
|
-
get alive() {
|
|
116
|
-
return alive
|
|
117
|
-
},
|
|
118
|
-
get pid() {
|
|
119
|
-
return child.pid
|
|
55
|
+
if (disposed) return
|
|
56
|
+
disposed = true
|
|
57
|
+
finish = stopTree(child, logger)
|
|
120
58
|
},
|
|
59
|
+
get alive() { return alive },
|
|
60
|
+
get pid() { return child.pid },
|
|
61
|
+
}
|
|
62
|
+
/** @private @author ddj 2026年09月28号 @param code 退出码 @param signal 退出信号 */
|
|
63
|
+
const emitExit = (code: number | null, signal: string | null): void => {
|
|
64
|
+
if (!alive) return
|
|
65
|
+
alive = false
|
|
66
|
+
disposed = true
|
|
67
|
+
if (finish) finish()
|
|
68
|
+
else killTree(child, logger)
|
|
69
|
+
running.delete(child)
|
|
70
|
+
transport.onExit?.(code, signal)
|
|
121
71
|
}
|
|
122
|
-
|
|
72
|
+
child.on('error', (error) => { logger?.('spawn error: ' + String(error)); emitExit(null, null) })
|
|
73
|
+
child.on('exit', emitExit)
|
|
74
|
+
child.on('close', emitExit)
|
|
75
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
76
|
+
if (stderrBuf.length < STDERR_CAP) stderrBuf = Buffer.concat([stderrBuf, chunk], Math.min(stderrBuf.length + chunk.length, STDERR_CAP))
|
|
77
|
+
logger?.(chunk.toString('utf8'))
|
|
78
|
+
})
|
|
79
|
+
return transport
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 启动语言服务器并接入协议流,显式 env 在父环境清理后覆盖。
|
|
84
|
+
* @public
|
|
85
|
+
* @author ddj 2026年09月28号
|
|
86
|
+
* @param spec 启动参数,不经 shell
|
|
87
|
+
* @param onMessage stdout 协议消息回调
|
|
88
|
+
* @param logger 诊断日志
|
|
89
|
+
* @returns 传输句柄
|
|
90
|
+
* @throws spawn 的同步参数错误
|
|
91
|
+
*/
|
|
92
|
+
export function spawnServer(spec: TransportSpec, onMessage: (message: unknown) => void, logger?: (line: string) => void): Transport {
|
|
93
|
+
const child = spawn(spec.argv[0], spec.argv.slice(1), {
|
|
94
|
+
cwd: spec.cwd,
|
|
95
|
+
env: childEnv(spec.env),
|
|
96
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
detached: process.platform !== 'win32',
|
|
99
|
+
})
|
|
100
|
+
running.add(child)
|
|
101
|
+
const transport = makeTransport(child, onMessage, logger)
|
|
102
|
+
const parser = createFrameParser()
|
|
123
103
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
124
104
|
for (const message of parser.push(chunk)) transport.onMessage?.(message)
|
|
125
105
|
})
|
|
106
|
+
// 写入管道在退出竞态中可能异步 EPIPE,必须监听以免宿主未处理异常。
|
|
107
|
+
child.stdin.on('error', (error) => logger?.('stdin error: ' + String(error)))
|
|
126
108
|
return transport
|
|
127
109
|
}
|
|
128
110
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
} catch (error) {
|
|
139
|
-
/* 忽略单个清理失败 */
|
|
140
|
-
}
|
|
141
|
-
}
|
|
111
|
+
/**
|
|
112
|
+
* 回收本模块所有进程树。
|
|
113
|
+
* @public
|
|
114
|
+
* @author ddj 2026年09月28号
|
|
115
|
+
* @param sync 宿主 exit 钩子使用同步 taskkill
|
|
116
|
+
*/
|
|
117
|
+
export function disposeAllServers(sync = false): void {
|
|
118
|
+
for (const child of running) killTree(child, undefined, sync)
|
|
142
119
|
running.clear()
|
|
143
120
|
}
|
|
144
121
|
|
|
145
|
-
/** 进程退出回收是否已注册(防重复挂监听)。 */
|
|
146
122
|
let exitHooked = false
|
|
147
|
-
|
|
148
123
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* 长期占用内存与工作区文件句柄)。
|
|
154
|
-
*
|
|
155
|
-
* ⚠️ 只回收、不退出:宿主自身已注册 SIGINT/SIGTERM 处理(DSH profile-boot 走
|
|
156
|
-
* shutdown.interrupt → 优雅关闭 + 状态落盘),这里若调 process.exit 会打断该流程。
|
|
157
|
-
* 因此仅在信号到达时顺带回收子进程,退出时机与退出码仍由宿主决定。
|
|
158
|
-
*
|
|
159
|
-
* ⚠️ 信号监听只在宿主已有监听时才挂:Node 的默认信号语义(终止进程)会被首个监听器
|
|
160
|
-
* 取代,若本插件是唯一监听者,挂上去反而让 Ctrl+C 不再退出宿主。无既有监听即跳过,
|
|
161
|
-
* 交由 'exit' 事件兜底。
|
|
162
|
-
*
|
|
163
|
-
* exit 事件下只能同步执行,Windows 的 child.kill 不走进程树,故复用
|
|
164
|
-
* disposeAllServers 的 taskkill /T /F 强杀路径;best-effort,失败不阻塞退出。
|
|
165
|
-
* @author ddj 2026年09月11号
|
|
124
|
+
* 注册宿主退出回收;不夺取宿主原有信号退出语义。
|
|
125
|
+
* @public
|
|
126
|
+
* @author ddj 2026年09月28号
|
|
127
|
+
* @description exit 用同步回收;仅在宿主已有信号监听时追加收尾。
|
|
166
128
|
*/
|
|
167
129
|
export function hookExitReclaim(): void {
|
|
168
130
|
if (exitHooked) return
|
|
169
131
|
exitHooked = true
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
disposeAllServers()
|
|
173
|
-
} catch (error) { /* 退出路径静默 */ }
|
|
174
|
-
}
|
|
175
|
-
process.on('exit', reclaim)
|
|
132
|
+
process.on('exit', () => disposeAllServers(true))
|
|
176
133
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
177
134
|
if (process.listenerCount(signal) === 0) continue
|
|
178
|
-
process.on(signal,
|
|
135
|
+
process.on(signal, () => disposeAllServers())
|
|
179
136
|
}
|
|
180
137
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from './compat.js'
|
|
13
13
|
import type { MpcConfig, MpcServer, MpcTool } from './shared/mcp.js'
|
|
14
14
|
import type { Ctx } from './store.js'
|
|
15
|
+
import { checkMcpName } from './mcpProject.js'
|
|
15
16
|
|
|
16
17
|
export { LEGACY_PROJECT_PREFIX, MCP_PACKAGE, PROJECT_ENTRY_PREFIX, isProjectEntryId }
|
|
17
18
|
|
|
@@ -31,10 +32,17 @@ export function publicConfig(config: Record<string, unknown>): MpcConfig {
|
|
|
31
32
|
return out as unknown as MpcConfig
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Read tool summaries from the caller's exact visible scope.
|
|
37
|
+
* @public @author ddj 2026年09月28号
|
|
38
|
+
* @param ctx Host tools service.
|
|
39
|
+
* @param serverName Server namespace.
|
|
40
|
+
* @param agent Optional agent scope; omitted for global entries.
|
|
41
|
+
* @returns Scoped tool summaries, allowing resource-only servers to have zero tools.
|
|
42
|
+
*/
|
|
43
|
+
export function toolsOf(ctx: Ctx, serverName: string, agent?: any): MpcTool[] {
|
|
36
44
|
const tools = ctx.get('tools')
|
|
37
|
-
const view = tools?.view?.(
|
|
45
|
+
const view = tools?.view?.(agent)
|
|
38
46
|
const visible = view?.visible
|
|
39
47
|
if (!(visible instanceof Map)) return []
|
|
40
48
|
const prefix = `mcp__${serverName}__`
|
|
@@ -44,7 +52,37 @@ export function toolsOf(ctx: Ctx, serverName: string): MpcTool[] {
|
|
|
44
52
|
}))
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Restore unchanged masked values without inventing credentials for new keys.
|
|
57
|
+
* @public @author ddj 2026年09月28号
|
|
58
|
+
* @param next Submitted configuration, including editable unknown fields.
|
|
59
|
+
* @param previous Last persisted configuration.
|
|
60
|
+
* @returns Detached configuration with unchanged secrets restored; throws for an orphan mask.
|
|
61
|
+
*/
|
|
62
|
+
export function mergeSecrets(next: Record<string, unknown>, previous: Record<string, unknown>): Record<string, unknown> {
|
|
63
|
+
const merged = { ...next }
|
|
64
|
+
for (const field of ['env', 'headers']) {
|
|
65
|
+
const values = next[field]
|
|
66
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) continue
|
|
67
|
+
const old = previous[field] as Record<string, unknown> | undefined
|
|
68
|
+
const restored: Record<string, unknown> = Object.create(null)
|
|
69
|
+
for (const [key, value] of Object.entries(values)) {
|
|
70
|
+
if (value !== '••••••') { restored[key] = value; continue }
|
|
71
|
+
if (!old || !Object.hasOwn(old, key)) throw new Error('脱敏凭据没有原值,请重新填写:' + key)
|
|
72
|
+
restored[key] = old[key]
|
|
73
|
+
}
|
|
74
|
+
merged[field] = restored
|
|
75
|
+
}
|
|
76
|
+
return merged
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Summarize a global entry without treating plugin activation as a successful handshake.
|
|
81
|
+
* @public @author ddj 2026年09月28号
|
|
82
|
+
* @param ctx Host services.
|
|
83
|
+
* @param entry Global loader entry.
|
|
84
|
+
* @returns Masked status and globally visible tools.
|
|
85
|
+
*/
|
|
48
86
|
export function serverOf(ctx: Ctx, entry: any): MpcServer {
|
|
49
87
|
const config = (entry.options?.config ?? {}) as Record<string, unknown>
|
|
50
88
|
const serverName = typeof config.serverName === 'string' ? config.serverName : entry.options?.id ?? 'unknown'
|
|
@@ -53,10 +91,12 @@ export function serverOf(ctx: Ctx, entry: any): MpcServer {
|
|
|
53
91
|
return {
|
|
54
92
|
id: String(entry.id ?? entry.options?.id ?? serverName),
|
|
55
93
|
serverName,
|
|
56
|
-
enabled: entry.disabled !== true,
|
|
94
|
+
enabled: entry.disabled !== true && entry.options?.disabled !== true,
|
|
57
95
|
transport: config.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
58
96
|
config: publicConfig(config),
|
|
59
|
-
status:
|
|
97
|
+
status: entry.disabled === true || entry.options?.disabled === true ? 'disabled'
|
|
98
|
+
: state === 2 ? 'unverified' : state === 1 || state === 0 ? 'connecting' : state === 3 ? 'error' : 'configured',
|
|
99
|
+
instanceCount: entry.fiber ? 1 : 0,
|
|
60
100
|
toolCount: tools.length,
|
|
61
101
|
tools,
|
|
62
102
|
error: state === 3 ? 'MCP 插件未正常运行' : undefined,
|
|
@@ -75,17 +115,26 @@ export function listMcp(ctx: Ctx): { servers: MpcServer[] } {
|
|
|
75
115
|
return { servers: entriesOf(ctx).filter((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? ''))).map((entry) => serverOf(ctx, entry)) }
|
|
76
116
|
}
|
|
77
117
|
|
|
78
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Save a global loader entry while preserving advanced options and unchanged secrets.
|
|
120
|
+
* @public @author ddj 2026年09月28号
|
|
121
|
+
* @param ctx Host loader context.
|
|
122
|
+
* @param config Submitted global configuration.
|
|
123
|
+
* @returns Masked server status; project name collisions reject before writing.
|
|
124
|
+
*/
|
|
79
125
|
export async function saveMcp(ctx: Ctx, config: MpcConfig): Promise<MpcServer> {
|
|
80
126
|
validateConfig(config)
|
|
127
|
+
await checkMcpName(ctx, config.serverName)
|
|
81
128
|
const loader = ctx.get('loader')
|
|
82
129
|
if (!loader) throw new Error('缺少 loader 服务')
|
|
83
130
|
const existing = entriesOf(ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? '')) && entry.options?.config?.serverName === config.serverName)
|
|
131
|
+
const previous = existing?.options?.config ?? {}
|
|
132
|
+
const next = mergeSecrets({ ...previous, ...config }, previous)
|
|
84
133
|
if (existing) {
|
|
85
|
-
await loader.update(existing.id, { config:
|
|
134
|
+
await loader.update(existing.id, { config: next })
|
|
86
135
|
return serverOf(ctx, loader.resolve(existing.id))
|
|
87
136
|
}
|
|
88
|
-
const id = await loader.create({ name: MCP_PACKAGE, config:
|
|
137
|
+
const id = await loader.create({ name: MCP_PACKAGE, config: next })
|
|
89
138
|
return serverOf(ctx, loader.resolve(id))
|
|
90
139
|
}
|
|
91
140
|
|