dsh-agy-swarm 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -137,12 +137,13 @@ docs/ 配置、参考、开发与变更日志
137
137
 
138
138
  ```bash
139
139
  npm run check # 全量 node --check
140
- npm test # 离线主套件(含内置 ACP 替身)
140
+ npm test # 离线主套件(含内置 ACP 替身;Windows 沙盒用)
141
+ npm run test:ci # 同一套件的 CI 版,不带 --test-isolation
141
142
  npm run test:acp-integration # 可选:真实 dsh-agy-acp 端到端
142
143
  npm pack --dry-run --json # 打包清单预演
143
144
  ```
144
145
 
145
- Windows 沙盒环境跑 `node --test` 必须带 `--test-isolation=none`(子进程命名管道在受限令牌下会 `spawn EPERM`)。
146
+ Windows 沙盒环境跑 `node --test` 必须带 `--test-isolation=none`(子进程命名管道在受限令牌下会 `spawn EPERM`);Linux / macOS 反而不能带(Node 22.13 之前不认该参数,会以 `exit code 9` 失败),CI 因此统一走 `npm run test:ci`。
146
147
 
147
148
  ## 许可
148
149
 
package/client.js CHANGED
@@ -235,6 +235,20 @@
235
235
  return value !== null && typeof value === 'object' && !Array.isArray(value);
236
236
  }
237
237
 
