pi-web-ui 0.94.0 → 0.95.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -28,6 +28,10 @@ import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog
28
28
  import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
29
29
  import { normalizeIconSvg } from "./icon-svg.js";
30
30
  import { PluginDomConsent, declarationWantsDom } from "./plugin-dom.js";
31
+ import { validatePluginManifest, formatManifestIssue, isKnownPermission } from "./plugin-manifest-validate.js";
32
+ import { buildPluginApiCatalog } from "./plugin-api-catalog.js";
33
+ import { AGENT_TOOL_CATALOG } from "./tool-manager.js";
34
+ import { applyPostEdit, denialText, freezeParams, isBlockingDecision, normalizePostEdit, normalizePreDecision, withGuardTimeout, } from "./plugin-tool-guard.js";
31
35
  // 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
32
36
  import { normalizeWorkspaceRoots } from "./client-state.js";
33
37
  import { createProject } from "./plugin-project.js";
@@ -79,6 +83,81 @@ export function isPluginLogsRequest(p) {
79
83
  const o = p;
80
84
  return o[PLUGIN_LOGS_HOST_KEY] === "logs" && (o.op === "get" || o.op === "clear" || o.op === undefined);
81
85
  }
86
+ /**
87
+ * per-plugin 可逆副作用栈(effect 栈)。
88
+ *
89
+ * 宿主每个注册面(事件订阅 / AI 工具 / 命令 / HTTP 路由 / 反向代理 / fs.watch /
90
+ * 定时任务 / 后台任务 / UI 条目 / 设置回调…)在内部 `add(label, dispose)` 登记一条
91
+ * disposer,反激活时**逆序**回卷 —— 插件不必记得注销,也不会因为漏掉一处就留下
92
+ * 孤儿订阅、定时器、路由或事件处理器(热重载后事件双触发、定时器叠加、watcher
93
+ * 堆积都是这个漏法的症状)。
94
+ *
95
+ * 与「每个注册函数自己返回注销函数」(插件主动调用)是**同一件事的两面**:
96
+ * 返回给插件的注销函数 = `remove(item)`(只撤这一条、幂等),反激活 = `release()`。
97
+ * 插件自建的副作用(自己的 setInterval、EventEmitter 监听…)可经 `host.effect()`
98
+ * 挂进来,同样享受逆序回卷 + 泄漏归因。
99
+ */
100
+ export class PluginEffectStack {
101
+ pluginId;
102
+ diag;
103
+ items = [];
104
+ released = false;
105
+ constructor(pluginId,
106
+ /** 记录一条诊断(cleanup 抛错时调用;不抛错、不阻断回卷)。 */
107
+ diag) {
108
+ this.pluginId = pluginId;
109
+ this.diag = diag;
110
+ }
111
+ /** 登记一条副作用;返回「只撤这一条」的注销函数(幂等,重复调用无副作用)。 */
112
+ add(label, dispose) {
113
+ const item = { label, dispose };
114
+ this.items.push(item);
115
+ return () => this.remove(item);
116
+ }
117
+ /** 当前未回卷的副作用数(单测断言「activate → deactivate 后栈空」)。 */
118
+ get size() {
119
+ return this.items.length;
120
+ }
121
+ /** 未回卷的副作用标签(按登记顺序,排障用)。 */
122
+ labels() {
123
+ return this.items.map((i) => i.label);
124
+ }
125
+ /** 撤销单条:先从栈里摘掉,再跑它的 dispose(幂等 —— 摘不到说明已撤过)。 */
126
+ remove(item) {
127
+ const i = this.items.indexOf(item);
128
+ if (i < 0)
129
+ return;
130
+ this.items.splice(i, 1);
131
+ try {
132
+ item.dispose();
133
+ }
134
+ catch (err) {
135
+ this.report(item.label, err);
136
+ }
137
+ }
138
+ /** 逆序回卷全部副作用;返回**失败**(dispose 抛错)的标签列表,绝不抛出。 */
139
+ release() {
140
+ if (this.released)
141
+ return [];
142
+ this.released = true;
143
+ const failed = [];
144
+ while (this.items.length > 0) {
145
+ const item = this.items.pop();
146
+ try {
147
+ item.dispose();
148
+ }
149
+ catch (err) {
150
+ failed.push(item.label);
151
+ this.report(item.label, err);
152
+ }
153
+ }
154
+ return failed;
155
+ }
156
+ report(label, err) {
157
+ console.warn(`[plugin:${this.pluginId}] effect "${label}" cleanup failed:`, err);
158
+ this.diag?.(`effect "${label}" cleanup failed: ${err?.message ?? String(err)}`);
159
+ }
160
+ }
82
161
  /** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
83
162
  * 插件能拿到明确的「请升级 pi-web-ui」而不是在新接口上莫名 undefined。
84
163
  * 1 = 初始:storage/secrets/命令/HTTP 路由/工具注册/受限 fs/后台任务。
@@ -173,7 +252,7 @@ const SETTING_OPTIONS_FROM = new Set(["models", "thinkingLevels"]);
173
252
  * 不认识的 slot / kind / when 直接丢弃(旧宿主读到新字段也不会崩,新宿主读到旧字段同理)。
174
253
  * 归属由宿主决定:全局 id = `<pluginId>:<itemId>`。
175
254
  */
