dsh-armory 2.4.3 → 2.4.5

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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Windows 下 stdio MCP 的「可执行化」——把裸 `npx` 换成真正能 spawn 的形式。
3
+ *
4
+ * 为什么需要这一层(2026-10-30 本机实测,不是推测):
5
+ * 宿主 `@deepseek-ai/dsh-mcp-client` 用 `child_process.spawn(command, args, { shell: false })`
6
+ * 拉起 stdio 子进程。win32 上 Node 不做 PATHEXT 解析,而 npm 的 `npx` 实际是 `npx.cmd`
7
+ * (批处理壳,必须经 cmd.exe 才有解释器):
8
+ * - spawn('npx', …, {shell:false}) → ENOENT,实测 6ms 内子进程根本没起来
9
+ * - spawn('npx.cmd', …, {shell:false}) → EINVAL(Node ≥ 24 明确拒绝直接 spawn .cmd)
10
+ * 子进程起不来 → stdin/stdout 管道立刻关闭 → SDK 侧只剩一句
11
+ * `SdkError: Connection closed`,再按 `reconnect.maxAttempts: 10` 重试到耗尽,面板永久「连接失败」。
12
+ * 官方 MCP Registry 的 npm 条目给出的命令恰恰就是裸 `npx`,所以 Windows 上「社区 MCP 选哪一条都连不上」
13
+ * 与条目本身无关,是命令形态问题。同机唯一两条能连上的(mcp-windows / mcp-rea)都是绝对 `.exe`——
14
+ * CreateProcess 对无扩展名的命令只补 `.exe`,所以裸 `node` / `python` 反而没事,出事的是 `.cmd` 壳。
15
+ *
16
+ * 这一层只改**命令形态**,不改语义:`npx -y pkg` → `node.exe npx-cli.js -y pkg`,
17
+ * 参数顺序、包名、附加参数一律原样保留;非 win32、绝对路径、`${VAR}` / `!!js` 表达式全部放行不改。
18
+ * 改写是幂等的:改写后的绝对 node.exe 再进这里不会被二次包装,读回配置也不会漂移。
19
+ *
20
+ * 顺带处理第二层拦路石(同样实测):本机全局 npm 缓存目录不可写时,`npx -y <pkg>` 会在
21
+ * 下载阶段 `EPERM` 失败——spawn 已经成功,但包拿不下来。所以为改写过的条目注入一个
22
+ * 属于军械库的可写缓存目录;用户自己配过缓存键时绝不覆盖。
23
+ *
24
+ * 边界:`uvx` / `uv` 在 Windows 上是真 `.exe`(astral 直接发二进制),不属于 `.cmd` 壳问题,
25
+ * 本机又未安装 uv,无法实测 → 本层不动它,避免把没验证过的路径写成规则。
26
+ */
27
+ import { existsSync, mkdirSync, statSync } from 'node:fs';
28
+ import { win32 as winpath } from 'node:path';
29
+ import { homedir } from 'node:os';
30
+
31
+ /** 会被 npm 装成 `.cmd` 壳、因而 spawn 不了的命令名。 */
32
+ const CMD_SHELL_COMMANDS = new Set(['npx', 'npm']);
33
+ /** `.cmd` 壳 → 同目录 npm 包里的真正入口 JS。 */
34
+ const LAUNCHER_ENTRY = { npx: 'npx-cli.js', npm: 'npm-cli.js' };
35
+ /** 交给宿主求值的表达式命令:形态不是我们能静态决定的,禁止改写。 */
36
+ const EXPRESSION_RE = /^\s*(?:!!js\b|\$\{)/;
37
+ /** 绝对路径 / UNC / 带引号的路径:本来就可 spawn。 */
38
+ const ABSOLUTE_RE = /^\s*"?\s*(?:[A-Za-z]:[\\/]|\\\\|\/)/;
39
+
40
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
41
+
42
+ /** 取命令尾段并小写:`C:\a\b\npx.cmd` 与 `npx` 都归到 `npx`。 */
43
+ export function commandBase(command) {
44
+ if (typeof command !== 'string') return '';
45
+ const text = command.trim().replace(/^"|"$/g, '');
46
+ const segments = text.split(/[\\/]/).filter(Boolean);
47
+ const tail = (segments.length ? segments[segments.length - 1] : text).toLowerCase();
48
+ return tail.replace(/\.exe$/, '').replace(/\.cmd$/, '');
49
+ }
50
+
51
+ /** 这条命令是不是 npm 装出来的 `.cmd` 壳(探测失败时要不要提醒,就按这个判)。 */
52
+ export function isCmdShellCommand(command) {
53
+ return CMD_SHELL_COMMANDS.has(commandBase(command));
54
+ }
55
+
56
+ /**
57
+ * 纯函数:按注入的上下文决定这条 stdio spec 该怎么写。
58
+ * 不做任何 fs / process 访问,便于零成本单测与跨平台运行。
59
+ *
60
+ * @param {{command:string,args?:string[],env?:object}} spec 已归一化的 stdio spec(就地不改,返回新对象)
61
+ * @param {{applicable?:boolean,node?:{exe:string,npxCli:string}|null,cacheDir?:string|null}} context
62
+ * @returns {{spec:object, rewritten:boolean, reason:string}}
63
+ * reason: 'not-win32' | 'not-stdio-command' | 'expression' | 'already-normalized'
64
+ * | 'already-absolute' | 'unsupported-command' | 'node-not-found' | 'rewritten'
65
+ */
66
+ export function planStdioLaunch(spec, context = {}) {
67
+ if (!isObject(spec) || typeof spec.command !== 'string' || !spec.command.trim()) {
68
+ return { spec, rewritten: false, reason: 'not-stdio-command' };
69
+ }
70
+ if (context.applicable !== true) return { spec, rewritten: false, reason: 'not-win32' };
71
+ // 表达式(`${NODE_BIN}`、`!!js …`)由宿主在挂载时求值,形态我们无法静态判定 → 放行。
72
+ if (EXPRESSION_RE.test(spec.command)) return { spec, rewritten: false, reason: 'expression' };
73
+ const inArgs = Array.isArray(spec.args) ? spec.args : [];
74
+ // 幂等护栏:已经是「node.exe + …/npx-cli.js」或「裸 node + …/npx-cli.js」形态的,不再包第二层
75
+ // (读回配置、反复保存、项目 MCP 重挂都会重新走一遍这里)。
76
+ if ((commandBase(spec.command) === 'node' || ABSOLUTE_RE.test(spec.command))
77
+ && inArgs.some((arg) => typeof arg === 'string' && /(?:npx|npm)-cli\.js$/i.test(arg))) {
78
+ return { spec, rewritten: false, reason: 'already-normalized' };
79
+ }
80
+ const base = commandBase(spec.command);
81
+ if (!CMD_SHELL_COMMANDS.has(base)) {
82
+ // 绝对路径本来就是我们要的样子;其余(node / python / 自定义 exe)走 CreateProcess 的 .exe 补齐。
83
+ if (ABSOLUTE_RE.test(spec.command)) return { spec, rewritten: false, reason: 'already-absolute' };
84
+ return { spec, rewritten: false, reason: 'unsupported-command' };
85
+ }
86
+ const node = isObject(context.node) ? context.node : null;
87
+ const entry = LAUNCHER_ENTRY[base];
88
+ if (!node || !node.exe || (entry === 'npx-cli.js' && !node.npxCli)) {
89
+ // 找不到本机 Node:宁可不改。改写成一个不存在的路径会把「连不上」换成更难的「找不到文件」。
90
+ return { spec, rewritten: false, reason: 'node-not-found' };
91
+ }
92
+ // 入口 JS 必须是探测到的真实路径;缺了就当找不到,绝不拼一个没验证过的路径出去写盘。
93
+ const cli = entry === 'npx-cli.js' ? node.npxCli : node.npmCli;
94
+ if (!cli) return { spec, rewritten: false, reason: 'node-not-found' };
95
+ const args = inArgs.filter((item) => typeof item === 'string');
96
+ const next = { ...spec, command: node.exe, args: [cli, ...args] };
97
+ if (context.cacheDir) {
98
+ const env = isObject(spec.env) ? { ...spec.env } : {};
99
+ // 用户显式配过缓存(任意大小写)时以用户为准,不覆盖。
100
+ const hasCacheKey = Object.keys(env).some((key) => key.toLowerCase() === 'npm_config_cache');
101
+ if (!hasCacheKey) env.npm_config_cache = context.cacheDir;
102
+ if (Object.keys(env).length) next.env = env;
103
+ }
104
+ return { spec: next, rewritten: true, reason: 'rewritten' };
105
+ }
106
+
107
+ /**
108
+ * 探测本机 Node 启动器。所有输入均可注入,便于在任意平台上跑同一份逻辑。
109
+ * 只在 win32 返回 applicable:true;其余平台一律 applicable:false(本层完全不介入)。
110
+ */
111
+ export function detectWin32LaunchContext({
112
+ platform = process.platform,
113
+ env = process.env,
114
+ home = homedir(),
115
+ exists = existsSync,
116
+ isFile = (value) => {
117
+ try {
118
+ return statSync(value).isFile();
119
+ } catch {
120
+ return false;
121
+ }
122
+ },
123
+ mkDir = mkdirSync,
124
+ } = {}) {
125
+ if (platform !== 'win32') return { applicable: false, platform, node: null, cacheDir: null };
126
+ const rawPath = env.PATH || env.Path || env.path || '';
127
+ const dirs = rawPath.split(';').map((dir) => dir.trim()).filter(Boolean);
128
+ let fallback = null;
129
+ for (const dir of dirs) {
130
+ const exe = winpath.join(dir, 'node.exe');
131
+ if (!isFile(exe)) continue;
132
+ const npmBin = winpath.join(dir, 'node_modules', 'npm', 'bin');
133
+ const npxCli = winpath.join(npmBin, 'npx-cli.js');
134
+ const npmCli = winpath.join(npmBin, 'npm-cli.js');
135
+ if (isFile(npxCli) && isFile(npmCli)) {
136
+ // 同目录成对命中:这是官方安装包的标准布局,优先采用。
137
+ return { applicable: true, platform, node: { exe, npxCli, npmCli }, cacheDir: armoryNpmCacheDir(home, mkDir) };
138
+ }
139
+ if (!fallback) fallback = { exe, npxCli: isFile(npxCli) ? npxCli : '', npmCli: isFile(npmCli) ? npmCli : '' };
140
+ }
141
+ return { applicable: true, platform, node: fallback, cacheDir: armoryNpmCacheDir(home, mkDir) };
142
+ }
143
+
144
+ /** 军械库专用 npm 缓存目录;建不出来就返回 null(不注入,保持原样而不是指向一个坏路径)。 */
145
+ function armoryNpmCacheDir(home, mkDir) {
146
+ if (typeof home !== 'string' || !home) return null;
147
+ const dir = winpath.join(home, '.dsh', 'armory', 'mcp-npm-cache');
148
+ try {
149
+ mkDir(dir, { recursive: true });
150
+ } catch {
151
+ return null;
152
+ }
153
+ return dir;
154
+ }
155
+
156
+ let cachedContext;
157
+
158
+ /**
159
+ * 进程内缓存的启动器上下文(首次用到才探测;探测失败不重试到写盘热路径上)。
160
+ *
161
+ * 这里的 try/catch 是**窄闸门**,不是兜底吞异常:本函数站在「加入 MCP」的唯一写入路径上,
162
+ * 探测层任何未预见的异常都不该让用户的添加动作整体失败。降级方向是回到修复前的行为
163
+ * (不改写、原样写入),并把降级原因如实带在 `probeError` 上交给调用方上报,
164
+ * 而不是静默成「这条不需要归一」。
165
+ */
166
+ export function win32LaunchContext(force = false, detect = detectWin32LaunchContext) {
167
+ if (cachedContext === undefined || force === true) {
168
+ try {
169
+ cachedContext = detect();
170
+ } catch (error) {
171
+ cachedContext = { applicable: false, platform: process.platform, node: null, cacheDir: null, probeError: String(error?.message || error) };
172
+ }
173
+ }
174
+ return cachedContext;
175
+ }
176
+
177
+ /** 测试与热修入口:注入假上下文 / 清空缓存。 */
178
+ export function setWin32LaunchContext(value) {
179
+ cachedContext = value;
180
+ }
181
+
182
+ export function resetWin32LaunchContext() {
183
+ cachedContext = undefined;
184
+ }
185
+
186
+ /**
187
+ * 给用户看的改写完说明:只比较「进」与「出」的命令,不往 spec 上挂任何多余字段
188
+ * (多余字段会被 workspace 配置序列化进 .dsh/mcp.json,污染用户文件)。
189
+ */
190
+ export function describeLaunchRewrite(originalCommand, finalCommand) {
191
+ if (typeof originalCommand !== 'string' || typeof finalCommand !== 'string') return '';
192
+ if (originalCommand.trim() === finalCommand.trim()) return '';
193
+ return `;Windows 启动器已改用本机 Node(${finalCommand}),裸 ${originalCommand.trim()} 在 win32 无法 spawn`;
194
+ }
@@ -508,6 +508,25 @@ function repointConnectionSlots(ctx, previous, entry) {
508
508
  }
509
509
  return repointed;
510
510
  }