238
+ /**
239
+ * 解包一元 Remote(Remote 直调)调用的宿主响应信封。
240
+ *
241
+ * `@deepseek-ai/dsh-api-gateway` 会把一元 Remote 的结果包成 `{ ok: true, value }`;
242
+ * 插件自带的 `SwarmRemoteServiceFallback`(以及离线单测里的替身)直接返回裸结果。
243
+ * 两种形态都必须接受,否则带信封的响应会被 `parseSwarmModelsResult` /
244
+ * `normalizeSwarmSnapshot` 判成非法载荷(典型症状:设置页报
245
+ * "models must be an array")。
246
+ */
247
+ function unwrapRemoteResult(res) {
248
+ if (isPlainObject(res) && res.ok === true && 'value' in res && res.value !== undefined) return res.value;
249
+ return res;
250
+ }
251
+
238
252
  function safeDecodeSegment(segment) {
239
253
  if (typeof segment !== 'string' || segment === '') return null;
240
254
  try {
@@ -1307,6 +1321,22 @@
1307
1321
  return String(error);
1308
1322
  }
1309
1323
 
1324
+ /**
1325
+ * 模型目录拉取失败的原因摘要:压成单行并限长,只用于设置页 status 的 tooltip,
1326
+ * 既方便排查(例如宿主流未挂载 / 协议校验失败),又不会把长堆栈塞进 DOM。
1327
+ */
1328
+ function describeModelsError(error) {
1329
+ let text = '';
1330
+ try {
1331
+ text = formatErrorMessage(error);
1332
+ } catch {
1333
+ text = '';
1334
+ }
1335
+ text = String(text || '').replace(/\s+/g, ' ').trim();
1336
+ if (!text) return 'unknown error';
1337
+ return text.length > 240 ? text.slice(0, 237) + '...' : text;
1338
+ }
1339
+
1310
1340
  function makeRemoteFailureFrame(code, message, details) {
1311
1341
  const error = new Error(message);
1312
1342
  error.name = 'RemoteError';
@@ -1469,6 +1499,8 @@
1469
1499
  Array.isArray(props?.models) ? props.models.map(normalizeSwarmModelEntry) : null
1470
1500
  );
1471
1501
  const [modelsStatus, setModelsStatus] = useState('');
1502
+ /** 拉取失败时的可读原因(单行摘要,挂在 status 的 tooltip 上,不阻塞设置页)。 */
1503
+ const [modelsError, setModelsError] = useState('');
1472
1504
  const loadModels = typeof props?.loadModels === 'function' ? props.loadModels : null;
1473
1505
  useEffect(() => {
1474
1506
  if (!loadModels) return undefined;
@@ -1479,10 +1511,12 @@
1479
1511
  if (cancelled || !alive.current) return;
1480
1512
  setDiscoveredModels(Array.isArray(list) ? list.map(normalizeSwarmModelEntry) : []);
1481
1513
  setModelsStatus('');
1514
+ setModelsError('');
1482
1515
  })
1483
- .catch(() => {
1516
+ .catch((error) => {
1484
1517
  if (cancelled || !alive.current) return;
1485
1518
  setModelsStatus('modelsUnavailable');
1519
+ setModelsError(describeModelsError(error));
1486
1520
  });
1487
1521
  return () => {
1488
1522
  cancelled = true;
@@ -1689,9 +1723,13 @@
1689
1723
  if (!alive.current) return undefined;
1690
1724
  setDiscoveredModels(Array.isArray(list) ? list.map(normalizeSwarmModelEntry) : []);
1691
1725
  setModelsStatus('');
1726
+ setModelsError('');
1692
1727
  return true;
1693
- } catch {
1694
- if (alive.current) setModelsStatus('modelsUnavailable');
1728
+ } catch (error) {
1729
+ if (alive.current) {
1730
+ setModelsStatus('modelsUnavailable');
1731
+ setModelsError(describeModelsError(error));
1732
+ }
1695
1733
  return false;
1696
1734
  }
1697
1735
  };
@@ -1754,6 +1792,18 @@
1754
1792
  const manualDraft = Object.prototype.hasOwnProperty.call(drafts, 'manualModelRule')
1755
1793
  ? drafts.manualModelRule
1756
1794
  : '';
1795
+ /**
1796
+ * 模型目录状态行:文字只保留本地化摘要,失败原因放进 `title`(tooltip),
1797
+ * 让用户能一眼看出是「宿主流未挂载」还是「协议校验失败」。
1798
+ */
1799
+ const modelsStatusText = modelsStatus
1800
+ ? t(modelsStatus)
1801
+ : (discoveredModels ? '' : t('modelsLoading'));
1802
+ const modelsStatusProps = {
1803
+ className: 'dsh-swarm-settings-status' + (modelsError ? ' is-error' : ''),
1804
+ 'data-role': 'models-status',
1805
+ ...(modelsError ? { title: modelsError, 'data-models-error': modelsError } : {}),
1806
+ };
1757
1807
 
1758
1808
  return h('section', { className: 'dsh-swarm-settings', 'data-settings-id': SWARM_SETTINGS_ID, 'aria-label': t('title') },
1759
1809
  h('h2', null, t('title')),
@@ -1798,8 +1848,7 @@
1798
1848
  h('p', { className: 'dsh-swarm-settings-hint', 'data-role': 'whitelist-hint' },
1799
1849
  whitelistMode ? t('whitelistHintOn') : t('whitelistHintOff')),
1800
1850
  h('div', { className: 'dsh-swarm-settings-row' },
1801
- h('p', { className: 'dsh-swarm-settings-status', 'data-role': 'models-status' },
1802
- modelsStatus ? t(modelsStatus) : (discoveredModels ? '' : t('modelsLoading'))),
1851
+ h('p', modelsStatusProps, modelsStatusText),
1803
1852
  h('button', {
1804
1853
  type: 'button',
1805
1854
  className: 'dsh-swarm-settings-chip',
@@ -2165,7 +2214,8 @@
2165
2214
  if (!api || typeof api.getSwarmSnapshot !== 'function') {
2166
2215
  throw new Error('Host 端 dsh-swarm.getSwarmSnapshot 服务未就绪');
2167
2216
  }
2168
- const response = await api.getSwarmSnapshot({ sessionId });
2217
+ // 宿主 api-gateway 会包 `{ ok: true, value }` 信封,先解包再做归一化。
2218
+ const response = unwrapRemoteResult(await api.getSwarmSnapshot({ sessionId }));
2169
2219
  if (isPlainObject(response) && response.ok === false) {
2170
2220
  throw new Error(formatErrorMessage(response.error || response));
2171
2221
  }
@@ -3407,7 +3457,8 @@
3407
3457
  if (!api || typeof api.getSwarmModels !== 'function') {
3408
3458
  throw new Error('Host 端 dsh-swarm.getSwarmModels 服务未就绪');
3409
3459
  }
3410
- const response = await api.getSwarmModels({ probeAgy: false });
3460
+ // 同 fetchSnapshotOnce:真实宿主的响应带 `{ ok: true, value }` 信封。
3461
+ const response = unwrapRemoteResult(await api.getSwarmModels({ probeAgy: false }));
3411
3462
  if (isPlainObject(response) && response.ok === false) {
3412
3463
  throw new Error(formatErrorMessage(response.error || response));
3413
3464
  }
@@ -3545,6 +3596,8 @@
3545
3596
  parseSwarmResourceUri: parseSwarmAddress,
3546
3597
  parseSwarmRequest,
3547
3598
  normalizeSwarmSnapshot,
3599
+ unwrapRemoteResult,
3600
+ describeModelsError,
3548
3601
  tokenizeSwarmText,
3549
3602
  isLongSwarmMessage,
3550
3603
  getCollapsedPreviewText,
package/docs/changelog.md CHANGED
@@ -1,12 +1,33 @@
1
1
  # 变更日志
2
2
 
3
- ## 未发布
3
+ ## [0.1.1] - 2026-11-15
4
+
5
+ ### 修复 · 设置页「未能读取宿主模型列表」
6
+
7
+ - 真实宿主 `@deepseek-ai/dsh-api-gateway` 的一元 Remote 调用在**成功**时返回 `{ ok: true, value: <payload> }` 信封(失败时 `{ ok: false, error }`)。此前设置页把整封响应当载荷直接交给 `parseSwarmModelsResult`,`Array.isArray(response.models)` 恒为假,于是永远报「未能读取宿主模型列表」并且只留下一句 `models must be an array` 的内部错误。
8
+ - 新增 `unwrapRemoteResult(res)`:`isPlainObject(res) && res.ok === true && 'value' in res && res.value !== undefined` 时取 `res.value`,否则原样透传(插件自带的 `SwarmRemoteServiceFallback` 与离线单测替身返回裸结果,必须同样可用)。`loadSwarmModels` 与 `fetchSnapshotOnce` 统一先解包再交给校验 / 归一化函数。
9
+ - 设置页仍拉取失败时保留简化可读的错误摘要(`modelsError`):状态行文字维持本地化提示,真实原因(截断为单行 240 字内)挂在 `title` 与 `data-models-error` 上,便于区分「宿主流未挂载」与「协议校验失败」;拉取成功或重试成功即清空。
10
+ - 新增回归:注入真实 `{ ok: true, value: { models: [...] } }` 信封给 `api.getSwarmModels`,设置页 `loadModels()` 必须解包返回全量模型;同时覆盖「裸结果透传」「失败信封不被误解包」与错误 tooltip 的出现 / 清空。
11
+
12
+ ### 修复 · Host 依赖加载脱节(`MODULE_NOT_FOUND` 退回 Fallback)
13
+
14
+ - 桌面 Profile 里 `require('@deepseek-ai/dsh-typert-protocol')` / `dsh-tools` / `dsh-scope` / `dsh-llm` / `dsh-session` 全部 `MODULE_NOT_FOUND`:这些包只在 `<resourcesPath>/app.asar/dsh/node_modules` 里,插件目录解析不到,于是 `index.js` 静默退回手写 Fallback(工具定义、fork 种子、中继消息、Typert 远程服务全部降级)。
15
+ - `runtime.js` 的宿主锚点升级:除环境变量与 `require.main` / `process.argv[1]` 外,新增 `<resourcesPath>/app.asar/{dsh,lib}` 锚点,并把入口文件所在目录的若干层祖先一并当锚点,覆盖桌面版 / CLI / 便携包布局,仍然零硬编码安装路径。
16
+ - 新增 `runtime.requireHostModule(specifier)`:先普通 `require`(依赖被提升到 profile 的布局),失败则 `resolveHostModule` 定位宿主 `app.asar` 入口再 `require(entry)`;`ERR_REQUIRE_ESM`、asar 读取失败等一律返回 `null`(结果含失败一并缓存),调用方继续走兜底,**绝不因为可选依赖缺失而崩溃**。`index.js` 的 `tryRequire` 改为委托它;`loadSchemastery` 复用同一条链路。
17
+ - `mountSwarmRemoteService` 加固:真实 `typertProto.Remote` 装饰器失败时退回手写 `remote-methods` 描述符;`new SwarmRemoteService(ctx)` 抛错(宿主 Cordis / Typert 契约漂移)时退回 `SwarmRemoteServiceFallback`;描述符定义失败也不再影响服务本身。
18
+ - 纯 Node 离线单测零依赖兜底 100% 保留:无 `resourcesPath`、无 `@deepseek-ai/*` 时所有解析都返回 `null`,全部用例仍跑在兜底实现上。
19
+
20
+ ### 修复 · GitHub Actions 在 Node 22 报 `exit code 9`
21
+
22
+ - `npm test` 里的 `--test-isolation=none` 是为 Windows 沙盒(命名管道 `spawn EPERM`)准备的;Linux/macOS 并不需要,而 Node 22.13 之前的版本不认这个参数,会以 `exit code 9`(Invalid Argument)直接失败。
23
+ - `package.json` 新增 `"test:ci"`(不带 `--test-isolation`),保留 `"test"` 兼容 Windows 沙盒;`ci.yml` 与 `publish.yml` 的测试步改调 `npm run test:ci`。
24
+ - Matrix 的 `22` 改为 `22.19`,对齐 `engines.node` 的 `^22.19.0`,避免 setup-node 拉到 22.13 以下的小版本。
4
25
 
5
26
  ### 修复 · 严格白名单空名单的权限反转
6
27
 
7
28
  - 新增持久化开关 `strictAllowlist`(默认 `false`):白名单激活条件改为「`strictAllowlist === true` 或 `allowedModels` 非空」。此前「严格模式下取消最后一个勾选」会写入 `allowedModels: []`,被误判成「未启用白名单 → 全部放行」,权限方向反转;现在空名单一律**全禁**,报错明确提示「已启用严格白名单模式但允许列表为空」。
8
- - 设置页开关改为先写 `strictAllowlist` 再写 `allowedModels`(关闭时相反),任一步保存失败即回滚开关并显示保存失败,绝不停留在未落盘的开关状态。
9
- - 新增 4 项回归:取消最后一个勾选、移除最后一个白名单胶囊、空目录开启严格模式、保存失败不误显示。
29
+ - 设置页开关与白名单写入路径均改为先持久化 `strictAllowlist` 再更新 `allowedModels`(包括 legacy 仅非空名单激活时清空最后一项,也会先钉住 `strictAllowlist: true`;关闭时先写 `strictAllowlist: false` 再清空),任一步保存失败即回滚到已持久化快照并显示保存失败,绝不停留在未落盘的开关状态。
30
+ - 新增 5 项设置页回归(含取消最后一个勾选、移除最后一个白名单胶囊、空目录开启严格模式、legacy 仅非空名单清空、保存失败不误显示)以及对应 Host / 协议回归。
10
31
 
11
32
  ### 新增 · 可视化模型派发权限
12
33
 
@@ -5,12 +5,14 @@
5
5
  - Node `^22.19.0 || >=24.0.0`
6
6
  - DSH 宿主(`engines.dsh: >=0.2.2-rc.1`)
7
7
  - Windows 沙盒下跑 `node --test` **必须**带 `--test-isolation=none`:受限令牌下子进程命名管道会 `spawn EPERM`
8
+ - 反之 Linux / macOS(CI)不需要该参数,且 Node 22.13 之前不认它(`exit code 9`),所以 CI 统一用 `npm run test:ci`
8
9
 
9
10
  ## 本地检查
10
11
 
11
12
  ```bash
12
13
  npm run check # node --check 全量源码
13
- npm test # 离线主套件
14
+ npm test # 离线主套件(Windows 沙盒:含 --test-isolation=none)
15
+ npm run test:ci # 同上但不带 --test-isolation,供 Linux / macOS CI 使用
14
16
  npm pack --dry-run --json
15
17
  ```
16
18
 
@@ -55,8 +57,8 @@ test/ 主套件、开源友好度套件、可选集成套
55
57
 
56
58
  `.github/workflows/publish.yml` 是 `workflow_dispatch` 触发,带 `dry_run` 布尔输入:
57
59
 
58
- 1. `actions/checkout@v4` → `actions/setup-node@v4`(Node 22,registry `https://registry.npmjs.org`);
59
- 2. `npm run check` + `npm pack --dry-run` 作为门禁;
60
+ 1. `actions/checkout@v4` → `actions/setup-node@v4`(Node 22.19,对齐 `engines.node`,registry `https://registry.npmjs.org`);
61
+ 2. `npm run check` + `npm run test:ci` + `npm pack --dry-run` 作为门禁;
60
62
  3. 正式发布走 `npm publish --provenance --access public`。
61
63
 
62
64
  包内容由 `package.json#files` 白名单控制,`test/` 与 `.github/` 不进入 tarball。
package/index.js CHANGED
@@ -860,12 +860,24 @@ function fenceAroundText(text) {
860
860
  return `${fence}text\n${text}\n${fence}`;
861
861
  }
862
862
 
863
+ /**
864
+ * Host dependency loader with a zero-dependency fallback.
865
+ *
866
+ * Desktop DSH bundles every `@deepseek-ai/*` runtime package inside its
867
+ * Electron archive, so a plain `require` from the plugin directory raises
868
+ * MODULE_NOT_FOUND and the plugin silently degraded to its hand-written
869
+ * fallbacks. `runtime.requireHostModule` first tries the plain `require`
870
+ * (profile-hoisted layouts), then resolves the specifier against host entry
871
+ * anchors (the desktop runtime's own node_modules, CLI layouts, ...) and
872
+ * requires that entry by path. The archive layout knowledge lives only in
873
+ * `runtime.js`; nothing here spawns a process or reads the archive directly.
874
+ *
875
+ * Missing / ESM-only / unreadable modules still return `null`, so every
876
+ * caller keeps its fallback and the pure-Node offline suite stays
877
+ * dependency-free — no host package is ever required for the plugin to boot.
878
+ */
863
879
  function tryRequire(specifier) {
864
- try {
865
- return require(specifier);
866
- } catch {
867
- return null;
868
- }
880
+ return runtime.requireHostModule(specifier);
869
881
  }
870
882
 
871
883
  /**
@@ -4718,58 +4730,68 @@ function mountSwarmRemoteService(ctx, controller) {
4718
4730
 
4719
4731
  const attachRemotePrototypeMarkers = (Proto) => {
4720
4732
  if (typertProto && typeof typertProto.Remote === 'function') {
4721
- let initGet;
4722
- typertProto.Remote(Proto.getSwarmSnapshot, {
4723
- name: 'getSwarmSnapshot',
4724
- private: false,
4725
- static: false,
4726
- addInitializer(fn) {
4727
- initGet = fn;
4728
- },
4729
- });
4730
- let initWatch;
4731
- const streamDec = typertProto.Remote({ mode: 'stream' });
4732
- streamDec(Proto.watchSwarm, {
4733
- name: 'watchSwarm',
4734
- private: false,
4735
- static: false,
4736
- addInitializer(fn) {
4737
- initWatch = fn;
4738
- },
4739
- });
4740
- let initModels;
4741
- typertProto.Remote(Proto.getSwarmModels, {
4742
- name: 'getSwarmModels',
4743
- private: false,
4744
- static: false,
4745
- addInitializer(fn) {
4746
- initModels = fn;
4747
- },
4748
- });
4749
- return { initGet, initWatch, initModels };
4733
+ // 宿主协议的装饰器会做名称 / 上下文字段校验;版本漂移时退回手写描述符,
4734
+ // 绝不能因为一个可选元数据步骤让整个 Swarm 服务挂不上。
4735
+ try {
4736
+ let initGet;
4737
+ typertProto.Remote(Proto.getSwarmSnapshot, {
4738
+ name: 'getSwarmSnapshot',
4739
+ private: false,
4740
+ static: false,
4741
+ addInitializer(fn) {
4742
+ initGet = fn;
4743
+ },
4744
+ });
4745
+ let initWatch;
4746
+ const streamDec = typertProto.Remote({ mode: 'stream' });
4747
+ streamDec(Proto.watchSwarm, {
4748
+ name: 'watchSwarm',
4749
+ private: false,
4750
+ static: false,
4751
+ addInitializer(fn) {
4752
+ initWatch = fn;
4753
+ },
4754
+ });
4755
+ let initModels;
4756
+ typertProto.Remote(Proto.getSwarmModels, {
4757
+ name: 'getSwarmModels',
4758
+ private: false,
4759
+ static: false,
4760
+ addInitializer(fn) {
4761
+ initModels = fn;
4762
+ },
4763
+ });
4764
+ return { initGet, initWatch, initModels };
4765
+ } catch {
4766
+ // fall through to the hand-written descriptor below
4767
+ }
4750
4768
  }
4751
4769
 
4752
- Object.defineProperty(Proto, REMOTE_METHOD_DESCRIPTOR_KEY, {
4753
- configurable: true,
4754
- value: Object.freeze({
4755
- version: 1,
4756
- methods: Object.freeze([
4757
- Object.freeze({
4758
- method: 'getSwarmSnapshot',
4759
- invocation: Object.freeze({ kind: 'direct' }),
4760
- }),
4761
- Object.freeze({
4762
- method: 'watchSwarm',
4763
- mode: 'stream',
4764
- invocation: Object.freeze({ kind: 'direct' }),
4765
- }),
4766
- Object.freeze({
4767
- method: 'getSwarmModels',
4768
- invocation: Object.freeze({ kind: 'direct' }),
4769
- }),
4770
- ]),
4771
- }),
4772
- });
4770
+ try {
4771
+ Object.defineProperty(Proto, REMOTE_METHOD_DESCRIPTOR_KEY, {
4772
+ configurable: true,
4773
+ value: Object.freeze({
4774
+ version: 1,
4775
+ methods: Object.freeze([
4776
+ Object.freeze({
4777
+ method: 'getSwarmSnapshot',
4778
+ invocation: Object.freeze({ kind: 'direct' }),
4779
+ }),
4780
+ Object.freeze({
4781
+ method: 'watchSwarm',
4782
+ mode: 'stream',
4783
+ invocation: Object.freeze({ kind: 'direct' }),
4784
+ }),
4785
+ Object.freeze({
4786
+ method: 'getSwarmModels',
4787
+ invocation: Object.freeze({ kind: 'direct' }),
4788
+ }),
4789
+ ]),
4790
+ }),
4791
+ });
4792
+ } catch {
4793
+ // 原型不可扩展 / 已有不可重定义描述符:服务本身依旧可用,放弃元数据声明。
4794
+ }
4773
4795
  return null;
4774
4796
  };
4775
4797
 
@@ -4805,7 +4827,12 @@ function mountSwarmRemoteService(ctx, controller) {
4805
4827
  initializers.initWatch?.call(protoProxy);
4806
4828
  initializers.initModels?.call(protoProxy);
4807
4829
  }
4808
- return new SwarmRemoteService(ctx);
4830
+ try {
4831
+ return new SwarmRemoteService(ctx);
4832
+ } catch {
4833
+ // 宿主 Cordis / Typert 契约漂移(例如 TypertRemoteService 构造签名变化)时
4834
+ // 退回插件自带实现:degrade gracefully,绝不让 apply 直接抛错。
4835
+ }
4809
4836
  }
4810
4837
 
4811
4838
  class SwarmRemoteServiceFallback {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agy-swarm",
3
3
  "displayName": "Swarm Collaboration Cluster",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "description": "Multi-agent collaboration cluster for DeepSeek Harness: shared #seq message board, doorbell-based wakeups, zero-token sleeping, hot model switching, live feed/topology panel, and an optional Antigravity ACP teammate backend.",
6
6
  "license": "MIT",
7
7
  "author": "higekibaka",
@@ -118,6 +118,7 @@
118
118
  "icon": "./icon.svg",
119
119
  "scripts": {
120
120
  "test": "node --test --test-isolation=none test/swarm.test.mjs test/swarm-agy-acp.test.mjs test/swarm-open-source.test.mjs",
121
+ "test:ci": "node --test test/swarm.test.mjs test/swarm-agy-acp.test.mjs test/swarm-open-source.test.mjs",
121
122
  "test:acp-integration": "node --test --test-isolation=none test/agy-acp-integration.test.mjs",
122
123
  "check": "node --check index.js && node --check client.js && node --check runtime.js && node --check src/protocol.js && node --check src/board-store.js && node --check src/prompts.js && node --check src/redaction.js && node --check src/mcp-bridge-server.js && node --check src/mcp-stdio-entry.js"
123
124
  }
package/runtime.js CHANGED
@@ -3,17 +3,19 @@
3
3
  /**
4
4
  * 宿主依赖加载器(零依赖兜底)。
5
5
  *
6
- * DSH 桌面版把 @deepseek-ai/schemastery 之类运行时依赖放在 app.asar 内,
7
- * 桌面插件目录之外 require 解析不到。这里按可选扩展 dsh-agy-acp 的
8
- * `runtime.js` 思路,用宿主入口作为解析锚点;插件自己绝不硬依赖该包:
6
+ * DSH 桌面版把 @deepseek-ai/* 运行时依赖打进 app.asar,插件目录之外 require
7
+ * 解析不到;CLI / 便携包布局又各不相同。这里按可选扩展 dsh-agy-acp 的
8
+ * `runtime.js` 思路,用「宿主入口」当解析锚点,而不是硬编码任何安装路径:
9
9
  *
10
- * 1) DSH_SWARM_HOST_MODULES 指向的目录(离线单测用);
11
- * 2) require.main.filename(宿主主进程入口);
12
- * 3) process.argv[1];
13
- * 4) 插件自身目录(宿主把依赖提升到 profile 时的兜底)。
10
+ * 1) DSH_SWARM_HOST_MODULES 指向的目录(离线单测注入用);
11
+ * 2) require.main / process.mainModule(宿主主进程入口)及其祖先目录;
12
+ * 3) process.argv[1] 及其祖先目录;
13
+ * 4) <resourcesPath>/app.asar/{dsh,lib} —— Electron 桌面版的真实依赖树
14
+ * (@deepseek-ai/* 全部在 app.asar/dsh/node_modules 下);
15
+ * 5) 插件自身目录(宿主把依赖提升到 profile 时的兜底)。
14
16
  *
15
- * 与 agy-acp 不同的是:这里找不到时返回 null,由调用方切到手写的
16
- * Standard Schema 兜底,保证插件在纯 Node 离线单测里也能正常挂载。
17
+ * 解析失败一律返回 null,由调用方切到手写兜底实现:纯 Node 离线单测因此
18
+ * 零依赖也能完整跑通,且这条路径绝不抛错。
17
19
  */
18
20
 
19
21
  const path = require('node:path');
@@ -21,25 +23,75 @@ const { createRequire } = require('node:module');
21
23
 
22
24
  const HOST_MODULES_ENV = 'DSH_SWARM_HOST_MODULES';
23
25
 
26
+ /** 把某个入口文件所在目录(及其若干层祖先)加成锚点,覆盖各种宿主布局。 */
27
+ function ancestorAnchors(entry, depth = 6) {
28
+ const anchors = [];
29
+ if (typeof entry !== 'string' || !entry) return anchors;
30
+ let dir;
31
+ try {
32
+ dir = path.dirname(path.resolve(entry));
33
+ } catch {
34
+ return anchors;
35
+ }
36
+ for (let i = 0; i < depth && dir; i += 1) {
37
+ anchors.push(path.join(dir, 'package.json'));
38
+ const parent = path.dirname(dir);
39
+ if (parent === dir) break;
40
+ dir = parent;
41
+ }
42
+ return anchors;
43
+ }
44
+
45
+ /** Electron 桌面版:app.asar 内部的依赖树锚点(非 Electron 返回空)。 */
46
+ function electronAnchors() {
47
+ const roots = [];
48
+ const resources = process.resourcesPath;
49
+ if (typeof resources === 'string' && resources) {
50
+ try {
51
+ roots.push(path.resolve(resources));
52
+ } catch {
53
+ // ignore
54
+ }
55
+ }
56
+ const anchors = [];
57
+ for (const root of roots) {
58
+ // dsh/ 是 @deepseek-ai/dsh-desktop-runtime,其 node_modules 才是完整依赖树;
59
+ // 根目录的 node_modules 只有桌面壳自己用到的一小部分包。
60
+ anchors.push(path.join(root, 'app.asar', 'dsh', 'package.json'));
61
+ anchors.push(path.join(root, 'app.asar', 'package.json'));
62
+ anchors.push(path.join(root, 'app.asar', 'lib', 'main.js'));
63
+ anchors.push(path.join(root, 'app', 'package.json'));
64
+ anchors.push(path.join(root, 'package.json'));
65
+ }
66
+ return anchors;
67
+ }
68
+
24
69
  function hostAnchors() {
25
70
  const anchors = [];
26
71
  const root = process.env[HOST_MODULES_ENV];
27
72
  if (typeof root === 'string' && root.trim()) {
28
73
  anchors.push(path.join(root.trim(), 'anchor.cjs'));
29
74
  }
30
- try {
31
- const main = require.main && require.main.filename;
32
- if (typeof main === 'string' && main) anchors.push(main);
33
- } catch {
34
- // ignore
75
+ const mainModule = (() => {
76
+ try {
77
+ return require.main && require.main.filename;
78
+ } catch {
79
+ return null;
80
+ }
81
+ })();
82
+ const main = typeof mainModule === 'string' && mainModule ? mainModule : process.mainModule && process.mainModule.filename;
83
+ if (typeof main === 'string' && main) anchors.push(...ancestorAnchors(main), main);
84
+ if (typeof process.argv[1] === 'string' && process.argv[1]) {
85
+ anchors.push(...ancestorAnchors(process.argv[1]), process.argv[1]);
35
86
  }
36
- if (typeof process.argv[1] === 'string' && process.argv[1]) anchors.push(process.argv[1]);
87
+ anchors.push(...electronAnchors());
37
88
  anchors.push(path.join(__dirname, 'index.js'));
38
89
  return [...new Set(anchors)];
39
90
  }
40
91
 
41
92
  /** 从宿主锚点解析模块入口,全部失败返回 null(绝不抛错)。 */
42
93
  function resolveHostModule(specifier) {
94
+ if (typeof specifier !== 'string' || !specifier) return null;
43
95
  for (const anchor of hostAnchors()) {
44
96
  try {
45
97
  return createRequire(anchor).resolve(specifier);
@@ -54,6 +106,40 @@ function resolveHostModule(specifier) {
54
106
  }
55
107
  }
56
108
 
109
+ /** specifier -> module | null 的加载结果缓存(含失败结果,避免重复解析)。 */
110
+ const hostModuleCache = new Map();
111
+
112
+ function loadHostModule(specifier) {
113
+ // 1) 普通 require:宿主把依赖提升到 profile / 与插件同层时的最快路径。
114
+ try {
115
+ return require(specifier);
116
+ } catch {
117
+ // 落到宿主入口锚点
118
+ }
119
+ // 2) 按宿主 app.asar 入口解析,再以入口路径加载。
120
+ const entry = resolveHostModule(specifier);
121
+ if (!entry) return null;
122
+ try {
123
+ return require(entry);
124
+ } catch {
125
+ // ERR_REQUIRE_ESM(旧版 Node 的纯 ESM 宿主模块)、asar 读失败、包自检失败
126
+ // 等一律视为不可用:调用方会自动退回手写兜底实现,绝不因此崩溃。
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 惰性加载宿主模块:先普通 require,失败再走 app.asar 锚点。
133
+ * 解析或加载失败返回 null,调用方据此退回零依赖兜底。
134
+ */
135
+ function requireHostModule(specifier) {
136
+ if (typeof specifier !== 'string' || !specifier) return null;
137
+ if (hostModuleCache.has(specifier)) return hostModuleCache.get(specifier);
138
+ const loaded = loadHostModule(specifier);
139
+ hostModuleCache.set(specifier, loaded);
140
+ return loaded;
141
+ }
142
+
57
143
  let schemasteryModule;
58
144
  let schemasteryResolved = false;
59
145
 
@@ -63,20 +149,7 @@ let schemasteryResolved = false;
63
149
  */
64
150
  function loadSchemastery() {
65
151
  if (schemasteryResolved) return schemasteryModule;
66
- const entry = resolveHostModule('@deepseek-ai/schemastery');
67
- if (!entry) {
68
- schemasteryResolved = true;
69
- schemasteryModule = null;
70
- return null;
71
- }
72
- let loaded;
73
- try {
74
- loaded = require(entry);
75
- } catch {
76
- schemasteryResolved = true;
77
- schemasteryModule = null;
78
- return null;
79
- }
152
+ const loaded = requireHostModule('@deepseek-ai/schemastery');
80
153
  const z = loaded && typeof loaded.object === 'function' ? loaded : loaded?.default;
81
154
  schemasteryModule = z && typeof z.object === 'function' ? z : null;
82
155
  schemasteryResolved = true;
@@ -87,12 +160,16 @@ function loadSchemastery() {
87
160
  function resetHostModuleCache() {
88
161
  schemasteryModule = undefined;
89
162
  schemasteryResolved = false;
163
+ hostModuleCache.clear();
90
164
  }
91
165
 
92
166
  module.exports = {
93
167
  HOST_MODULES_ENV,
168
+ ancestorAnchors,
169
+ electronAnchors,
94
170
  hostAnchors,
95
171
  resolveHostModule,
172
+ requireHostModule,
96
173
  loadSchemastery,
97
174
  resetHostModuleCache,
98
175
  };