176
- const UI_SLOTS = new Set([
255
+ export const UI_SLOTS = new Set([
177
256
  "topbar.primary",
178
257
  "topbar.overflow",
179
258
  "bottombar",
@@ -198,7 +277,7 @@ const UI_SLOTS = new Set([
198
277
  "modal.dialog",
199
278
  ]);
200
279
  /** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
201
- const UI_SLOT_ALIASES = {
280
+ export const UI_SLOT_ALIASES = {
202
281
  topbar: "topbar.primary",
203
282
  "topbar.more": "topbar.overflow",
204
283
  composer: "composer.actions",
@@ -208,7 +287,7 @@ const UI_SLOT_ALIASES = {
208
287
  modal: "modal.dialog",
209
288
  };
210
289
  /** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
211
- const UI_KINDS = new Set([
290
+ export const UI_KINDS = new Set([
212
291
  "view",
213
292
  "action",
214
293
  "badge",
@@ -554,6 +633,69 @@ export function parseUiArrange(raw, diagnostics) {
554
633
  }
555
634
  return out;
556
635
  }
636
+ /** 解析 manifest.requires → 归一化硬依赖声明(P2-8,坏形状宽容忽略:
637
+ * 形状错在 P1-6 校验层已拒,这里只管把合法部分抽出来供展示 + activate 判定)。 */
638
+ export function parseRequires(raw) {
639
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
640
+ return undefined;
641
+ const o = raw;
642
+ const out = {};
643
+ if (typeof o.hostApi === "number" && Number.isInteger(o.hostApi) && o.hostApi >= 1)
644
+ out.hostApi = o.hostApi;
645
+ if (Array.isArray(o.families)) {
646
+ const fams = o.families
647
+ .filter((x) => typeof x === "string" && x.trim().length > 0)
648
+ .map((x) => x.trim())
649
+ .slice(0, 8);
650
+ if (fams.length)
651
+ out.families = fams;
652
+ }
653
+ if (Array.isArray(o.plugins)) {
654
+ const deps = o.plugins
655
+ .filter((x) => typeof x === "string" && ID_RE.test(x.trim()))
656
+ .map((x) => x.trim())
657
+ .slice(0, 16);
658
+ if (deps.length)
659
+ out.plugins = deps;
660
+ }
661
+ return out.hostApi !== undefined || out.families !== undefined || out.plugins !== undefined ? out : undefined;
662
+ }
663
+ /** 依赖拓扑排序(P2-8):按 requires.plugins 把被依赖者排前面;同层保持扫描顺序。
664
+ * 返回 { order }(可激活顺序)+ { cyclic }(环/挂在环上的,调用方直接拒)。
665
+ * 边只连「本次扫描里存在」的对等端;缺失的对端由 activate 判定点名拒绝。 */
666
+ export function sortByRequires(ids, depsOf) {
667
+ const indeg = new Map();
668
+ const edges = new Map();
669
+ const idSet = new Set(ids);
670
+ for (const id of ids) {
671
+ indeg.set(id, 0);
672
+ edges.set(id, []);
673
+ }
674
+ for (const id of ids) {
675
+ const seen = new Set();
676
+ for (const dep of depsOf(id)) {
677
+ if (!idSet.has(dep) || dep === id || seen.has(dep))
678
+ continue;
679
+ seen.add(dep);
680
+ edges.get(dep).push(id);
681
+ indeg.set(id, (indeg.get(id) ?? 0) + 1);
682
+ }
683
+ }
684
+ const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0);
685
+ const order = [];
686
+ while (queue.length) {
687
+ const cur = queue.shift();
688
+ order.push(cur);
689
+ for (const next of edges.get(cur) ?? []) {
690
+ const d = (indeg.get(next) ?? 1) - 1;
691
+ indeg.set(next, d);
692
+ if (d === 0)
693
+ queue.push(next);
694
+ }
695
+ }
696
+ const ordered = new Set(order);
697
+ return { order, cyclic: ids.filter((id) => !ordered.has(id)) };
698
+ }
557
699
  /** 合并 manifest 基线与运行时注册:运行时同 id 覆盖,removed 里的删除;arrange 追加。 */
558
700
  function mergeUiPluginUi(base, rt) {
559
701
  const items = new Map();
@@ -607,53 +749,161 @@ function parseSettingsSchema(raw) {
607
749
  function secretSettingKey(key) {
608
750
  return `setting:${key}`;
609
751
  }
610
- /** 从 <pluginDir>/storage.json 读 settings 存值,按 schema 并默认值。
611
- * secret 字段不返回明文:有 secrets 时返回有无(布尔),调用方是浏览器;
612
- * 插件运行时要真值请用 runtimeSettingsValues。 */
613
- function storedSettingsValues(dir, schema, secrets) {
614
- const out = {};
615
- let stored = {};
752
+ /** 用户 overlay 原文件(P2-9):`<dataDir>/plugin-overrides/<id>.json` 的 settings 节。
753
+ * 缺文件 = 没写(不警告);坏形状 → 警告并整体忽略(用户自己的文件,不连坐插件)。
754
+ * 顶层未知键忽略(前向兼容);每键校验在 cleanOverlaySettings 里按 schema 做。 */
755
+ function readSettingsOverlayFile(dataDir, pluginId) {
756
+ const empty = { raw: {}, warnings: [] };
757
+ if (!ID_RE.test(pluginId))
758
+ return empty;
759
+ let parsed;
616
760
  try {
617
- const parsed = JSON.parse(readFileSync(join(dir, "storage.json"), "utf8"));
618
- if (parsed && typeof parsed === "object" && parsed.settings && typeof parsed.settings === "object") {
619
- stored = parsed.settings;
620
- }
761
+ parsed = JSON.parse(readFileSync(join(dataDir, "plugin-overrides", `${pluginId}.json`), "utf8"));
621
762
  }
622
763
  catch {
623
- /* 无存储文件 = 全默认 */
764
+ return empty;
765
+ }
766
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
767
+ return { raw: {}, warnings: [`settings override ignored: ${pluginId}.json must be an object`] };
768
+ }
769
+ const o = parsed;
770
+ if (o.settings === undefined)
771
+ return empty;
772
+ if (!o.settings || typeof o.settings !== "object" || Array.isArray(o.settings)) {
773
+ return { raw: {}, warnings: [`settings override ignored: ${pluginId}.json settings must be an object`] };
624
774
  }
775
+ return { raw: o.settings, warnings: [] };
776
+ }
777
+ /** 按 schema 清洗 overlay 值(P2-9):secret 一律拒绝(明文永不进加密 store)+
778
+ * 诊断;类型不对/越界/非法候选 → 警告并丢该键;schema 外的键 → 警告并忽略(拼写检查)。 */
779
+ function cleanOverlaySettings(schema, raw, lang) {
780
+ const values = {};
781
+ const warnings = [];
782
+ const byKey = new Map(schema.map((f) => [f.key, f]));
783
+ for (const [k, v] of Object.entries(raw)) {
784
+ const f = byKey.get(k);
785
+ if (!f) {
786
+ warnings.push(`settings override ignored: unknown key "${k.slice(0, 64)}"`);
787
+ continue;
788
+ }
789
+ if (f.type === "secret") {
790
+ warnings.push(`settings override ignored: secret "${k.slice(0, 64)}" never comes from overlay`);
791
+ continue;
792
+ }
793
+ const r = cleanNonSecretSettingsField(f, v, lang);
794
+ if (r.error) {
795
+ warnings.push(`settings override ignored: "${k.slice(0, 64)}": ${r.error}`);
796
+ continue;
797
+ }
798
+ values[k] = r.value;
799
+ }
800
+ return { values, warnings };
801
+ }
802
+ /** 三层合并(P2-9):schema 默认 < overlay(用户钉住的新默认,不 fork)< storage.json
803
+ * (面板保存,最高)。stored 不清洗(保存路径已洗过,可信);overlay 已预洗。
804
+ * secret 走加密 store,overlay 永不参与。 */
805
+ function resolveSettingsValues(schema, stored, overlay, secrets) {
806
+ const values = {};
807
+ const sources = {};
625
808
  for (const f of schema) {
626
809
  if (f.type === "secret") {
627
- // 浏览器侧只看到有无(布尔),明文永不下发;无 secrets 上下文(如单测)回落 false。
628
- out[f.key] = secrets ? secrets.has(secretSettingKey(f.key)) : false;
810
+ if (secrets?.get) {
811
+ const got = secrets.get(secretSettingKey(f.key));
812
+ values[f.key] = got ?? f.default ?? "";
813
+ sources[f.key] = got !== undefined ? "stored" : "default";
814
+ }
815
+ else if (secrets?.has) {
816
+ const has = secrets.has(secretSettingKey(f.key));
817
+ values[f.key] = has;
818
+ sources[f.key] = has ? "stored" : "default";
819
+ }
820
+ else {
821
+ values[f.key] = false;
822
+ sources[f.key] = "default";
823
+ }
629
824
  continue;
630
825
  }
631
- out[f.key] = stored[f.key] ?? f.default;
826
+ const s = stored[f.key];
827
+ if (s !== undefined && s !== null) {
828
+ values[f.key] = s;
829
+ sources[f.key] = "stored";
830
+ }
831
+ else if (f.key in overlay) {
832
+ values[f.key] = overlay[f.key];
833
+ sources[f.key] = "override";
834
+ }
835
+ else {
836
+ values[f.key] = f.default;
837
+ sources[f.key] = "default";
838
+ }
632
839
  }
633
- return out;
840
+ return { values, sources };
634
841
  }
635
- /** 插件运行时视角的设置值:非 secret 与 storedSettingsValues 同口径;secret 返回真值
636
- * (无则回落默认值/空串)。只给插件服务端代码用,绝不下发浏览器。 */
637
- function runtimeSettingsValues(dir, schema, secrets) {
638
- const out = {};
639
- let stored = {};
842
+ /** 从 <pluginDir>/storage.json 读 settings 存值,按 schema 并默认值。
843
+ * secret 字段不返回明文:有 secrets 时返回有无(布尔),调用方是浏览器;
844
+ * 插件运行时要真值请用 runtimeSettingsValues。 */
845
+ function readStoredSettings(dir) {
640
846
  try {
641
847
  const parsed = JSON.parse(readFileSync(join(dir, "storage.json"), "utf8"));
642
848
  if (parsed && typeof parsed === "object" && parsed.settings && typeof parsed.settings === "object") {
643
- stored = parsed.settings;
849
+ return parsed.settings;
644
850
  }
645
851
  }
646
852
  catch {
647
853
  /* 无存储文件 = 全默认 */
648
854
  }
649
- for (const f of schema) {
650
- if (f.type === "secret") {
651
- out[f.key] = secrets.get(secretSettingKey(f.key)) ?? f.default ?? "";
652
- continue;
855
+ return {};
856
+ }
857
+ function storedSettingsValues(dir, schema, secrets, overlay = {}) {
858
+ // 浏览器侧 secret 只看到有无(布尔),明文永不下发;无 secrets 上下文(如单测)回落 false。
859
+ return resolveSettingsValues(schema, readStoredSettings(dir), overlay, secrets ? { has: (k) => secrets.has(k) } : undefined).values;
860
+ }
861
+ /** 插件运行时视角的设置值:非 secret 与 storedSettingsValues 同口径;secret 返回真值
862
+ * (无则回落默认值/空串)。只给插件服务端代码用,绝不下发浏览器。 */
863
+ function runtimeSettingsValues(dir, schema, secrets, overlay = {}) {
864
+ return resolveSettingsValues(schema, readStoredSettings(dir), overlay, { get: (k) => secrets.get(k) }).values;
865
+ }
866
+ /** 清洗单个非 secret 设置值(P2-9 抽出:保存路径与 overlay 复用同一套口径)。
867
+ * v === undefined → 回落 schema 默认;类型不对/越界/非法候选 → error(调用方丢弃该键)。
868
+ * secret 字段不在这里处理(overlay 直接拒绝,明文永不进加密 store)。 */
869
+ function cleanNonSecretSettingsField(f, v, lang) {
870
+ if (f.type === "number") {
871
+ const n = v === undefined ? Number(f.default ?? 0) : Number(v);
872
+ if (!Number.isFinite(n) || (f.min !== undefined && n < f.min) || (f.max !== undefined && n > f.max)) {
873
+ return {
874
+ error: pick(lang, `${f.label} 超出范围`, `${f.label} out of range`, "plugins.settings.out.of.range", {
875
+ "f.label": f.label,
876
+ }),
877
+ };
653
878
  }
654
- out[f.key] = stored[f.key] ?? f.default;
879
+ return { value: n };
655
880
  }
656
- return out;
881
+ if (f.type === "boolean") {
882
+ return { value: v === undefined ? Boolean(f.default) : Boolean(v) };
883
+ }
884
+ if (f.type === "select") {
885
+ const s = v === undefined ? "" : String(v);
886
+ // optionsFrom(宿主数据源):候选值在浏览器侧现算,服务端无从校验,
887
+ // 只做个长度护栏;非法值由用的时候(如 host.chat 切模型)报错。
888
+ if (f.optionsFrom) {
889
+ if (s.length > 200) {
890
+ return {
891
+ error: pick(lang, `${f.label} 过长`, `${f.label} too long`, "plugins.settings.too.long", {
892
+ "f.label": f.label,
893
+ }),
894
+ };
895
+ }
896
+ return { value: v === undefined ? (f.default ?? "") : s };
897
+ }
898
+ if (v !== undefined && !f.options?.includes(s))
899
+ return {
900
+ error: pick(lang, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
901
+ "f.label": f.label,
902
+ }),
903
+ };
904
+ return { value: v === undefined ? f.default : s };
905
+ }
906
+ return { value: v === undefined ? (f.default ?? "") : String(v) };
657
907
  }
658
908
  /** 校验并写回 settings(storage.json 的 settings 键,原子写);返回错误信息或 null。
659
909
  * secret 字段写加密 secrets(明文永不落盘、不进 storage.json):空串/缺省 = 不改;
@@ -665,45 +915,11 @@ lang, secrets) {
665
915
  const clean = {};
666
916
  for (const f of schema) {
667
917
  const v = values?.[f.key];
668
- if (f.type === "number") {
669
- const n = v === undefined ? Number(f.default ?? 0) : Number(v);
670
- if (!Number.isFinite(n) || (f.min !== undefined && n < f.min) || (f.max !== undefined && n > f.max)) {
671
- return {
672
- error: pick(l, `${f.label} 超出范围`, `${f.label} out of range`, "plugins.settings.out.of.range", {
673
- "f.label": f.label,
674
- }),
675
- clean,
676
- };
677
- }
678
- clean[f.key] = n;
679
- }
680
- else if (f.type === "boolean") {
681
- clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
682
- }
683
- else if (f.type === "select") {
684
- const s = v === undefined ? "" : String(v);
685
- // optionsFrom(宿主数据源):候选值在浏览器侧现算,服务端无从校验,
686
- // 只做个长度护栏;非法值由用的时候(如 host.chat 切模型)报错。
687
- if (f.optionsFrom) {
688
- if (s.length > 200) {
689
- return {
690
- error: pick(l, `${f.label} 过长`, `${f.label} too long`, "plugins.settings.too.long", {
691
- "f.label": f.label,
692
- }),
693
- clean,
694
- };
695
- }
696
- clean[f.key] = v === undefined ? (f.default ?? "") : s;
697
- continue;
698
- }
699
- if (v !== undefined && !f.options?.includes(s))
700
- return {
701
- error: pick(l, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
702
- "f.label": f.label,
703
- }),
704
- clean,
705
- };
706
- clean[f.key] = v === undefined ? f.default : s;
918
+ if (f.type !== "secret") {
919
+ const r = cleanNonSecretSettingsField(f, v, l);
920
+ if (r.error)
921
+ return { error: r.error, clean };
922
+ clean[f.key] = r.value;
707
923
  }
708
924
  else if (f.type === "secret") {
709
925
  // 空串/缺省 = 不改(浏览器侧回显的本来就是有无布尔,前端把“没碰”发成空串)。
@@ -1229,6 +1445,81 @@ export class PluginManager {
1229
1445
  }
1230
1446
  }
1231
1447
  }
1448
+ /** agent-service 调(P1-5):bash/read 执行前的守卫求值。首个 deny/ask 胜出;
1449
+ * 守卫抛错/超时一律按弃权(不阻断)。无守卫时回 allow,调用方可直接放行。 */
1450
+ async evaluateToolPre(req, lang = "en") {
1451
+ const frozen = {
1452
+ toolName: req.toolName,
1453
+ params: freezeParams(req.params),
1454
+ conversationId: req.conversationId,
1455
+ };
1456
+ for (const p of this.loaded.values()) {
1457
+ for (const h of p.preGuards ?? []) {
1458
+ let raw;
1459
+ try {
1460
+ raw = await withGuardTimeout(Promise.resolve().then(() => h(frozen)));
1461
+ }
1462
+ catch (err) {
1463
+ console.error(`[plugin:${p.info.id}] tool-pre guard failed (abstained):`, err);
1464
+ this.pushRuntimeDiag(p.info.id, `tool-pre guard threw, abstained (${req.toolName})`);
1465
+ continue;
1466
+ }
1467
+ if (raw === undefined)
1468
+ continue;
1469
+ const d = normalizePreDecision(raw);
1470
+ if (!isBlockingDecision(d))
1471
+ continue;
1472
+ this.pushRuntimeDiag(p.info.id, `tool-pre ${d.decision} ${req.toolName} (by ${p.info.id})`);
1473
+ return { verdict: d, pluginId: p.info.id };
1474
+ }
1475
+ }
1476
+ return { verdict: { decision: "allow" } };
1477
+ }
1478
+ /** agent-service 调(P1-5):bash/read 执行后的守卫合并。逐个顺序合进结果;
1479
+ * 抛错/超时的那个被跳过(其余继续)。返回合并后的 content(无编辑回 undefined)。 */
1480
+ async evaluateToolPost(req, lang = "en") {
1481
+ let content = Array.isArray(req.result?.content)
1482
+ ? [...req.result.content]
1483
+ : undefined;
1484
+ const base = { ...req.result, ...(content ? { content: [...content] } : {}) };
1485
+ let touched = false;
1486
+ const pluginIds = [];
1487
+ const frozenReq = {
1488
+ toolName: req.toolName,
1489
+ params: freezeParams(req.params),
1490
+ result: req.result,
1491
+ conversationId: req.conversationId,
1492
+ };
1493
+ for (const p of this.loaded.values()) {
1494
+ for (const h of p.postGuards ?? []) {
1495
+ let raw;
1496
+ try {
1497
+ raw = await withGuardTimeout(Promise.resolve().then(() => h(frozenReq)));
1498
+ }
1499
+ catch (err) {
1500
+ console.error(`[plugin:${p.info.id}] tool-post guard failed (skipped):`, err);
1501
+ this.pushRuntimeDiag(p.info.id, `tool-post guard threw, skipped (${req.toolName})`);
1502
+ continue;
1503
+ }
1504
+ const edit = normalizePostEdit(raw);
1505
+ if (!edit)
1506
+ continue;
1507
+ const merged = applyPostEdit(base, edit, lang);
1508
+ base.content = merged.content;
1509
+ Object.assign(base, merged);
1510
+ touched = true;
1511
+ pluginIds.push(p.info.id);
1512
+ this.pushRuntimeDiag(p.info.id, `tool-post edited ${req.toolName} (by ${p.info.id})`);
1513
+ }
1514
+ }
1515
+ if (!touched)
1516
+ return undefined;
1517
+ return { content: base.content, pluginIds };
1518
+ }
1519
+ /** 阻断时给模型看的一句话(pre 守卫命中后由 agent-service 取用)。 */
1520
+ guardDenialText(verdict, pluginId, lang = "en") {
1521
+ return denialText(verdict, pluginId, lang);
1522
+ }
1232
1523
  /** index.ts 注入:读取当前打开对话的快照(轨迹类插件经 host.getActiveConversation 调用)。 */
1233
1524
  conversationProvider = undefined;
1234
1525
  /** index.ts 注入:插件无头调用 agent(微信通道等经 host.chat 调用)。 */
@@ -1618,13 +1909,61 @@ export class PluginManager {
1618
1909
  async list(lang) {
1619
1910
  return this.scan(lang);
1620
1911
  }
1912
+ /** 机器可读的注册面目录(P2-7):slot 别名/kind/例子 + 工具目录 +
1913
+ * 宿主方法表 + 当前占用者(现算)。只读装配,无副作用。 */
1914
+ getApiCatalog() {
1915
+ const counts = new Map();
1916
+ const add = (pluginId, slot) => {
1917
+ let m = counts.get(slot);
1918
+ if (!m)
1919
+ counts.set(slot, (m = new Map()));
1920
+ m.set(pluginId, (m.get(pluginId) ?? 0) + 1);
1921
+ };
1922
+ for (const [pid, base] of this.uiBase)
1923
+ for (const it of base.items ?? [])
1924
+ add(pid, it.slot);
1925
+ for (const [pid, rt] of this.uiRuntime)
1926
+ for (const it of rt.items.values())
1927
+ add(pid, it.slot);
1928
+ return buildPluginApiCatalog({
1929
+ slots: [...UI_SLOTS],
1930
+ aliases: { ...UI_SLOT_ALIASES },
1931
+ kinds: [...UI_KINDS],
1932
+ agentTools: AGENT_TOOL_CATALOG.map((t) => ({
1933
+ name: t.name,
1934
+ group: t.group,
1935
+ defaultOn: t.defaultOn,
1936
+ dshVisible: t.dshVisible,
1937
+ })),
1938
+ occupantsOf: (slot) => {
1939
+ const m = counts.get(slot);
1940
+ if (!m)
1941
+ return [];
1942
+ return [...m].map(([pluginId, items]) => ({ pluginId, items }));
1943
+ },
1944
+ });
1945
+ }
1621
1946
  /**
1622
1947
  * attach 时调用:重扫目录 + 激活尚未加载的新插件。
1623
1948
  * 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
1624
1949
  */
1625
1950
  async ensureLoaded(lang) {
1951
+ const l = lang?.() ?? "en";
1626
1952
  const found = await this.scan(lang);
1627
- for (const info of found) {
1953
+ const byId = new Map(found.map((f) => [f.id, f]));
1954
+ const scannedIds = new Set(byId.keys());
1955
+ // P2-8:依赖拓扑排序激活(被依赖者先行;决定启动时机的是依赖,不是目录顺序)。
1956
+ // 边只连本次扫描里存在的对端;缺失的对端由 activate 点名拒绝。环上节点直接拒。
1957
+ const { order, cyclic } = sortByRequires(found.map((f) => f.id), (id) => (byId.get(id)?.requires?.plugins ?? []).filter((d) => scannedIds.has(d)));
1958
+ for (const cycId of cyclic) {
1959
+ const info = byId.get(cycId);
1960
+ if (this.loaded.has(info.id) || this.attempted.has(info.id))
1961
+ continue;
1962
+ const msg = pick(l, `插件 requires 存在循环依赖(${cycId})—— 请先解环再激活`, `Plugin has cyclic requires (${cycId}) — break the cycle first`, "plugins.requires.cycle", { id: cycId });
1963
+ this.setFrozenRefusal(info, msg, `cyclic requires (${cycId})`);
1964
+ }
1965
+ for (const id of order) {
1966
+ const info = byId.get(id);
1628
1967
  if (this.loaded.has(info.id) || this.attempted.has(info.id))
1629
1968
  continue;
1630
1969
  if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
@@ -1638,6 +1977,8 @@ export class PluginManager {
1638
1977
  this.deactivateEntry(id, p);
1639
1978
  }
1640
1979
  }
1980
+ // P2-8 级联:提供方刚坏(被删/失败/被拒)→ 仍在跑的消费方一并反激活 + 教学占位。
1981
+ this.cascadeRequiresRefusals(found, lang);
1641
1982
  return found.map((f) => {
1642
1983
  const base = this.loaded.get(f.id)?.info ?? f;
1643
1984
  // 运行相位(设置面板清单用):宿主持有实例即 active(含激活失败的占位行;
@@ -1650,32 +1991,23 @@ export class PluginManager {
1650
1991
  return rest;
1651
1992
  });
1652
1993
  }
1653
- /** 反激活清理:把该插件名下全部订阅/注册一次收完(工具/命令/watch/定时/
1654
- * 总线/stats/流式——参考 agentToolUnsubscribers 模式,新增订阅一律走这里)。 */
1994
+ /** 反激活清理:把该插件名下全部订阅/注册一次收完。
1995
+ *
1996
+ * 两段:① 副作用栈逆序回卷(插件注册的全部可逆副作用都在里面 —— 工具/命令/路由/
1997
+ * 代理/watch/定时/后台任务/事件订阅/UI 条目…;跑不干净的记一条诊断);
1998
+ * ② 兜底回收全局表里按 pluginId 索引的残留(老宿主激活的实例没有栈、或
1999
+ * 插件绕过 host 自己往表里塞过东西:代理前缀按 id 过滤清一遍,幂等)。 */
1655
2000
  releaseEntry(p) {
1656
- // 该插件注册的代理前缀随反激活一起回收(全局表按 pluginId 过滤;
2001
+ // ① 逆序回卷。失败项已在栈内打过诊断,这里不再重复。
2002
+ const failed = p.effects?.release() ?? [];
2003
+ if (failed.length > 0)
2004
+ console.warn(`[plugin:${p.info.id}] ${failed.length} effect(s) failed to clean up: ${failed.join(", ")}`);
2005
+ // ② 兜底:代理前缀随反激活一起回收(全局表按 pluginId 过滤;
1657
2006
  // Map 迭代中删除是良定义的:删过的条目不会再被访问到)。
1658
2007
  for (const [prefix, hit] of this.proxyRoutes) {
1659
2008
  if (hit.pluginId === p.info.id)
1660
2009
  this.proxyRoutes.delete(prefix);
1661
2010
  }
1662
- // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
1663
- for (const off of [
1664
- ...(p.agentToolUnsubscribers ?? []),
1665
- ...(p.commandUnsubscribers ?? []),
1666
- ...(p.watchUnsubscribers ?? []),
1667
- ...(p.scheduleUnsubscribers ?? []),
1668
- ...(p.busUnsubscribers ?? []),
1669
- ...(p.statsUnsubscribers ?? []),
1670
- ...(p.streamingUnsubscribers ?? []),
1671
- ]) {
1672
- try {
1673
- off();
1674
- }
1675
- catch {
1676
- /* already gone */
1677
- }
1678
- }
1679
2011
  }
1680
2012
  /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。
1681
2013
  *
@@ -1709,17 +2041,26 @@ export class PluginManager {
1709
2041
  this.epochCounter += 1;
1710
2042
  console.log(`[plugin:${id}] removed`);
1711
2043
  }
1712
- /** 关机时反激活全部插件。 */
2044
+ /** 关机 / reload 时反激活全部插件(dispose 后 loaded 为空,后续 releaseEntry 是 no-op)。 */
1713
2045
  dispose() {
2046
+ const pluginIds = new Set();
1714
2047
  for (const [id, p] of this.loaded) {
2048
+ pluginIds.add(id);
1715
2049
  try {
1716
2050
  p.deactivate?.();
1717
2051
  }
1718
2052
  catch (err) {
1719
2053
  console.error(`[plugin:${id}] deactivate failed:`, err);
1720
2054
  }
1721
- this.releaseEntry(p);
1722
- // 反激活时停掉它注册的常驻后台任务(轮询器等),不留孤儿计时器。
2055
+ }
2056
+ // ① 先回卷全部 effect 栈(含 bgTask 停表、工具/命令/路由/watch/UI 条目注销),
2057
+ // 并把 loaded 清掉 —— 之后逐个走 releaseEntry 的兜底回收。
2058
+ for (const [id, p] of this.loaded) {
2059
+ p.effects?.release();
2060
+ this.loaded.delete(id);
2061
+ }
2062
+ // ② 兜底:停掉任何没经 effect 栈登记的常驻后台任务,不留孤儿计时器。
2063
+ for (const id of pluginIds) {
1723
2064
  for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
1724
2065
  try {
1725
2066
  t.stop?.();
@@ -1729,6 +2070,11 @@ export class PluginManager {
1729
2070
  }
1730
2071
  }
1731
2072
  }
2073
+ // ③ 兜底:清掉插件名下的代理前缀(该插件名下但未经栈登记的残留)。
2074
+ for (const prefix of [...this.proxyRoutes.keys()]) {
2075
+ if (pluginIds.has(this.proxyRoutes.get(prefix).pluginId))
2076
+ this.proxyRoutes.delete(prefix);
2077
+ }
1732
2078
  this.pluginBgTasks.clear();
1733
2079
  this.loaded.clear();
1734
2080
  this.messageHandlers.clear();
@@ -1762,6 +2108,43 @@ export class PluginManager {
1762
2108
  this.domWants.set(name, wantsDom);
1763
2109
  // 本轮 manifest 解析的诊断(重算覆盖;运行时诊断另存在 runtimeDiags)。
1764
2110
  const uiDiags = [];
2111
+ // P1-6:manifest 本体先校验,失败即拒(坏配置不进运行时)。
2112
+ const mv = validatePluginManifest(m, name);
2113
+ for (const w of mv.warnings)
2114
+ uiDiags.push(formatManifestIssue(w));
2115
+ if (mv.errors.length > 0) {
2116
+ for (const e of mv.errors)
2117
+ uiDiags.push(formatManifestIssue(e));
2118
+ const first = mv.errors[0];
2119
+ const msg = pick(l, `manifest 校验失败:${first.message}(${name}/)—— 修复后点“重新扫描”`, `manifest validation failed: ${first.messageEn} (${name}/) — fix it, then hit Rescan`, "plugins.manifest.invalid", { name, path: first.path });
2120
+ console.error(`[plugin:${name}] ${msg}`);
2121
+ this.manifestDiags.set(name, uiDiags);
2122
+ const refused = [...uiDiags, ...(this.runtimeDiags.get(name) ?? [])].slice(0, 100);
2123
+ out.push({
2124
+ id: name,
2125
+ name: typeof m.name === "string" && m.name ? m.name : name,
2126
+ hasClient: existsSync(join(dir, "client", "entry.mjs")),
2127
+ error: msg,
2128
+ view: false,
2129
+ ...(refused.length ? { diagnostics: refused } : {}),
2130
+ });
2131
+ const lpRefused = this.loaded.get(name);
2132
+ if (lpRefused) {
2133
+ if (refused.length)
2134
+ lpRefused.info.diagnostics = [...refused];
2135
+ else
2136
+ delete lpRefused.info.diagnostics;
2137
+ }
2138
+ continue;
2139
+ }
2140
+ // P2-9:声明式设置的三层合并(schema 默认 < 用户 overlay < 面板保存值)。
2141
+ // overlay 先读先洗,警告进诊断(英文短句,随清单下发);secret 永不来自 overlay。
2142
+ const settingsSchemaForScan = parseSettingsSchema(m.settings);
2143
+ const overlayFileForScan = readSettingsOverlayFile(this.dataDir, name);
2144
+ const overlayForScan = cleanOverlaySettings(settingsSchemaForScan, overlayFileForScan.raw, "en");
2145
+ for (const w of [...overlayFileForScan.warnings, ...overlayForScan.warnings])
2146
+ uiDiags.push(`override: ${w}`);
2147
+ const resolvedSettingsForScan = resolveSettingsValues(settingsSchemaForScan, readStoredSettings(dir), overlayForScan.values, { has: (k) => new PluginSecrets(this.dataDir, dir).has(k) });
1765
2148
  out.push({
1766
2149
  id: name,
1767
2150
  name: typeof m.name === "string" && m.name ? m.name : name,
@@ -1792,6 +2175,9 @@ export class PluginManager {
1792
2175
  peerPlugins: Array.isArray(m.peerPlugins)
1793
2176
  ? m.peerPlugins.filter((x) => typeof x === "string" && ID_RE.test(x)).slice(0, 16)
1794
2177
  : undefined,
2178
+ // 硬依赖声明(manifest "requires",P2-8):形状坏的在 P1-6 校验层已拒,
2179
+ // 这里只做宽容解析供展示 + activate 语义判定(缺失/失败/环/版本/能力即拒)。
2180
+ requires: parseRequires(m.requires),
1795
2181
  // 特权 DOM:permissions 含 dom 族 → bundle 默认 403,需用户逐个授权。
1796
2182
  // wantsDom 缓进 domWants(静态门禁查它,不必每次 readdir);error 在未授权时
1797
2183
  // 直接写明原因(tab 置灰 + 设置行提示),授权后下次 scan 自动清除。
@@ -1807,8 +2193,9 @@ export class PluginManager {
1807
2193
  }
1808
2194
  : {}),
1809
2195
  // 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
1810
- settingsSchema: parseSettingsSchema(m.settings),
1811
- settingsValues: storedSettingsValues(dir, parseSettingsSchema(m.settings), new PluginSecrets(this.dataDir, dir)),
2196
+ settingsSchema: settingsSchemaForScan,
2197
+ settingsValues: resolvedSettingsForScan.values,
2198
+ settingsSources: resolvedSettingsForScan.sources,
1812
2199
  // 可渲染的 fenced-code 语言(manifest "renderers")——前端据此按需加载
1813
2200
  renderers: Array.isArray(m.renderers)
1814
2201
  ? m.renderers.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
@@ -1823,6 +2210,39 @@ export class PluginManager {
1823
2210
  composerProviders: Array.isArray(m.composerProviders)
1824
2211
  ? m.composerProviders.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
1825
2212
  : undefined,
2213
+ // 文件单击查看器声明:只下发扩展名/处理器 id/优先级,实际 DOM
2214
+ // 渲染仍由客户端 bundle 提供,避免 manifest 携带可执行内容。
2215
+ fileHandlers: (() => {
2216
+ if (!Array.isArray(m.fileHandlers))
2217
+ return undefined;
2218
+ const rows = m.fileHandlers
2219
+ .filter((x) => Boolean(x) && typeof x === "object" && !Array.isArray(x))
2220
+ .slice(0, 32)
2221
+ .map((x) => {
2222
+ const extensions = Array.isArray(x.extensions)
2223
+ ? x.extensions
2224
+ .filter((e) => typeof e === "string" && /^\.[A-Za-z0-9][A-Za-z0-9._-]*$/.test(e.trim()))
2225
+ .map((e) => e.trim().toLowerCase())
2226
+ .slice(0, 32)
2227
+ : [];
2228
+ if (!extensions.length)
2229
+ return null;
2230
+ const id = typeof x.id === "string" && /^[A-Za-z0-9_-]+$/.test(x.id) ? x.id : "default";
2231
+ return {
2232
+ id,
2233
+ extensions: [...new Set(extensions)],
2234
+ priority: Number.isFinite(Number(x.priority))
2235
+ ? Math.max(-1000, Math.min(1000, Number(x.priority)))
2236
+ : 0,
2237
+ ...(typeof x.label === "string" && x.label.trim() ? { label: x.label.trim().slice(0, 120) } : {}),
2238
+ ...(typeof x.labelEn === "string" && x.labelEn.trim()
2239
+ ? { labelEn: x.labelEn.trim().slice(0, 120) }
2240
+ : {}),
2241
+ };
2242
+ })
2243
+ .filter((x) => x !== null);
2244
+ return rows.length ? rows : undefined;
2245
+ })(),
1826
2246
  // 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
1827
2247
  view: typeof m.view === "boolean" ? m.view : true,
1828
2248
  // 客户端 bundle 是否常驻加载(manifest "preload",缺省 false):无视图
@@ -1910,22 +2330,120 @@ export class PluginManager {
1910
2330
  }
1911
2331
  return out;
1912
2332
  }
2333
+ /** 非 activate 上下文的拒绝占位(P2-8 环/级联):空集合 + error + 诊断,
2334
+ * 与各门控的占位行同形(ensureLoaded 照例跳过,reload 重算刷新)。 */
2335
+ setFrozenRefusal(info, msg, diagEn) {
2336
+ console.error(`[plugin:${info.id}] ${msg}`);
2337
+ this.pushRuntimeDiag(info.id, diagEn);
2338
+ this.loaded.set(info.id, {
2339
+ info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
2340
+ toolHandlers: new Set(),
2341
+ runHandlers: new Set(),
2342
+ convChangeHandlers: new Set(),
2343
+ attachHandlers: new Set(),
2344
+ cwdHandlers: new Set(),
2345
+ httpRoutes: new Map(),
2346
+ settingsHandlers: new Set(),
2347
+ });
2348
+ }
2349
+ /** 级联(P2-8):提供方刚坏掉(被删/失败/被拒)→ 仍在跑的消费方一并反激活 +
2350
+ * 留教学占位。循环直到一轮无变化(链式依赖一轮收敛),返回受影响的 id。
2351
+ * hostApi/families 运行时不变,这里只查 plugins 对端(目录在 + 后端 peer 激活成功)。 */
2352
+ cascadeRequiresRefusals(found, lang) {
2353
+ const l = lang?.() ?? "en";
2354
+ const byId = new Map(found.map((f) => [f.id, f]));
2355
+ const hit = [];
2356
+ for (;;) {
2357
+ let changed = false;
2358
+ for (const [id, p] of [...this.loaded]) {
2359
+ if (p.info.error)
2360
+ continue;
2361
+ const req = byId.get(id)?.requires ?? p.info.requires;
2362
+ if (!req?.plugins?.length)
2363
+ continue;
2364
+ for (const dep of req.plugins) {
2365
+ let why = "";
2366
+ let whyEn = "";
2367
+ if (dep === id) {
2368
+ why = `插件 requires 依赖了自己(${dep})—— 请删掉这条自依赖`;
2369
+ whyEn = `Plugin requires itself (${dep}) — remove the self-dependency`;
2370
+ }
2371
+ else if (!byId.has(dep)) {
2372
+ why = `插件 requires 对等插件「${dep}」但它已不在(被删/被拒)—— 装回来或去掉这条依赖`;
2373
+ whyEn = `Plugin requires peer "${dep}" which is gone (removed or refused) — reinstall it or drop the dependency`;
2374
+ }
2375
+ else if (existsSync(join(this.pluginsDir, dep, "index.mjs"))) {
2376
+ const peer = this.loaded.get(dep);
2377
+ if (!peer || peer.info.error) {
2378
+ why = `插件 requires 对等插件「${dep}」但它已失败/停止:${peer?.info.error ?? "未激活"}—— 修好它或去掉这条依赖`;
2379
+ whyEn = `Plugin requires peer "${dep}" which failed/stopped: ${peer?.info.error ?? "inactive"} — fix it or drop the dependency`;
2380
+ }
2381
+ }
2382
+ if (!why)
2383
+ continue;
2384
+ const msg = pick(l, why, whyEn, "plugins.requires.cascade", { dep });
2385
+ this.deactivateEntry(id, p);
2386
+ this.setFrozenRefusal(byId.get(id) ?? p.info, msg, whyEn);
2387
+ hit.push(id);
2388
+ changed = true;
2389
+ break;
2390
+ }
2391
+ }
2392
+ if (!changed)
2393
+ break;
2394
+ }
2395
+ return hit;
2396
+ }
1913
2397
  async activate(info, lang) {
1914
2398
  const l = lang?.() ?? "en";
1915
2399
  this.attempted.add(info.id);
1916
2400
  const dir = join(this.pluginsDir, info.id);
2401
+ // P1-6:manifest 本体失败即拒(scan 缓存可能过期,这里重读重判,半途注册的副作用不留)。
2402
+ try {
2403
+ const rawManifest = readFileSync(join(dir, "manifest.json"), "utf8");
2404
+ const parsedManifest = JSON.parse(rawManifest);
2405
+ const mvActivate = validatePluginManifest(parsedManifest, info.id);
2406
+ if (mvActivate.errors.length > 0) {
2407
+ const first = mvActivate.errors[0];
2408
+ const msg = pick(l, `manifest 校验失败:${first.message} —— 修复后点“重新扫描”`, `manifest validation failed: ${first.messageEn} — fix it, then hit Rescan`, "plugins.manifest.invalid", { name: info.id, path: first.path });
2409
+ console.error(`[plugin:${info.id}] ${msg}`);
2410
+ for (const e of mvActivate.errors)
2411
+ this.pushRuntimeDiag(info.id, formatManifestIssue(e));
2412
+ for (const w of mvActivate.warnings)
2413
+ this.pushRuntimeDiag(info.id, formatManifestIssue(w));
2414
+ this.loaded.set(info.id, {
2415
+ info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
2416
+ toolHandlers: new Set(),
2417
+ runHandlers: new Set(),
2418
+ convChangeHandlers: new Set(),
2419
+ attachHandlers: new Set(),
2420
+ cwdHandlers: new Set(),
2421
+ httpRoutes: new Map(),
2422
+ settingsHandlers: new Set(),
2423
+ });
2424
+ return;
2425
+ }
2426
+ }
2427
+ catch {
2428
+ // 读不到/解析失败 → 走原有的 broken 占位逻辑(scan 里已产出,不在这里重复拒绝)。
2429
+ }
1917
2430
  const handlers = new Set();
1918
2431
  this.messageHandlers.set(info.id, handlers);
1919
2432
  const toolHandlers = new Set();
2433
+ const preGuards = new Set();
2434
+ const postGuards = new Set();
1920
2435
  const runHandlers = new Set();
1921
2436
  const convChangeHandlers = new Set();
1922
2437
  const attachHandlers = new Set();
1923
2438
  const cwdHandlers = new Set();
1924
2439
  const httpRoutes = new Map();
1925
- const unregisterTools = [];
1926
- const unregisterCommands = [];
1927
2440
  const bgTaskTable = new Map();
1928
2441
  const settingsHandlers = new Set();
2442
+ // 可逆副作用栈:本插件的全部注册面(工具/命令/路由/代理/watch/定时/后台任务/
2443
+ // UI 条目/事件订阅…)都经 effects.add 登记,反激活时**逆序**回卷 —— 插件忘写
2444
+ // 注销也不会留下孤儿订阅/定时器/路由(热重载后事件双触发、定时器叠加、watcher
2445
+ // 堆积都是这个漏法的症状)。cleanup 抛错只记诊断,不阻断回卷。
2446
+ const effects = new PluginEffectStack(info.id, (m) => this.pushRuntimeDiag(info.id, m));
1929
2447
  // 宿主 API 版本协商:插件要的比宿主新 → 明确拒绝(而不是让它在运行期
1930
2448
  // 撞 undefined 接口莫名其妙地坏)。与激活失败同一处理:error 字段 + 置灰。
1931
2449
  let apiVersion = 1;
@@ -1956,6 +2474,76 @@ export class PluginManager {
1956
2474
  const permsDeclared = (info.permissions ?? []).slice();
1957
2475
  const strict = permsDeclared.length > 0 || apiVersion >= 2;
1958
2476
  const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
2477
+ // 硬依赖声明(manifest "requires",P2-8):任一条不满足即拒绝激活 + 教学式错误
2478
+ // (peerPlugins 仍是缺失只警告的软依赖)。形状坏的在 P1-6 校验层已提前拒,
2479
+ // 这里只做语义判定:hostApi 下限、能力族已知+已声明、对等插件已安装且激活成功。
2480
+ const req = info.requires;
2481
+ if (req) {
2482
+ const refuseRequires = (zh, en, key, vars) => {
2483
+ const msg = pick(l, zh, en, key, vars);
2484
+ console.error(`[plugin:${info.id}] ${msg}`);
2485
+ this.pushRuntimeDiag(info.id, en);
2486
+ this.loaded.set(info.id, {
2487
+ info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
2488
+ toolHandlers,
2489
+ runHandlers,
2490
+ convChangeHandlers,
2491
+ attachHandlers,
2492
+ cwdHandlers,
2493
+ httpRoutes,
2494
+ settingsHandlers: new Set(),
2495
+ });
2496
+ return true;
2497
+ };
2498
+ if (req.hostApi !== undefined && req.hostApi > PLUGIN_API_VERSION) {
2499
+ refuseRequires(`插件要求宿主 API v${req.hostApi}+,当前宿主 v${PLUGIN_API_VERSION} —— 请升级 pi-web-ui`, `Plugin requires host API v${req.hostApi}+ but the host is v${PLUGIN_API_VERSION} — please upgrade pi-web-ui`, "plugins.requires.hostapi", { hostApi: req.hostApi, PLUGIN_API_VERSION });
2500
+ return;
2501
+ }
2502
+ let refused = false;
2503
+ for (const f of req.families ?? []) {
2504
+ if (!isKnownPermission(f)) {
2505
+ refuseRequires(`插件 requires 声明了未知能力族「${f}」(拼写?或需要更新 pi-web-ui)—— 请修正 manifest.requires.families`, `Plugin requires unknown family "${f}" (typo? or needs newer pi-web-ui) — fix manifest.requires.families`, "plugins.requires.family.unknown", { family: f });
2506
+ refused = true;
2507
+ break;
2508
+ }
2509
+ const fam = f.split(":")[0];
2510
+ if (!permFamilies.has(fam)) {
2511
+ refuseRequires(`插件 requires 需要「${fam}」族,但 permissions 里没声明 —— 加上它,否则运行时必被门控拒绝`, `Plugin requires family "${fam}" but does not declare it in permissions — add it, otherwise every call will be denied`, "plugins.requires.family.undeclared", { family: fam });
2512
+ refused = true;
2513
+ break;
2514
+ }
2515
+ }
2516
+ if (refused)
2517
+ return;
2518
+ for (const dep of req.plugins ?? []) {
2519
+ if (dep === info.id) {
2520
+ refuseRequires(`插件 requires 依赖了自己(${dep})—— 请删掉这条自依赖`, `Plugin requires itself (${dep}) — remove the self-dependency`, "plugins.requires.self", { dep });
2521
+ refused = true;
2522
+ break;
2523
+ }
2524
+ if (!existsSync(join(this.pluginsDir, dep, "manifest.json"))) {
2525
+ refuseRequires(`插件 requires 对等插件「${dep}」但它没安装(plugins/${dep}/ 缺失)—— 先装上它`, `Plugin requires peer "${dep}" which is not installed (plugins/${dep}/ missing) — install it first`, "plugins.requires.peer.missing", { dep });
2526
+ refused = true;
2527
+ break;
2528
+ }
2529
+ if (existsSync(join(this.pluginsDir, dep, "index.mjs"))) {
2530
+ const peer = this.loaded.get(dep);
2531
+ if (!peer) {
2532
+ refuseRequires(`插件 requires 对等插件「${dep}」但它尚未激活 —— 请先解决它的问题`, `Plugin requires peer "${dep}" which is not active yet — fix it first`, "plugins.requires.peer.inactive", { dep });
2533
+ refused = true;
2534
+ break;
2535
+ }
2536
+ if (peer.info.error) {
2537
+ refuseRequires(`插件 requires 对等插件「${dep}」但它激活失败:${peer.info.error}`, `Plugin requires peer "${dep}" which failed to activate: ${peer.info.error}`, "plugins.requires.peer.failed", { dep });
2538
+ refused = true;
2539
+ break;
2540
+ }
2541
+ }
2542
+ // 纯前端对等端(无 index.mjs):目录存在即满足,它没有激活态。
2543
+ }
2544
+ if (refused)
2545
+ return;
2546
+ }
1959
2547
  // 引擎约束(manifest engines["pi-web-ui"]):不满足即拒绝激活(与 apiVersion 超前同级处理)。
1960
2548
  // 解析失败 → 警告放行不阻断;对等依赖缺失 → 只警告不断活。
1961
2549
  const enginesReq = info.engines?.["pi-web-ui"];
@@ -1989,12 +2577,7 @@ export class PluginManager {
1989
2577
  this.pushRuntimeDiag(info.id, `peer plugin missing: ${peer} (warn only, activation continues)`);
1990
2578
  }
1991
2579
  }
1992
- // 新增订阅的取消函数(反激活时经 releaseEntry 统一释放)。
1993
- const watchSubs = [];
1994
- const scheduleSubs = [];
1995
- const busSubs = [];
1996
- const statsSubs = [];
1997
- const streamingSubs = [];
2580
+ // 新增订阅的取消函数(反激活时经 effects 栈统一逆序释放)。
1998
2581
  // 出站网络白名单(scan 解析的 manifest netAllowlist 快照)。
1999
2582
  const netAllow = info.netAllowlist ?? [];
2000
2583
  // 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
@@ -2155,6 +2738,18 @@ export class PluginManager {
2155
2738
  toolHandlers.add(h);
2156
2739
  return () => toolHandlers.delete(h);
2157
2740
  },
2741
+ onToolPre: (h) => {
2742
+ if (!can("tools"))
2743
+ return () => { };
2744
+ preGuards.add(h);
2745
+ return () => preGuards.delete(h);
2746
+ },
2747
+ onToolPost: (h) => {
2748
+ if (!can("tools"))
2749
+ return () => { };
2750
+ postGuards.add(h);
2751
+ return () => postGuards.delete(h);
2752
+ },
2158
2753
  onRunEvent: (h) => {
2159
2754
  runHandlers.add(h);
2160
2755
  return () => runHandlers.delete(h);
@@ -2366,13 +2961,7 @@ export class PluginManager {
2366
2961
  },
2367
2962
  registerCommand: (cmd) => {
2368
2963
  const off = this.registerCommand(info.id, cmd);
2369
- unregisterCommands.push(off);
2370
- return () => {
2371
- const i = unregisterCommands.indexOf(off);
2372
- if (i >= 0)
2373
- unregisterCommands.splice(i, 1);
2374
- off();
2375
- };
2964
+ return effects.add(`command:/${String(cmd?.name ?? "?").replace(/^\/+/, "")}`, off);
2376
2965
  },
2377
2966
  storage,
2378
2967
  secrets,
@@ -2389,8 +2978,9 @@ export class PluginManager {
2389
2978
  self.pushRuntimeDiag(info.id, `route: invalid method/path (method=${String(method)} path=${String(path)}), ignored`);
2390
2979
  return () => { };
2391
2980
  }
2392
- httpRoutes.set(`${m} ${path}`, handler);
2393
- return () => httpRoutes.delete(`${m} ${path}`);
2981
+ const key = `${m} ${path}`;
2982
+ httpRoutes.set(key, handler);
2983
+ return effects.add(`route:${key}`, () => httpRoutes.delete(key));
2394
2984
  },
2395
2985
  registerProxy: (prefix, target) => {
2396
2986
  if (!can("http"))
@@ -2401,22 +2991,16 @@ export class PluginManager {
2401
2991
  self.pushRuntimeDiag(info.id, `registerProxy: invalid prefix/target or taken (prefix=${String(prefix)}), ignored`);
2402
2992
  return () => { };
2403
2993
  }
2404
- return () => {
2994
+ return effects.add(`proxy:${p}`, () => {
2405
2995
  self.unregisterProxy(info.id, p);
2406
- };
2996
+ });
2407
2997
  },
2408
2998
  // 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
2409
2999
  registerAgentTool: (tool) => {
2410
3000
  if (!can("tools"))
2411
3001
  return () => { };
2412
3002
  const off = this.registerAgentTool(info.id, tool);
2413
- unregisterTools.push(off);
2414
- return () => {
2415
- const i = unregisterTools.indexOf(off);
2416
- if (i >= 0)
2417
- unregisterTools.splice(i, 1);
2418
- off();
2419
- };
3003
+ return effects.add(`agentTool:${String(tool?.name ?? "?")}`, off);
2420
3004
  },
2421
3005
  dir,
2422
3006
  dataDir: this.dataDir,
@@ -2495,8 +3079,7 @@ export class PluginManager {
2495
3079
  /* already closed */
2496
3080
  }
2497
3081
  };
2498
- watchSubs.push(off);
2499
- return off;
3082
+ return effects.add(`watch:${String(relPath)}`, off);
2500
3083
  },
2501
3084
  },
2502
3085
  project: {
@@ -2543,6 +3126,13 @@ export class PluginManager {
2543
3126
  }
2544
3127
  };
2545
3128
  fire();
3129
+ effects.add(`bgTask:${id}`, () => {
3130
+ if (bgTaskTable.delete(id)) {
3131
+ if (bgTaskTable.size === 0)
3132
+ self.pluginBgTasks.delete(info.id);
3133
+ fire();
3134
+ }
3135
+ });
2546
3136
  return {
2547
3137
  update: (next) => {
2548
3138
  if (!bgTaskTable.has(id))
@@ -2600,13 +3190,13 @@ export class PluginManager {
2600
3190
  }
2601
3191
  if (added.length)
2602
3192
  void self.pushToAll().catch(() => { });
2603
- return () => {
3193
+ return effects.add(`ui:register(${added.join(",") || "none"})`, () => {
2604
3194
  if (!added.length)
2605
3195
  return;
2606
3196
  for (const id of added)
2607
3197
  self.removeUiItem(info.id, id);
2608
3198
  void self.pushToAll().catch(() => { });
2609
- };
3199
+ });
2610
3200
  },
2611
3201
  update: (id, patch) => {
2612
3202
  if (!can("ui"))
@@ -2643,7 +3233,13 @@ export class PluginManager {
2643
3233
  },
2644
3234
  list: () => self.uiOf(info.id) ?? { items: [], arrange: [] },
2645
3235
  },
2646
- getSettings: () => runtimeSettingsValues(dir, info.settingsSchema ?? [], secrets),
3236
+ getSettings: () => {
3237
+ // P2-9:运行时视角同样走三层合并(警告已在 scan 落诊断,这里静默用值)。
3238
+ const schema = info.settingsSchema ?? [];
3239
+ const overlayFile = readSettingsOverlayFile(self.dataDir, info.id);
3240
+ const overlay = cleanOverlaySettings(schema, overlayFile.raw, "en");
3241
+ return runtimeSettingsValues(dir, schema, secrets, overlay.values);
3242
+ },
2647
3243
  onSettingsChanged: (h) => {
2648
3244
  settingsHandlers.add(h);
2649
3245
  return () => settingsHandlers.delete(h);
@@ -2915,7 +3511,7 @@ export class PluginManager {
2915
3511
  }
2916
3512
  };
2917
3513
  // 反激活只停表、不断持久化:下次 activate 重调 schedule() 即按落盘声明重建。
2918
- scheduleSubs.push(cancelTimer);
3514
+ effects.add(`schedule:${sid}`, cancelTimer);
2919
3515
  return off;
2920
3516
  },
2921
3517
  models: {
@@ -2938,29 +3534,15 @@ export class PluginManager {
2938
3534
  },
2939
3535
  onStats: (h) => {
2940
3536
  self.statsHandlers.add(h);
2941
- const off = () => {
3537
+ return effects.add("onStats", () => {
2942
3538
  self.statsHandlers.delete(h);
2943
- };
2944
- statsSubs.push(off);
2945
- return () => {
2946
- const i = statsSubs.indexOf(off);
2947
- if (i >= 0)
2948
- statsSubs.splice(i, 1);
2949
- off();
2950
- };
3539
+ });
2951
3540
  },
2952
3541
  onStreaming: (h) => {
2953
3542
  self.streamingHandlers.add(h);
2954
- const off = () => {
3543
+ return effects.add("onStreaming", () => {
2955
3544
  self.streamingHandlers.delete(h);
2956
- };
2957
- streamingSubs.push(off);
2958
- return () => {
2959
- const i = streamingSubs.indexOf(off);
2960
- if (i >= 0)
2961
- streamingSubs.splice(i, 1);
2962
- off();
2963
- };
3545
+ });
2964
3546
  },
2965
3547
  net: {
2966
3548
  fetch: async (url, init) => {
@@ -3038,18 +3620,11 @@ export class PluginManager {
3038
3620
  if (!set)
3039
3621
  self.busHandlers.set(t, (set = new Set()));
3040
3622
  set.add(handler);
3041
- const off = () => {
3623
+ return effects.add(`bus:${t}`, () => {
3042
3624
  set.delete(handler);
3043
3625
  if (set.size === 0)
3044
3626
  self.busHandlers.delete(t);
3045
- };
3046
- busSubs.push(off);
3047
- return () => {
3048
- const i = busSubs.indexOf(off);
3049
- if (i >= 0)
3050
- busSubs.splice(i, 1);
3051
- off();
3052
- };
3627
+ });
3053
3628
  },
3054
3629
  },
3055
3630
  log: (levelOrArg, ...args) => {
@@ -3068,6 +3643,14 @@ export class PluginManager {
3068
3643
  else
3069
3644
  console.log(line);
3070
3645
  },
3646
+ effect: (label, dispose) => {
3647
+ if (typeof dispose !== "function")
3648
+ return () => { };
3649
+ const name = String(label ?? "")
3650
+ .trim()
3651
+ .slice(0, 64) || "anonymous";
3652
+ return effects.add(`plugin:${name}`, dispose);
3653
+ },
3071
3654
  };
3072
3655
  try {
3073
3656
  // Node 对同一 URL 的 import() 永远返回缓存模块——追加 epoch 作查询串
@@ -3083,17 +3666,13 @@ export class PluginManager {
3083
3666
  info: { ...info, ...(combined ? { diagnostics: combined } : {}) },
3084
3667
  deactivate: typeof ret === "function" ? ret : undefined,
3085
3668
  toolHandlers,
3669
+ preGuards,
3670
+ postGuards,
3086
3671
  runHandlers,
3087
3672
  convChangeHandlers,
3088
3673
  attachHandlers,
3089
3674
  cwdHandlers,
3090
- agentToolUnsubscribers: unregisterTools,
3091
- commandUnsubscribers: unregisterCommands,
3092
- watchUnsubscribers: watchSubs,
3093
- scheduleUnsubscribers: scheduleSubs,
3094
- busUnsubscribers: busSubs,
3095
- statsUnsubscribers: statsSubs,
3096
- streamingUnsubscribers: streamingSubs,
3675
+ effects,
3097
3676
  httpRoutes,
3098
3677
  permsDeclared,
3099
3678
  permFamilies,
@@ -3108,9 +3687,14 @@ export class PluginManager {
3108
3687
  void this.maybeConsentNotice(info, dir, permsDeclared);
3109
3688
  }
3110
3689
  catch (err) {
3690
+ // 激活失败:先把这一轮已经登记的可逆副作用逆序回卷(半途注册的工具/路由/
3691
+ // 定时器不能留着 —— 插件已经坏了,谁也不会来撤它们),再留一条错误占位行。
3692
+ const failed = effects.release();
3111
3693
  httpRoutes.clear();
3112
3694
  self.activatingGates.delete(info.id);
3113
3695
  this.pushRuntimeDiag(info.id, `activate failed: ${err.message}`);
3696
+ if (failed.length > 0)
3697
+ this.pushRuntimeDiag(info.id, `activate failed with ${failed.length} effect(s) not cleaned: ${failed.join(", ")}`);
3114
3698
  this.loaded.set(info.id, {
3115
3699
  info: { ...info, error: err.message, diagnostics: this.diagnosticsOf(info.id) },
3116
3700
  toolHandlers,