511
+ // 宿主 agents 服务里的存活会话,作为 agent/created 之外的兜底来源。
512
+ // 为什么需要兜底:本代挂载只覆盖「本代上线之后创建的会话」。插件被宿主热加载
513
+ // (改 profile patch 触发重载)时,旧代登记过的会话仍在跑,却不在新代的
514
+ // agentWorkspaceStates 里;只靠事件的话,它们要等下一次创建才重新进入管理,
515
+ // 期间新增的项目 MCP 对它们无效。写盘对齐时扫一遍活会话把它们并进来,
516
+ // 代价只是一次 list();拿不到服务(旧宿主/作用域外调用)时退化为空表。
517
+ function liveAgentsOf(ctx) {
518
+ const get = typeof ctx.get === "function" ? ctx.get.bind(ctx) : null;
519
+ const agents = get && get("agents");
520
+ if (!agents || typeof agents.list !== "function") return [];
521
+ try {
522
+ return agents.list() || [];
523
+ } catch (error) {
524
+ // 不能静默吞:兜底来源失效时用户只会看到「写盘后历史会话没吃到新配置」。
525
+ // 同一条故障只打一次(reconcile 每 5s 会走到这里)。
526
+ warnDiagnosticOnce(ctx?.logger, "live-agents", `armory: 读取存活会话失败(项目 MCP 兜底同步暂停):${error?.message || error}`);
527
+ return [];
528
+ }
529
+ }
511
530
  // 配置 → 连接集的唯一同步点:**写盘之后**调用,把该项目对齐到新配置。
