@max-null/dsh-plugin-center 0.2.12 → 0.2.14

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/dist/update.js CHANGED
@@ -3,12 +3,63 @@
3
3
  * npm registry is the primary version source; changelog is commit-history
4
4
  * first (many community repos ship no release/tag/CHANGELOG — verified §7.2).
5
5
  */
6
- import { spawn } from 'node:child_process';
7
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
6
+ import { execFileSync, spawn } from 'node:child_process';
7
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { compareVersions, satisfies } from "./semver.js";
12
+ /** 服务面判定:目标客户端 bundle 是否深度依赖 Remote BFF(ctx.remote.*)。
13
+ * SSiD 内核(0.1.x)无 remote BFF 服务(走 /plugin-center RPC channel),
14
+ * 这类版本在 SSiD 上必然「pending waiting for service: remote.session」。
15
+ * 案例: dsh-sidebar-qa 0.4.1/0.4.2(2026-08-29 两次实崩)。 */
16
+ export function clientBundleUsesRemote(content) {
17
+ return /ctx\.remote\.[a-zA-Z_$]+\./.test(content);
18
+ }
19
+ /** 目标版本客户端 bundle 缓存:name@version → true/false。 */
20
+ const remoteUseCache = new Map();
21
+ /** 下载目标 tgz 并抽取 client bundle,判定 remote 服务依赖。
22
+ * 仅返回 boolean 不用 pnpm(直接 registry 下载 tgz + bsdtar 抽文件)。 */
23
+ export async function targetClientUsesRemote(name, version) {
24
+ const key = `${name}@${version}`;
25
+ const hit = remoteUseCache.get(key);
26
+ if (hit !== undefined)
27
+ return hit;
28
+ let result = false;
29
+ const tmp = mkdtempSync(join(homedir(), '.dsh', 'tmp-remoteprobe-'));
30
+ try {
31
+ const tarballName = `${name.replace(/^@.*\//, '')}-${version}.tgz`;
32
+ for (const registry of ['https://registry.npmjs.org', 'https://registry.npmmirror.com']) {
33
+ try {
34
+ const res = await fetch(`${registry}/${name}/-/${tarballName}`, { signal: AbortSignal.timeout(15000) });
35
+ if (!res.ok)
36
+ continue;
37
+ const tgz = join(tmp, tarballName);
38
+ writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
39
+ // 找 client bundle 文件(约定 client.js / lib/client.js / client/*.js)
40
+ const listing = execFileSync('tar', ['-tzf', tgz], { encoding: 'utf8', timeout: 30000 });
41
+ const lines = (listing ?? '').split('\n').map(l => l.trim()).filter(l => l.endsWith('.js'));
42
+ const candidates = lines.filter(l => /(^|\/)client(\.js|\/)|\/client\//.test(l) || l.includes('/client.js'));
43
+ const file = candidates.find(l => l.endsWith('client.js'));
44
+ if (file !== undefined) {
45
+ const bundle = execFileSync('tar', ['-xzOf', tgz, file], { encoding: 'utf8', timeout: 30000 });
46
+ result = clientBundleUsesRemote(String(bundle));
47
+ }
48
+ break;
49
+ }
50
+ catch { /* next registry */ }
51
+ }
52
+ }
53
+ catch { /* 任何失败保持 false(不误伤) */ }
54
+ finally {
55
+ try {
56
+ rmSync(tmp, { recursive: true, force: true });
57
+ }
58
+ catch { /* best-effort */ }
59
+ }
60
+ remoteUseCache.set(key, result);
61
+ return result;
62
+ }
12
63
  const UA = { 'User-Agent': 'dsh-plugin-center' };
13
64
  /** Latest published version on the npm registry; null when unreachable/unpublished. */
14
65
  export async function npmLatest(packageName) {
@@ -24,6 +75,62 @@ export async function npmLatest(packageName) {
24
75
  }
25
76
  return null;
26
77
  }
78
+ // ---- 上游同源判定(2026-08-29):包名相同 ≠ 同一项目 ---------------
79
+ // 案例: dsh-session-manager 本地 0.2.2(dream12347 定制)vs npm 0.4.1
80
+ // (hkkz9522 独立同名项目)——机械升级按包名匹配会误报并覆盖定制。
81
+ // 本地为 vendor/tarball/local-file 来源时,校验两边 repository 是否一致。
82
+ const npmRepoCache = new Map();
83
+ const NPM_REPO_TTL = 24 * 3600_000;
84
+ /** 测试用:清空 repository 缓存(生产无调用)。 */
85
+ export function clearNpmRepoCache() { npmRepoCache.clear(); }
86
+ /** 读 npm 包根级 repository.url(带 24h 缓存;失败/缺失 null)。 */
87
+ export async function npmRepository(packageName) {
88
+ const hit = npmRepoCache.get(packageName);
89
+ if (hit !== undefined && Date.now() - hit.at < NPM_REPO_TTL)
90
+ return hit.repo;
91
+ let repo = null;
92
+ for (const registry of ['https://registry.npmjs.org', 'https://registry.npmmirror.com']) {
93
+ try {
94
+ const res = await fetch(`${registry}/${packageName}`, { signal: AbortSignal.timeout(8000) });
95
+ if (res.ok) {
96
+ const doc = await res.json();
97
+ repo = typeof doc.repository === 'object' && doc.repository !== null ? doc.repository.url ?? null : typeof doc.repository === 'string' ? doc.repository : null;
98
+ break;
99
+ }
100
+ }
101
+ catch { /* next registry */ }
102
+ }
103
+ const entry = { at: Date.now(), repo };
104
+ npmRepoCache.set(packageName, entry);
105
+ return repo;
106
+ }
107
+ /** 仓库 URL 归一化(去 scheme/git+ 前缀/尾 .git/尾斜杠/大小写)用于同源比较。 */
108
+ export function normalizeRepoUrl(url) {
109
+ return url
110
+ .trim()
111
+ .replace(/^git\+/, '')
112
+ .replace(/^https?:\/\//, '')
113
+ .replace(/^git:\/\//, '')
114
+ .replace(/^ssh:\/\/git@/, '')
115
+ .replace(/\.git$/, '')
116
+ .replace(/\/$/, '')
117
+ .replace(/^github\.com\//, '')
118
+ .replace(/@/g, '')
119
+ .toLowerCase();
120
+ }
121
+ /** 同源判定:true=同一上游;false=同名异源;null=无法判定(任一侧缺 repo)。 */
122
+ export async function isSameUpstream(localRepoUrl, packageName) {
123
+ if (localRepoUrl === null)
124
+ return null;
125
+ const npmRepo = await npmRepository(packageName);
126
+ if (npmRepo === null)
127
+ return null;
128
+ const a = normalizeRepoUrl(localRepoUrl);
129
+ const b = normalizeRepoUrl(npmRepo);
130
+ if (a === '' || b === '')
131
+ return null;
132
+ return a === b;
133
+ }
27
134
  /** Extract owner/repo from a package.json repository field. */
28
135
  function repoOf(repoUrl) {
29
136
  if (repoUrl === null)
@@ -62,6 +169,12 @@ export async function detectUpdate(name, localVersion, repoUrl, compatRange, loc
62
169
  if (compatRange !== null) {
63
170
  compat = satisfies(localDshVersion, compatRange) ? 'compatible' : 'incompatible';
64
171
  }
172
+ // 服务面校验(SSiD 专用):目标版本客户端依赖 Remote BFF(ctx.remote.*)而
173
+ // SSiD 内核无该服务 → 标不兼容(否则升级后内核启动即 failed)。
174
+ if (compat !== 'incompatible' && process.env.SSID_PENDING_CONSUMER === '1') {
175
+ if (await targetClientUsesRemote(name, latest))
176
+ compat = 'incompatible';
177
+ }
65
178
  return {
66
179
  name,
67
180
  fromVersion: localVersion,
@@ -185,6 +298,32 @@ export function pnpmCandidates() {
185
298
  }
186
299
  return commands;
187
300
  }
301
+ /** One node executable that can run pnpm scripts.
302
+ * SSiD 注入的 SSID_MCP_NODE(与 SSID_PNPM 同模式注入,存在即用)→ 本进程
303
+ * execPath(官方 dsh 是 node 进程)→ PATH 的 node。 */
304
+ function nodeCandidate() {
305
+ const fromEnv = process.env.SSID_MCP_NODE;
306
+ if (fromEnv !== undefined && fromEnv !== '')
307
+ return fromEnv;
308
+ const exe = process.execPath;
309
+ if (/node(?:\.exe)?$/i.test(exe))
310
+ return exe;
311
+ return 'node';
312
+ }
313
+ /** Wrap a bundled pnpm CLI path into a runnable command line. SSID_PNPM
314
+ * (SSiD 捆绑 pnpm) points at `pnpm.cjs` — a node script. On Windows,
315
+ * spawning it directly through `shell: true` makes cmd hand the .cjs to
316
+ * its file association (ShellExecute): cmd returns exit 0 immediately and
317
+ * node never runs (2026-08-25 another machine: 142ms fake success, npm
318
+ * add 未生效; output redirection test produced a 0-byte file). Non-.cjs
319
+ * paths (e.g. pnpm.exe) are used as-is.
320
+ */
321
+ export function pnpmExecCommand(bundled) {
322
+ if (!/\.(cjs|mjs|js)$/i.test(bundled))
323
+ return bundled;
324
+ const node = nodeCandidate();
325
+ return node === null ? bundled : `"${node}" "${bundled}"`;
326
+ }
188
327
  /** 归档 profile 的 node_modules 由 pnpm `<major>` 生成(SSiD 部署时把构建机
189
328
  * store 元数据改写成本机路径且保留 major 后缀——shell/main.mjs rewire)。
190
329
  * 若执行机全局 pnpm 是另一个 major(常见:机器装 pnpm 10,归档是 pnpm 11
@@ -214,7 +353,7 @@ export function pnpmCommandCandidates(profileDir) {
214
353
  const commands = [];
215
354
  const bundled = process.env.SSID_PNPM;
216
355
  if (bundled !== undefined && bundled !== '')
217
- commands.push(bundled);
356
+ commands.push(pnpmExecCommand(bundled));
218
357
  commands.push(...pnpmCandidates());
219
358
  const major = detectStoreMajor(profileDir);
220
359
  if (major !== undefined) {
@@ -449,3 +588,93 @@ export async function updatePlugin(packageName, version, profileDir) {
449
588
  }
450
589
  return result;
451
590
  }
591
+ export function sourceOf(specifier, profileDir) {
592
+ if (specifier.startsWith('@deepseek-ai/dsh-'))
593
+ return 'official';
594
+ // tarball 判定必须在 vendor 之前(否则 'file:./vendor/x.tgz' 被 vendor 分支截胡)。
595
+ if (specifier.startsWith('file:./vendor/') && specifier.endsWith('.tgz'))
596
+ return 'tarball';
597
+ if (specifier.startsWith('file:./vendor/'))
598
+ return 'vendor';
599
+ if (specifier.startsWith('file:') || specifier.startsWith('link:'))
600
+ return 'local-file';
601
+ if (specifier.startsWith('github:') || specifier.startsWith('git+'))
602
+ return 'tarball';
603
+ // ^x.y.z / x.y.z / ~x.y.z → npm 源
604
+ return 'npm';
605
+ }
606
+ /** 读 profile dependencies 里该插件的声明形态(npm 纯净 / file: vendor / link: 等)。 */
607
+ export function dependencySpecifierOf(profileDir, name) {
608
+ try {
609
+ const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
610
+ return pkg.dependencies?.[name] ?? null;
611
+ }
612
+ catch {
613
+ return null;
614
+ }
615
+ }
616
+ /** 采集一个插件用于 LLM 更新的完整信息包。 */
617
+ export async function buildLlmPackage(name, localVersion, repoUrl, compatRange, localDshVersion, sinceIso, profileDir) {
618
+ const latest = await npmLatest(name);
619
+ const specifier = dependencySpecifierOf(profileDir, name);
620
+ const source = specifier === null ? 'npm' : sourceOf(specifier, profileDir);
621
+ const isVendorModified = source === 'vendor' || source === 'tarball' || source === 'local-file';
622
+ // 同名异源:本地非 npm 来源时校验 npm 同名包上游是否一致(不一致 → 警告)。
623
+ const upstreamMismatch = (source === 'vendor' || source === 'tarball' || source === 'local-file')
624
+ ? (await isSameUpstream(repoUrl, name)) === false
625
+ : false;
626
+ let compat = 'unknown';
627
+ if (compatRange !== null) {
628
+ compat = satisfies(localDshVersion, compatRange) ? 'compatible' : 'incompatible';
629
+ }
630
+ // 服务面校验(SSiD 专用):目标版本依赖 Remote BFF 服务 → 不兼容(LLM 直接 keep)。
631
+ if (compat !== 'incompatible' && latest !== null && process.env.SSID_PENDING_CONSUMER === '1') {
632
+ if (await targetClientUsesRemote(name, latest))
633
+ compat = 'incompatible';
634
+ }
635
+ const pkg = {
636
+ name,
637
+ fromVersion: localVersion,
638
+ toVersion: latest,
639
+ // 变更取 commit changelog(与 detectUpdate 一致,社区 repo 常无 release notes)
640
+ changelog: (await fetchCommitChangelog(repoUrl, sinceIso)).slice(0, 10),
641
+ compat,
642
+ compatRange,
643
+ source,
644
+ specifier,
645
+ isVendorModified,
646
+ profileDir,
647
+ upstreamMismatch,
648
+ // 小白视角环境标签:SSiD 内核(kernel.ts)在 boot 时设置该变量;官方 DSH web 无。
649
+ runtimeLabel: process.env.SSID_PENDING_CONSUMER === '1' ? 'SSID' : 'DSH-WEB',
650
+ prompt: '',
651
+ };
652
+ return { ...pkg, prompt: buildLlmPrompt(pkg) };
653
+ }
654
+ /** 组装发给 LLM 会话的 prompt(角色设定 + 信息包 + 规则引用)。 */
655
+ export function buildLlmPrompt(pkg) {
656
+ const srcBadge = pkg.source.toUpperCase();
657
+ return [
658
+ '你是 dsh 插件更新决策 Agent。请严格按「dsh-plugin-upgrade」skill 的规则决策并执行本插件更新。',
659
+ '',
660
+ `插件: ${pkg.name}`,
661
+ `当前版本: ${pkg.fromVersion}`,
662
+ `npm 最新: ${pkg.toVersion ?? '(未发布或不可达)'}`,
663
+ `来源: ${srcBadge}${pkg.isVendorModified ? '(本地定制!机械更新会覆盖,需核对作者是否已采纳)' : ''}`,
664
+ ...(pkg.upstreamMismatch ? [`同名异源警告: npm 上的 ${pkg.name} 与本地上游不是同一项目(repository 不一致,如独立同名项目),升级将丢失本地定制——执行前务必核实来源。`] : []),
665
+ `依赖声明: ${pkg.specifier ?? '(非 npm 依赖)'}`,
666
+ `安装位置: ${pkg.profileDir}(唯一允许操作目录!本会话工作区与之不同,严禁按会话 cwd 操作)`,
667
+ `DSH 兼容: ${pkg.compat} (要求 ${pkg.compatRange ?? '未知'})`,
668
+ `变更: ${pkg.changelog.join('; ') || '(无 changelog,查 GitHub release/tag)'}`,
669
+ '',
670
+ '规则要点(详见 skill): ',
671
+ '1. 本地超前于 npm → 保持本地,不升级(vendor 魔改第一优先)。',
672
+ '2. vendor/定制 → 下载 npm 版对比是否已被作者采纳;采纳后切 npm 版,未采纳保持 vendor。',
673
+ '3. peer 缺失/不兼容 → 检查依赖树,先修复或回退,禁止让 DSH 启动失败。',
674
+ '4. Windows EPERM 锁 → 走两段式(pending 预下载)或 CLI 指令。',
675
+ '5. pnpm exit 0 假执行 → 校验实体版本,不符则重试或给手动命令。',
676
+ '6. SSiD 预置插件升级 → 注意同步归档(profile-template/vendor)。',
677
+ '',
678
+ '完成后回传: 决策(action) + 执行摘要(detail) + 状态(upgrade/keep/switch-npm/failed)。',
679
+ ].join('\n');
680
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-plugin-center",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Plugin center for DeepSeek Harness 閳?installed metadata, community market, update detection, and What's New",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -22,7 +22,8 @@
22
22
  "dist",
23
23
  "client.js",
24
24
  "cordis.patch.yml",
25
- "assets"
25
+ "assets",
26
+ "skills"
26
27
  ],
27
28
  "dsh": {
28
29
  "bundle": {
@@ -59,7 +60,8 @@
59
60
  },
60
61
  "scripts": {
61
62
  "build": "tsc -p tsconfig.json && node build-client.mjs",
62
- "typecheck": "tsc --noEmit -p tsconfig.json"
63
+ "typecheck": "tsc --noEmit -p tsconfig.json",
64
+ "test": "vitest run"
63
65
  },
64
66
  "dependencies": {
65
67
  "js-yaml": "^4.1.0"
@@ -80,6 +82,7 @@
80
82
  "@types/react": "~18.3.1",
81
83
  "esbuild": "^0.24.0",
82
84
  "react": "^18.2.0",
83
- "typescript": "^5.5.0"
85
+ "typescript": "^5.5.0",
86
+ "vitest": "^4.1.11"
84
87
  }
85
88
  }
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: dsh-plugin-upgrade
3
+ description: DSH 插件更新决策与执行规则——LLM 更新会话的核心技能。当会话收到「插件更新」信息包(插件名/当前版本/npm 最新/来源/兼容性/变更)时按本技能决策;本地超前以本地为准、vendor 定制核对作者采纳、peer 缺失修复、Windows EPERM 两段式、pnpm 假执行校验。Triggered by plugin update packages sent by dsh-plugin-center's LLM update flow.
4
+ ---
5
+
6
+ # DSH Plugin Upgrade
7
+
8
+ 你是 DSH(思灵/SSiD)插件更新的决策 Agent。用户(插件中心 LLM 更新入口)交给你一个信息包,你的职责是:**判断要不要升级、怎么升级、升级后验证、失败时修复**。全程遵守本技能规则,禁止越权操作。
9
+
10
+ ## 信息包字段(prompt 中给出)
11
+
12
+ | 字段 | 含义 |
13
+ |------|------|
14
+ | `name` | 插件名(如 dsh-dream-skin、dsh-sidebar-qa) |
15
+ | `fromVersion` | 当前本地版本(实体 package.json 为准;版本号「超前」的判断基准) |
16
+ | `toVersion` | npm latest(null = 未发布或 npm 不可达,转 GitHub tag/commit 路径) |
17
+ | `source` | `npm` / `official` / `vendor` / `tarball` / `local-file` |
18
+ | `specifier` | 依赖声明形态(`file:./vendor/...`、`github:...`、`^0.4.0` 等) |
19
+ | `isVendorModified` | 本地是否定制(vendor/tarball/local-file 为 true) |
20
+ | `compat` / `compatRange` | DSH peer 兼容性(compatible / incompatible / unknown) |
21
+ | `changelog` | GitHub commit 摘要(用于判断版本差异大小与作者改名) |
22
+ | `profileDir` | **插件所在 profile 目录(唯一允许操作目录)**——DSH web / SSiD dev / SSiD 安装版各自 profile 不同,严禁按本会话工作目录(cwd)或任何其他 profile 操作 |
23
+
24
+ ## 操作域校验(开始前必做)
25
+
26
+ 1. `Test-Path "$profileDir\node_modules\<name>\package.json"` —— 信息包声明的安装位置必须真实存在;
27
+ 2. 若该路径与实际不一致(安装位置错误/环境串扰),**立即停止**并回传 `action: failed, detail: 安装位置不符: <声明路径> vs <实际路径>`;
28
+ 3. 所有 pnpm / npm / git / 读写操作一律以 `$profileDir` 为 cwd 执行;**禁止**对会话工作目录(如 H:\MaxNull\WorkStation)或其他 profile(~/.dsh/profiles/web、.dsh/profiles/headless 等)执行任何更新/安装。
29
+ 4. 更新前记录 `$profileDir\package.json` 的依赖声明原值;完成后回传「实际修改的目录 = <profileDir>」。
30
+
31
+ ## 决策树(按优先级,自上而下——第一条命中即执行)
32
+
33
+ ```
34
+ 1. 本地超前 → 不升级
35
+ 条件: 本地版本 > npm latest(数值比较, 不是字符串)
36
+ 动作: 保持本地, action=keep, detail="本地 X 已超前 npm Y"
37
+ (这是 vendor 魔改第一优先: 本地定制且未发布到 npm 的版本永远以本地为准)
38
+
39
+ 2. vendor/定制来源 → 核对作者是否已采纳
40
+ 条件: source ∈ {vendor, tarball, local-file} 或 isVendorModified=true
41
+ 动作:
42
+ a. 读 npm 上该包 latest(info 包 toVersion 已给)
43
+ b. 比对 npm 版本新特性是否已包含本地定制(读本地 vendor 的 package.json/CHANGELOG
44
+ 与 npm 版本 changelog 对比; 定制点通常能在 npm release 中看到对应 commit 说明)
45
+ c. 已采纳 → action=switch-npm: 改 profile 依赖声明为 npm 版本并安装
46
+ d. 未采纳/不确定 → action=keep: 保持 vendor, 明确告知用户"上游未采纳, 建议上游提交"
47
+ (机械更新会直接覆盖定制文件——这就是本技能存在的意义)
48
+
49
+ 3. peer/依赖缺失或不兼容 → 先修, 禁止裸升
50
+ 条件: compat=incompatible, 或安装后 DSH 启动失败/依赖缺包
51
+ 动作:
52
+ a. 查目标版本 peerDependencies(pnpm view <pkg>@<ver> peerDependencies)
53
+ b. 缺的包按 profile 版本策略补装; 冲突时**回退**到兼容版本
54
+ c. 修改后必须验证: 不可让 DSH 出现 "Failed to load plugins (pending waiting for service...)" 式启动失败
55
+ (案例: dsh-sidebar-qa 0.4.1 缺 dsh-client-ui-primitives → 回退 0.4.0 或补装缺失依赖)
56
+
57
+ 4. npm 纯净来源 → 升 npm 最新
58
+ 条件: source ∈ {npm, official} 且 本地 < npm latest
59
+ 动作:
60
+ a. 更新 profile dependency 声明(pnpm add/update or 改 package.json + pnpm install)
61
+ b. 安装后校验实体版本(见 §pnpm 假执行)
62
+ c. 若目标版本要求 DSH 高于当前 → 提示用户, 不硬升
63
+
64
+ 5. Windows EPERM 锁(两段式):
65
+ 条件: 安装报 EPERM/EBUSY(文件被正在运行的 DSH 占用)
66
+ 动作:
67
+ a. 检查插件中心是否已有 pending 机制: 有则走 pending 预下载(插件中心重启后自动安装)
68
+ b. 无则给出 CLI 指令: 关闭应用后在终端执行, 回传 command 字段
69
+ (禁止强制删除被锁文件)
70
+
71
+ 6. pnpm 假执行校验:
72
+ 条件: pnpm 命令 exit 0 但实体版本没变
73
+ 动作: 读 profile/node_modules/<pkg>/package.json 的 version
74
+ a. 版本已更新 → 真成功
75
+ b. 版本没变 → 重试一次; 再失败则回传手动命令, 不谎报成功
76
+ ```
77
+
78
+ ## 工具白名单(越权即拒绝)
79
+
80
+ **允许:**
81
+ - 读: `pnpm view` / `npm view` / `git log` / 读 profile 内 package.json、node_modules、vendor
82
+ - 写: 仅限 **信息包 `profileDir` 声明的那一个 profile** 的依赖声明与 node_modules(`pnpm add` / `pnpm update` / `pnpm install`,一律以该目录为 cwd)
83
+ - 报告: 写 `~/.dsh/plugin-center/llm-update-log.jsonl`(见回传格式)
84
+
85
+ **禁止:**
86
+ - 改 `~/.dsh/profiles/*` 之外的其他 profile
87
+ - 改系统目录、删除 DSH 内核文件、改 shell/运行时配置
88
+ - 未知来源命令、npm install 全局包、改 CI/发布脚本
89
+ - 未经用户确认的破坏性操作(删除 vendor 目录前必须报告)
90
+
91
+ ## 完成后回传格式(必须执行)
92
+
93
+ 1. **写主机状态文件(插件中心轮询的证据源)**:把决策结果以**单行 JSON** 追加到
94
+ `$env:DSH_HOME\plugin-center\llm-update-log.jsonl`(`~/.dsh/plugin-center/` 下):
95
+ ```powershell
96
+ Add-Content -Path "$env:DSH_HOME\plugin-center\llm-update-log.jsonl" -Value '{"name":"<插件名>","action":"upgrade|keep|switch-npm|fix-peer|failed","detail":"<一句话摘要,含实体版本/命令/错误>","status":"success|pending|failed","at":<当前毫秒时间戳>}'
97
+ ```
98
+ 字段要求:`status` 只允许 `success` / `pending` / `failed`;`detail` 一行内写完;
99
+ `at` 填当前毫秒时间戳(PowerShell: `[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()`);
100
+ action=keep 表示「保持不动、未更新」——插件中心据此显示「保持」而非「已更新」。
101
+ 2. 同时在会话最后一条消息用同一格式回传(供用户阅读),例如:
102
+ ```
103
+ action: upgrade
104
+ detail: dsh-dream-skin 8.27.0 → 8.28.0, pnpm update 完成, 实体版本已校验
105
+ status: success
106
+ ```
107
+ > 忘记写状态文件时,插件中心会用 20 分钟兜底保留「执行中」并提示查看会话——务必写。
108
+
109
+ ## 常见陷阱
110
+
111
+ - **版本比较**用数值逐段比较(0.9.10 > 0.9.9),不要用字符串或只比主版本。
112
+ - **服务级依赖(致命,2026-08-29 二次血泪)**:升级前必须核对目标版本的 `client inject`/host 服务依赖是否为当前内核所提供——**SSiD 内核 0.1.1-rc.2 没有 `remote.session` Remote BFF 服务**(SSiD 走 `/plugin-center` 式 RPC channel)。dsh-sidebar-qa 0.4.1/0.4.2 的客户端依赖 `remote.session` → 装上升级后内核启动即「Failed to load plugins (pending waiting for service: remote.session)」。**peer 满足 ≠ 服务满足**:0.4.2 的 peer 全部满足,但服务缺失。判定:反编译/读目标版本 client.js 的 inject 列表,有 `remote.*` 且当前 profile 无 `@deepseek-ai/dsh-*remotes*` 对应服务 → 不兼容保持现状。
113
+ - **npm 范围漂移**:profile 声明常为 `^0.4.0`,pnpm 会把 `^0.4.0` 浮到 `0.4.2`;升级后核对实体版本,必要时把声明钉死到精确版本并在 detail 说明钉死原因。
114
+ - **SSiD 预置插件**:升级后需同步归档(profile-template / vendor 目录),否则打包时被旧版覆盖——在 detail 中注明「需归档同步」。
115
+ - **hot 通道**:纯前端插件更新可能已热生效,升级成功后仍建议重启一次确认加载无错。
116
+
117
+ ## 与插件中心 UI 的关系
118
+
119
+ - 插件中心「LLM 更新」按钮把信息包注入本会话;本会话即「插件更新」会话(复用同名会话)。
120
+ - 升级完成后回到「已安装 / 更新」页核对:实体版本、是否还出现在更新列表、是否标了「待重启」。
121
+ - 若插件中心显示「更新假成功」,按 §6 重新校验并把结果回传。