@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/engine.d.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { Service, type Context } from '@deepseek-ai/cordis';
9
9
  import { type InstalledPlugin } from './meta.ts';
10
10
  import { type MarketPlugin } from './market.ts';
11
- import { type PnpmResult, type UpdateDigest } from './update.ts';
11
+ import { type LlmUpdatePackage, type PnpmResult, type UpdateDigest } from './update.ts';
12
+ import { type LlmLogRecord } from './llm-log.ts';
12
13
  declare module '@deepseek-ai/cordis' {
13
14
  interface Context {
14
15
  /** The plugin-center engine (provided by this package's host half). */
@@ -75,6 +76,11 @@ export declare class PluginCenterEngine extends Service {
75
76
  readVersions(): Promise<Record<string, string>>;
76
77
  /** Persist the read-mark (best-effort; a quota/IO failure just loses the mark). */
77
78
  markRead(versions: Record<string, string>): Promise<void>;
79
+ /** 弹窗当天已展示标记(2026-08-27):host 文件(DSH web 端口随机,
80
+ * localStorage 按 origin 隔离会丢;文件侧稳定)。 */
81
+ private get whatsNewDailyPath();
82
+ whatsNewDaily(): Promise<string>;
83
+ markWhatsNewDaily(day: string): Promise<void>;
78
84
  /** Current DSH version, read from the installed @deepseek-ai/dsh package. */
79
85
  dshVersion(): Promise<string>;
80
86
  /** Non-group Loader entries, cross-matched with market categories. */
@@ -105,6 +111,20 @@ export declare class PluginCenterEngine extends Service {
105
111
  * - 官方 dsh web(无消费方)→ 仿社区市场返回可复制 CLI 指令;
106
112
  * 3. 非锁失败(网络/版本)→ 原样报错。 */
107
113
  update(name: string, version: string): Promise<PnpmResult>;
114
+ /** LLM 驱动更新准备:采集信息包供确认面板/会话 prompt 使用(2026-08-28)。
115
+ * 只读采集(npm/GitHub/本地 package.json),不执行任何安装——执行由 LLM
116
+ * Agent 在「插件更新」会话中按 skill 决策后完成。 */
117
+ llmUpdatePrepare(name: string): Promise<LlmUpdatePackage | null>;
118
+ /** 追加一条 LLM 更新动作日志(JSONL,供 client 轮询结果展示)。 */
119
+ appendLlmUpdateLog(entry: {
120
+ name: string;
121
+ action: string;
122
+ detail: string;
123
+ status: 'pending' | 'running' | 'success' | 'failed';
124
+ }): Promise<void>;
125
+ /** 读取某个插件最近一条 LLM 更新动作(JSONL 逆序找 name 匹配);
126
+ * 无记录返回 null。client 轮询据此做三态(进行中/成功/失败)。 */
127
+ readLlmUpdateResult(name: string): Promise<LlmLogRecord | null>;
108
128
  /** 串行执行一次 pnpm 操作并失效缓存(无论成败都放行链条后续任务)。 */
109
129
  private enqueuePnpm;
110
130
  /** Temporary diagnostics for the empty-update bug; removed once root-caused. */
@@ -116,8 +136,12 @@ export declare class PluginCenterEngine extends Service {
116
136
  source: string;
117
137
  }[];
118
138
  }>;
119
- /** Disable/enable one loader entry through the profile patch layer. */
120
- toggle(id: string, disabled: boolean): Promise<{
139
+ /** Disable/enable one loader entry through the profile patch layer.
140
+ * 2026-08-25 禁用失效:`dsh plugin add` 清单的 insert 子条目无 id,loader
141
+ * 每次启动分配随机运行时 id,按它写禁用行重启后永远匹配不到。当 patch
142
+ * 文件中没有 `- id: <entryId>` 行时,改用该条目的包名 name 作寻址键
143
+ * (setDisabled 内按 name 把 insert 子条目升级为稳定 id 后再写禁用行)。 */
144
+ toggle(id: string, name: string, disabled: boolean): Promise<{
121
145
  ok: boolean;
122
146
  detail: string;
123
147
  nowDisabled: boolean | null;
package/dist/engine.js CHANGED
@@ -12,9 +12,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { buildInstalledPlugin, clearPackageCache, resolvePackage } from "./meta.js";
14
14
  import { fetchAwesomePluginsJson, fetchDshMarketPlugins, fetchOhMyDshOverrides, fetchOhMyDshPlugins, mapConcurrent, mergePlugins, } from "./market.js";
15
- import { detectUpdate, installPlugin, preparePluginUpdate, updatePlugin } from "./update.js";
15
+ import { detectUpdate, installPlugin, preparePluginUpdate, updatePlugin, buildLlmPackage, dependencySpecifierOf, isSameUpstream, sourceOf, } from "./update.js";
16
16
  import { reconcileInstalled, readDependencyKeys } from "./reconcile.js";
17
- import { readDisabledState, setDisabled } from "./toggle.js";
17
+ import { readDisabledState, setDisabled, escapeRegExp } from "./toggle.js";
18
+ import { appendLlmLog, readLlmLogLatest } from "./llm-log.js";
18
19
  /** Runtime mirror of cordis FiberState (a cross-package const enum). */
19
20
  const FIBER_PHASE = {
20
21
  0: 'pending',
@@ -135,6 +136,30 @@ export class PluginCenterEngine extends Service {
135
136
  }
136
137
  catch { /* best-effort */ }
137
138
  }
139
+ /** 弹窗当天已展示标记(2026-08-27):host 文件(DSH web 端口随机,
140
+ * localStorage 按 origin 隔离会丢;文件侧稳定)。 */
141
+ get whatsNewDailyPath() {
142
+ return join(this.dshHome, 'plugin-center-whatsnew-daily.json');
143
+ }
144
+ async whatsNewDaily() {
145
+ try {
146
+ const parsed = JSON.parse(await readFile(this.whatsNewDailyPath, 'utf8'));
147
+ return typeof parsed === 'object' && parsed !== null && typeof parsed.day === 'string'
148
+ ? parsed.day
149
+ : '';
150
+ }
151
+ catch {
152
+ return '';
153
+ }
154
+ }
155
+ async markWhatsNewDaily(day) {
156
+ try {
157
+ const path = this.whatsNewDailyPath;
158
+ await mkdir(dirname(path), { recursive: true });
159
+ await writeFile(path, JSON.stringify({ day, at: new Date().toISOString() }), 'utf8');
160
+ }
161
+ catch { /* best-effort */ }
162
+ }
138
163
  /** Current DSH version, read from the installed @deepseek-ai/dsh package. */
139
164
  async dshVersion() {
140
165
  const resolved = await resolvePackage(this.baseUrl, '@deepseek-ai/dsh');
@@ -310,7 +335,22 @@ export class PluginCenterEngine extends Service {
310
335
  return hit.digests;
311
336
  const [installed, localDsh] = await Promise.all([this.listInstalled(), this.dshVersion()]);
312
337
  const candidates = installed.filter(p => UPDATABLE.has(p.source) && p.version !== null);
313
- const digests = await Promise.all(candidates.map(p => detectUpdate(p.name, p.version, p.repoUrl, p.compatRange, localDsh, sinceIso)));
338
+ const digests = await Promise.all(candidates.map(async (p) => {
339
+ // 同名异源保护(2026-08-29):本地自定义来源(依赖声明 vendor/tarball/
340
+ // local-file)时,校验 npm 同名包是否同一上游——不一致(如 dream12347
341
+ // 定制 vs hkkz9522 独立同名项目)排除出更新列表,防误报与覆盖定制。
342
+ // 无法判定(任一侧缺 repository,如 dsh-session-manager 0.2.2 作者未
343
+ // 声明)同样排除:宁少报不漏报——漏报只少一个提示,误报是 LLM 白跑 +
344
+ // 用户被误导;人工核对入口见 InstalledView/LLM 会话。
345
+ const spec = dependencySpecifierOf(this.baseUrl, p.name);
346
+ const src = spec === null ? 'npm' : sourceOf(spec, this.baseUrl);
347
+ if (src === 'vendor' || src === 'tarball' || src === 'local-file') {
348
+ const same = await isSameUpstream(p.repoUrl, p.name);
349
+ if (same !== true)
350
+ return null;
351
+ }
352
+ return detectUpdate(p.name, p.version, p.repoUrl, p.compatRange, localDsh, sinceIso);
353
+ }));
314
354
  this.updatesCache = { since: sinceIso, at: now, digests: digests.filter((d) => d !== null) };
315
355
  return this.updatesCache.digests;
316
356
  }
@@ -357,6 +397,29 @@ export class PluginCenterEngine extends Service {
357
397
  const profileName = basename(this.baseUrl) || 'web';
358
398
  return { ok: true, detail: '', durationMs: direct.durationMs, command: `dsh plugin --profile ${profileName} add ${name}@${version}` };
359
399
  }
400
+ /** LLM 驱动更新准备:采集信息包供确认面板/会话 prompt 使用(2026-08-28)。
401
+ * 只读采集(npm/GitHub/本地 package.json),不执行任何安装——执行由 LLM
402
+ * Agent 在「插件更新」会话中按 skill 决策后完成。 */
403
+ async llmUpdatePrepare(name) {
404
+ // 从已安装列表找该插件的元数据(版本/repo/兼容范围)
405
+ const installed = await this.listInstalled();
406
+ const p = installed.find(i => i.name === name);
407
+ if (p === undefined || p.version === null)
408
+ return null;
409
+ const localDsh = await this.dshVersion();
410
+ const since = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
411
+ // detectUpdate 同款采集参数,复用 buildLlmPackage
412
+ return buildLlmPackage(p.name, p.version, p.repoUrl, p.compatRange, localDsh, since, this.baseUrl);
413
+ }
414
+ /** 追加一条 LLM 更新动作日志(JSONL,供 client 轮询结果展示)。 */
415
+ async appendLlmUpdateLog(entry) {
416
+ await appendLlmLog(entry);
417
+ }
418
+ /** 读取某个插件最近一条 LLM 更新动作(JSONL 逆序找 name 匹配);
419
+ * 无记录返回 null。client 轮询据此做三态(进行中/成功/失败)。 */
420
+ async readLlmUpdateResult(name) {
421
+ return readLlmLogLatest(name);
422
+ }
360
423
  /** 串行执行一次 pnpm 操作并失效缓存(无论成败都放行链条后续任务)。 */
361
424
  enqueuePnpm(op) {
362
425
  const run = this.pnpmChain.then(async () => {
@@ -378,9 +441,30 @@ export class PluginCenterEngine extends Service {
378
441
  installed: (await this.listInstalled()).map(p => ({ name: p.name, version: p.version, source: p.source })),
379
442
  };
380
443
  }
381
- /** Disable/enable one loader entry through the profile patch layer. */
382
- async toggle(id, disabled) {
383
- const result = await setDisabled(this.baseUrl, id, disabled);
444
+ /** Disable/enable one loader entry through the profile patch layer.
445
+ * 2026-08-25 禁用失效:`dsh plugin add` 清单的 insert 子条目无 id,loader
446
+ * 每次启动分配随机运行时 id,按它写禁用行重启后永远匹配不到。当 patch
447
+ * 文件中没有 `- id: <entryId>` 行时,改用该条目的包名 name 作寻址键
448
+ * (setDisabled 内按 name 把 insert 子条目升级为稳定 id 后再写禁用行)。 */
449
+ async toggle(id, name, disabled) {
450
+ // 老调用方(未透传 name 的 client)兜底:从 loader 实时取包名。
451
+ const entryName = name !== '' ? name : [...this.ctx.loader.entries()].find(e => e.id === id)?.options.name ?? '';
452
+ if (entryName === '') {
453
+ return { ok: false, detail: `entry "${id}" not found in loader`, nowDisabled: null };
454
+ }
455
+ // 寻址键:patch 文件中已有 `- id: <id>` 行 → 稳定 id 直接用;否则是该
456
+ // 条目的随机运行时 id → 只能用 name 找到它的 insert 子条目。
457
+ const patchId = id.replace(/^include:/u, '');
458
+ let key = entryName;
459
+ try {
460
+ const patchPath = join(this.baseUrl, 'cordis.patch.yml');
461
+ if (existsSync(patchPath)
462
+ && new RegExp(`^- id: ${escapeRegExp(patchId)}$`, 'm').test(readFileSync(patchPath, 'utf8'))) {
463
+ key = patchId;
464
+ }
465
+ }
466
+ catch { /* 读失败:走 name 寻址,由 setDisabled 报具体错误 */ }
467
+ const result = await setDisabled(this.baseUrl, key, entryName, disabled);
384
468
  this.installedNamesCache = null;
385
469
  return result;
386
470
  }
@@ -0,0 +1,25 @@
1
+ export type LlmLogStatus = 'pending' | 'running' | 'success' | 'failed';
2
+ export interface LlmLogEntry {
3
+ name: string;
4
+ action: string;
5
+ detail: string;
6
+ status: LlmLogStatus;
7
+ }
8
+ /** 最近一条记录(JSONL 逆序找 name 匹配;坏行跳过;无记录/文件缺失 → null)。 */
9
+ export interface LlmLogRecord {
10
+ at: number;
11
+ name: string;
12
+ action: string;
13
+ detail: string;
14
+ status: LlmLogStatus;
15
+ }
16
+ /** 日志文件路径(DSH_HOME 未设时回退 ~/.dsh)。 */
17
+ export declare function llmLogPath(dshHome?: string | undefined): string;
18
+ /** 追加一条记录(目录自动创建;失败静默——日志绝不阻断主流程)。 */
19
+ export declare function appendLlmLog(entry: LlmLogEntry, dshHome?: string | undefined): Promise<void>;
20
+ /** 解析一行 JSON:合法且 name 匹配 → 记录;其他 → null。 */
21
+ export declare function parseLlmLogLine(line: string, name: string): LlmLogRecord | null;
22
+ /** 读取某插件最近一条记录(逆序;文件缺失/全坏行 → null)。 */
23
+ export declare function readLlmLogLatest(name: string, dshHome?: string | undefined): Promise<LlmLogRecord | null>;
24
+ /** 按文本扫最后一行含 name 的 JSON(提取解析出的 attr——测试断言用)。 */
25
+ export declare function extractLlmLogField(line: string): Record<string, unknown> | null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * LLM 更新动作日志(JSONL)——纯文件读写模块(无 engine 依赖,可单测)。
3
+ * 文件位置:$DSH_HOME/plugin-center/llm-update-log.jsonl
4
+ * 行格式:{"at":number,"name":string,"action":string,"detail":string,"status":...}
5
+ * client 侧轮询 llm-update.result 依赖此文件做三态(执行中/成功/失败)。
6
+ */
7
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ /** 日志文件路径(DSH_HOME 未设时回退 ~/.dsh)。 */
11
+ export function llmLogPath(dshHome = process.env.DSH_HOME) {
12
+ return join(dshHome ?? join(homedir(), '.dsh'), 'plugin-center', 'llm-update-log.jsonl');
13
+ }
14
+ /** 追加一条记录(目录自动创建;失败静默——日志绝不阻断主流程)。 */
15
+ export async function appendLlmLog(entry, dshHome) {
16
+ try {
17
+ const file = llmLogPath(dshHome);
18
+ await mkdir(dirname(file), { recursive: true });
19
+ const line = JSON.stringify({ at: Date.now(), ...entry }) + '\n';
20
+ await appendFile(file, line, 'utf8');
21
+ }
22
+ catch { /* best-effort */ }
23
+ }
24
+ /** 解析一行 JSON:合法且 name 匹配 → 记录;其他 → null。 */
25
+ export function parseLlmLogLine(line, name) {
26
+ try {
27
+ const rec = JSON.parse(line);
28
+ if (rec.name !== name)
29
+ return null;
30
+ const status = rec.status === 'pending' || rec.status === 'running' || rec.status === 'success' || rec.status === 'failed' ? rec.status : 'running';
31
+ return {
32
+ at: typeof rec.at === 'number' ? rec.at : 0,
33
+ name,
34
+ action: typeof rec.action === 'string' ? rec.action : '',
35
+ detail: typeof rec.detail === 'string' ? rec.detail : '',
36
+ status,
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** 读取某插件最近一条记录(逆序;文件缺失/全坏行 → null)。 */
44
+ export async function readLlmLogLatest(name, dshHome) {
45
+ try {
46
+ const text = await readFile(llmLogPath(dshHome), 'utf8');
47
+ const lines = text.split('\n');
48
+ for (let i = lines.length - 1; i >= 0; i--) {
49
+ const line = lines[i]?.trim();
50
+ if (line === undefined || line === '')
51
+ continue;
52
+ const rec = parseLlmLogLine(line, name);
53
+ if (rec !== null)
54
+ return rec;
55
+ }
56
+ return null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** 按文本扫最后一行含 name 的 JSON(提取解析出的 attr——测试断言用)。 */
63
+ export function extractLlmLogField(line) {
64
+ try {
65
+ return JSON.parse(line);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
package/dist/rpc.js CHANGED
@@ -54,12 +54,46 @@ export class PluginCenterRpc extends Service {
54
54
  },
55
55
  };
56
56
  }
57
+ case 'llm-update.prepare': {
58
+ // LLM 驱动更新信息包(只读采集):来源/版本/兼容/变更,供确认面板与
59
+ // 会话 prompt。执行由 LLM Agent 在插件更新会话中按 skill 决策完成。
60
+ const name = payload?.name;
61
+ if (typeof name !== 'string' || name === '')
62
+ return internal('llm-update.prepare: name is required');
63
+ const pkg = await ctx.pluginCenter.llmUpdatePrepare(name);
64
+ if (pkg === null)
65
+ return internal('llm-update.prepare: 插件不存在或版本未知');
66
+ return { ok: true, value: pkg };
67
+ }
68
+ case 'llm-update.log': {
69
+ // 追加一条 LLM 更新动作日志(host JSONL,client 轮询结果展示)。
70
+ const p = payload;
71
+ if (typeof p?.name !== 'string' || typeof p.status !== 'string')
72
+ return internal('llm-update.log: bad payload');
73
+ await ctx.pluginCenter.appendLlmUpdateLog({
74
+ name: p.name,
75
+ action: typeof p.action === 'string' ? p.action : '',
76
+ detail: typeof p.detail === 'string' ? p.detail : '',
77
+ status: p.status,
78
+ });
79
+ return { ok: true, value: null };
80
+ }
81
+ case 'llm-update.result': {
82
+ // 读某插件最近一条 LLM 更新动作(轮询三态:running/success/failed)。
83
+ const name = payload?.name;
84
+ if (typeof name !== 'string' || name === '')
85
+ return internal('llm-update.result: name is required');
86
+ return { ok: true, value: await ctx.pluginCenter.readLlmUpdateResult(name) };
87
+ }
57
88
  case 'toggle': {
58
- const id = payload?.id;
59
- const disabled = payload?.disabled;
89
+ const payload2 = payload;
90
+ const id = payload2?.id;
91
+ const name = payload2?.name;
92
+ const disabled = payload2?.disabled;
60
93
  if (typeof id !== 'string' || id === '')
61
94
  return internal('toggle: id is required');
62
- const result = await ctx.pluginCenter.toggle(id, disabled === true);
95
+ // name 用于无稳定 id 条目的 seek-by-name 寻址(2026-08-25 禁用失效修复)。
96
+ const result = await ctx.pluginCenter.toggle(id, typeof name === 'string' ? name : '', disabled === true);
63
97
  if (!result.ok)
64
98
  return internal(`toggle ${id} 失败:${result.detail}`);
65
99
  return { ok: true, value: { nowDisabled: result.nowDisabled } };
@@ -86,6 +120,14 @@ export class PluginCenterRpc extends Service {
86
120
  const versions = payload?.versions ?? {};
87
121
  return { ok: true, value: await ctx.pluginCenter.markRead(versions) };
88
122
  }
123
+ case 'whatsNewDaily':
124
+ return { ok: true, value: await ctx.pluginCenter.whatsNewDaily() };
125
+ case 'markWhatsNewDaily': {
126
+ const day = payload?.day;
127
+ if (typeof day !== 'string' || day === '')
128
+ return internal('markWhatsNewDaily: day is required');
129
+ return { ok: true, value: await ctx.pluginCenter.markWhatsNewDaily(day) };
130
+ }
89
131
  default:
90
132
  return internal(`unknown endpoint "${endpoint}"`);
91
133
  }
package/dist/toggle.d.ts CHANGED
@@ -10,6 +10,15 @@
10
10
  * followed by an optional `disabled:` line), serialized so concurrent
11
11
  * toggles cannot interleave a read-modify-write, refused when the file is
12
12
  * not a plain entry list, and protected for host-infrastructure rows.
13
+ *
14
+ * Stable ids: `dsh plugin add` install lists mount entries as id-less
15
+ * `insert` children (`- name: X`), which the Loader gives a RANDOM runtime
16
+ * id on every boot (cordis-plugin-loader ensureId). Toggling by that
17
+ * runtime id writes a row no later boot matches (applyEntryPatches warns
18
+ * and skips) — the 2026-08-25 disable-broken bug. When no `- id:` row
19
+ * matches, this module addresses the entry by `name` instead: the id-less
20
+ * insert child is upgraded in place to a stable `- id: X` so the appended
21
+ * disable row actually hits. It never guesses: no match = refused write.
13
22
  */
14
23
  export interface ToggleResult {
15
24
  ok: boolean;
@@ -19,13 +28,18 @@ export interface ToggleResult {
19
28
  }
20
29
  /** What the user patch layer currently says about every row id. */
21
30
  export declare function readDisabledState(patchPath: string): Map<string, boolean>;
31
+ /** Escape a literal for use inside a RegExp (plugin names may contain `.` etc.). */
32
+ export declare function escapeRegExp(text: string): string;
22
33
  /**
23
34
  * Set one entry's disabled stance in the profile patch layer. The file is
24
35
  * only touched when the stance changes; a malformed file (not a plain
25
36
  * entry list) is reported instead of being made worse.
26
37
  * @param profileDir - the profile directory holding cordis.patch.yml.
27
38
  * @param id - the loader entry id to toggle.
39
+ * @param name - the entry's package name; used as the addressing key when
40
+ * `id` is a Loader-assigned random runtime id with no `- id:` row in the
41
+ * patch file (id-less insert children of `dsh plugin add` lists).
28
42
  * @param disabled - the target stance.
29
43
  * @returns the outcome; `nowDisabled` mirrors the stance or null when refused.
30
44
  */
31
- export declare function setDisabled(profileDir: string, entryId: string, disabled: boolean): Promise<ToggleResult>;
45
+ export declare function setDisabled(profileDir: string, entryId: string, name: string, disabled: boolean): Promise<ToggleResult>;
package/dist/toggle.js CHANGED
@@ -10,6 +10,15 @@
10
10
  * followed by an optional `disabled:` line), serialized so concurrent
11
11
  * toggles cannot interleave a read-modify-write, refused when the file is
12
12
  * not a plain entry list, and protected for host-infrastructure rows.
13
+ *
14
+ * Stable ids: `dsh plugin add` install lists mount entries as id-less
15
+ * `insert` children (`- name: X`), which the Loader gives a RANDOM runtime
16
+ * id on every boot (cordis-plugin-loader ensureId). Toggling by that
17
+ * runtime id writes a row no later boot matches (applyEntryPatches warns
18
+ * and skips) — the 2026-08-25 disable-broken bug. When no `- id:` row
19
+ * matches, this module addresses the entry by `name` instead: the id-less
20
+ * insert child is upgraded in place to a stable `- id: X` so the appended
21
+ * disable row actually hits. It never guesses: no match = refused write.
13
22
  */
14
23
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
15
24
  import { join } from 'node:path';
@@ -89,6 +98,46 @@ export function readDisabledState(patchPath) {
89
98
  }
90
99
  return state;
91
100
  }
101
+ /** Escape a literal for use inside a RegExp (plugin names may contain `.` etc.). */
102
+ export function escapeRegExp(text) {
103
+ return text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
104
+ }
105
+ /**
106
+ * Address a `dsh plugin add` install-list insert child by name: an id-less
107
+ * child (` - name: X`, 4-space indent under `- insert:`) gets upgraded in
108
+ * place to a stable-id form (` - id: X` + ` name: X`), then a
109
+ * `- id: X / disabled: <bool>` row is appended. A child that already carries
110
+ * the stable id (` - id: X`) just gets the row appended (the upgrade must
111
+ * not regress to a random runtime id). The insert block always stays before
112
+ * the appended row, so applyEntryPatches registers the id before the toggle
113
+ * row reads it. Returns false when nothing matches (or `name` is empty) —
114
+ * callers must refuse the write, never append blindly.
115
+ */
116
+ function patchInsertChildByName(lines, name, disabled) {
117
+ if (name === '')
118
+ return false;
119
+ const idPattern = new RegExp(`^ {4}- id: ${escapeRegExp(name)}$`, 'u');
120
+ const namePattern = new RegExp(`^ {4}- name: ${escapeRegExp(name)}$`, 'u');
121
+ let found = false;
122
+ for (let i = 0; i < lines.length; i++) {
123
+ if (namePattern.test(lines[i])) {
124
+ // 无 id 子条目 → 原地升级为稳定 id(4 空格 + 6 空格 name)。
125
+ lines[i] = ` - id: ${name}\n name: ${name}`;
126
+ found = true;
127
+ break;
128
+ }
129
+ if (idPattern.test(lines[i])) {
130
+ // 已是稳定 id 子条目 → 无需升级,直接追加禁用行。
131
+ found = true;
132
+ break;
133
+ }
134
+ }
135
+ if (!found)
136
+ return false;
137
+ const tail = lines.length > 0 && lines[lines.length - 1] !== '' ? '\n' : '';
138
+ lines.push(`${tail}- id: ${name}\n disabled: ${String(disabled)}`);
139
+ return true;
140
+ }
92
141
  /** Serialize toggles so concurrent writes cannot interleave. */
93
142
  let toggleChain = Promise.resolve();
94
143
  /**
@@ -97,10 +146,13 @@ let toggleChain = Promise.resolve();
97
146
  * entry list) is reported instead of being made worse.
98
147
  * @param profileDir - the profile directory holding cordis.patch.yml.
99
148
  * @param id - the loader entry id to toggle.
149
+ * @param name - the entry's package name; used as the addressing key when
150
+ * `id` is a Loader-assigned random runtime id with no `- id:` row in the
151
+ * patch file (id-less insert children of `dsh plugin add` lists).
100
152
  * @param disabled - the target stance.
101
153
  * @returns the outcome; `nowDisabled` mirrors the stance or null when refused.
102
154
  */
103
- export function setDisabled(profileDir, entryId, disabled) {
155
+ export function setDisabled(profileDir, entryId, name, disabled) {
104
156
  const run = toggleChain.then(async () => {
105
157
  // Loader runtime ids (include:<id>) never match patch composition —
106
158
  // always address rows by their original patch id.
@@ -157,9 +209,22 @@ export function setDisabled(profileDir, entryId, disabled) {
157
209
  out.push(line);
158
210
  }
159
211
  if (!patched) {
160
- // Append a new row (the file ends with a newline when non-empty).
161
- const tail = out.length > 0 && out[out.length - 1] !== '' ? '\n' : '';
162
- out.push(`${tail}- id: ${id}\n disabled: ${String(disabled)}`);
212
+ // 2026-08-25 禁用失效根因:id-less insert 子条目每次启动拿随机运行时
213
+ // id,按它追加的禁用行重启后永远匹配不到(applyEntryPatches warn
214
+ // 静默跳过)。所以这里绝不静默追加:先按 name 寻址 insert 子条目行,
215
+ // 原地升级为稳定 id 再追加;都找不到 → 拒绝(不写文件)。
216
+ if (!patchInsertChildByName(out, name, disabled)) {
217
+ if (/^[0-9a-f]{8}$/u.test(id)) {
218
+ return {
219
+ ok: false,
220
+ detail: `entry "${id}" has no stable patch id (random runtime id); ` +
221
+ 'edit cordis.patch.yml to give its insert child an explicit id',
222
+ nowDisabled: null,
223
+ };
224
+ }
225
+ return { ok: false, detail: `no patch row or insert child matches "${id}"`, nowDisabled: null };
226
+ }
227
+ patched = true;
163
228
  }
164
229
  try {
165
230
  writeFileSync(patchPath, out.join('\n') + '\n');
package/dist/update.d.ts CHANGED
@@ -7,8 +7,24 @@ export interface UpdateDigest {
7
7
  compat: 'compatible' | 'incompatible' | 'unknown';
8
8
  compatRange: string | null;
9
9
  }
10
+ /** 服务面判定:目标客户端 bundle 是否深度依赖 Remote BFF(ctx.remote.*)。
11
+ * SSiD 内核(0.1.x)无 remote BFF 服务(走 /plugin-center RPC channel),
12
+ * 这类版本在 SSiD 上必然「pending waiting for service: remote.session」。
13
+ * 案例: dsh-sidebar-qa 0.4.1/0.4.2(2026-08-29 两次实崩)。 */
14
+ export declare function clientBundleUsesRemote(content: string): boolean;
15
+ /** 下载目标 tgz 并抽取 client bundle,判定 remote 服务依赖。
16
+ * 仅返回 boolean 不用 pnpm(直接 registry 下载 tgz + bsdtar 抽文件)。 */
17
+ export declare function targetClientUsesRemote(name: string, version: string): Promise<boolean>;
10
18
  /** Latest published version on the npm registry; null when unreachable/unpublished. */
11
19
  export declare function npmLatest(packageName: string): Promise<string | null>;
20
+ /** 测试用:清空 repository 缓存(生产无调用)。 */
21
+ export declare function clearNpmRepoCache(): void;
22
+ /** 读 npm 包根级 repository.url(带 24h 缓存;失败/缺失 null)。 */
23
+ export declare function npmRepository(packageName: string): Promise<string | null>;
24
+ /** 仓库 URL 归一化(去 scheme/git+ 前缀/尾 .git/尾斜杠/大小写)用于同源比较。 */
25
+ export declare function normalizeRepoUrl(url: string): string;
26
+ /** 同源判定:true=同一上游;false=同名异源;null=无法判定(任一侧缺 repo)。 */
27
+ export declare function isSameUpstream(localRepoUrl: string | null, packageName: string): Promise<boolean | null>;
12
28
  /** Commit-message changelog: the reliable source for repos without release notes. */
13
29
  export declare function fetchCommitChangelog(repoUrl: string | null, sinceIso: string): Promise<string[]>;
14
30
  /**
@@ -64,6 +80,15 @@ export declare function logPnpm(profileDir: string, args: readonly string[], res
64
80
  * CreateProcess 只找 pnpm.exe(.cmd/.ps1 必须经 shell)——2026-08-17 实测
65
81
  * spawn('pnpm', shell:false) 直接 ENOENT,更新永远假成功。 */
66
82
  export declare function pnpmCandidates(): string[];
83
+ /** Wrap a bundled pnpm CLI path into a runnable command line. SSID_PNPM
84
+ * (SSiD 捆绑 pnpm) points at `pnpm.cjs` — a node script. On Windows,
85
+ * spawning it directly through `shell: true` makes cmd hand the .cjs to
86
+ * its file association (ShellExecute): cmd returns exit 0 immediately and
87
+ * node never runs (2026-08-25 another machine: 142ms fake success, npm
88
+ * add 未生效; output redirection test produced a 0-byte file). Non-.cjs
89
+ * paths (e.g. pnpm.exe) are used as-is.
90
+ */
91
+ export declare function pnpmExecCommand(bundled: string): string;
67
92
  /** 归档 profile 的 node_modules 由 pnpm `<major>` 生成(SSiD 部署时把构建机
68
93
  * store 元数据改写成本机路径且保留 major 后缀——shell/main.mjs rewire)。
69
94
  * 若执行机全局 pnpm 是另一个 major(常见:机器装 pnpm 10,归档是 pnpm 11
@@ -113,3 +138,44 @@ export declare function hostFilesChanged(before: FileIdentity[], after: FileIden
113
138
  * `minimumReleaseAgeExclude` itself (2026-08-18, reproduced in-process).
114
139
  */
115
140
  export declare function updatePlugin(packageName: string, version: string, profileDir: string): Promise<PnpmResult>;
141
+ /** 插件来源判定:依据依赖声明形态 + profile 目录(复用前置设计 §4.2 算法)。
142
+ * vendor 定制是 SSiD 生态核心(open-sea-skin/genui/panels 均本地魔改),
143
+ * 机械更新会把 file: 覆盖回 npm —— 来源标记给 LLM 决策「保持 vendor」。 */
144
+ export type PluginSource = 'official' | 'npm' | 'vendor' | 'tarball' | 'local-file';
145
+ export declare function sourceOf(specifier: string, profileDir: string): PluginSource;
146
+ /** 读 profile dependencies 里该插件的声明形态(npm 纯净 / file: vendor / link: 等)。 */
147
+ export declare function dependencySpecifierOf(profileDir: string, name: string): string | null;
148
+ /** LLM 更新信息包:在 UpdateDigest 基础上补充来源/定制标记,驱动 Agent 决策。 */
149
+ export interface LlmUpdatePackage {
150
+ name: string;
151
+ /** 当前本地版本(实体 package.json)。 */
152
+ fromVersion: string;
153
+ /** npm latest(可 null=未发布/不可达,LLM 走 GitHub commit 路径)。 */
154
+ toVersion: string | null;
155
+ /** GitHub commit changelog(更新前后差异,截前 10 条)。 */
156
+ changelog: string[];
157
+ /** DSH 兼容性(peer 检查)。 */
158
+ compat: 'compatible' | 'incompatible' | 'unknown';
159
+ /** peer 声明的 DSH 版本范围。 */
160
+ compatRange: string | null;
161
+ /** 来源判定。 */
162
+ source: PluginSource;
163
+ /** 依赖声明形态(file:/github:等等),机械更新可能覆盖定制的线索。 */
164
+ specifier: string | null;
165
+ /** 是否本地定制(vendor/tarball/local-file)。 */
166
+ isVendorModified: boolean;
167
+ /** 插件所在 profile 目录(host 运行时锚点;LLM 只允许在此目录内操作)。
168
+ * 内部技术字段——不直接展示给用户,由 runtimeLabel 承担语义化表达。 */
169
+ profileDir: string;
170
+ /** 同名异源警告:npm 同名包与本地上游不是同一项目(repository 不一致)。
171
+ * 真机案例: dsh-session-manager(dream12347 定制)vs npm 0.4.1(独立项目)。 */
172
+ upstreamMismatch: boolean;
173
+ /** 用户可读的环境标签(小白视角):'SSID'(思灵应用内)/'DSH-WEB'。 */
174
+ runtimeLabel: string;
175
+ /** 已组装的 Agent prompt(host 单一来源,client 直接注入会话)。 */
176
+ prompt: string;
177
+ }
178
+ /** 采集一个插件用于 LLM 更新的完整信息包。 */
179
+ export declare function buildLlmPackage(name: string, localVersion: string, repoUrl: string | null, compatRange: string | null, localDshVersion: string, sinceIso: string, profileDir: string): Promise<LlmUpdatePackage>;
180
+ /** 组装发给 LLM 会话的 prompt(角色设定 + 信息包 + 规则引用)。 */
181
+ export declare function buildLlmPrompt(pkg: LlmUpdatePackage): string;