512
531
  // 1. 配置里已不存在、或配置已变化的连接立刻 retire(不做空闲等待:它已经"不再有效");
513
532
  // 2. 对每个存活会话重跑一次挂载(幂等):缺的连上、换掉的重新投射、已有的当场归还多余引用。
@@ -548,6 +567,16 @@ async function reconcileWorkspaceConnections(ctx, wsPath) {
548
567
  await mountProjectMcpForAgent(ctx, agent, generation, { awaitReady: false });
549
568
  remounted += 1;
550
569
  }
570
+ // 兜底:并入本代尚未登记、但确实活在这个项目里的会话(见 liveAgentsOf)。
571
+ // 正常情况下这一步是空转;热加载换代后它负责让「历史会话」也吃到本次写盘。
572
+ for (const agent of liveAgentsOf(ctx)) {
573
+ if (!agent || agentWorkspaceStates.has(agent)) continue;
574
+ const cwd = agent.session?.header?.cwd;
575
+ if (typeof cwd !== "string" || cwd.length === 0) continue;
576
+ if (canonicalWorkspacePath(cwd) !== wsPath) continue;
577
+ await mountProjectMcpForAgent(ctx, agent, generation, { awaitReady: false });
578
+ remounted += 1;
579
+ }
551
580
  return { retired, remounted };
552
581
  }
553
582
  function agentSlotIndex(ctx, agent) {
@@ -1155,11 +1184,16 @@ async function mountProjectMcpForAgent(ctx, agent, generation, options = {}) {
1155
1184
  if (typeof cwd === "string" && cwd.length > 0) {
1156
1185
  const wsPath = canonicalWorkspacePath(cwd);
1157
1186
  try {
1187
+ // **先登记、再读配置**:项目 MCP 最常见的添加时机,正是这个项目已经开着会话、
1188
+ // 但还没有 `.dsh/mcp.json` 的时候(第一次加)。若这里因为 config.missing 跳过登记,
1189
+ // 之后面板写盘时的 reconcileWorkspaceConnections 就遍历不到这个会话(它只认
1190
+ // agentWorkspaceStates),新增的 server 只能等用户新开会话——正是 issue #6 的现象。
1191
+ // 登记只是记账,不建连、不注册工具;没有配置时它没有任何可观察副作用。
1192
+ // 会话记录只承载「本会话属于哪个项目 + restrict 展开到哪一步」。它不再是任何
1193
+ // 存活性凭据:共享连接的引用由 commitSharedConnection 交给 cordis 的 effect 持有。
1194
+ ensureAgentWorkspaceState(ctx, agentCtx, agent, wsPath);
1158
1195
  const config = await readWorkspaceConfigCached(ctx, wsPath);
1159
1196
  if (!config.error && !config.missing) {
1160
- // 会话记录只承载「本会话属于哪个项目 + restrict 展开到哪一步」。它不再是任何
1161
- // 存活性凭据:共享连接的引用由 commitSharedConnection 交给 cordis 的 effect 持有。
1162
- ensureAgentWorkspaceState(ctx, agentCtx, agent, wsPath);
1163
1197
  // 本次挂载的共享就绪预算(见 waitForConnectionReady):取各 server 上限里的最大值,
1164
1198
  // 而不是逐个相加 —— 预算是给「这次会话创建」的,不是给每个 server 各发一份。
1165
1199
  let readyDeadline = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & DSH community plugin market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",