pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -21,16 +21,20 @@ import { existsSync, readFileSync, renameSync, watch as fsWatch, writeFileSync }
21
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import { pathToFileURL, fileURLToPath } from "node:url";
23
23
  import { pick } from "./i18n.js";
24
- import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
24
+ import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS, withFileRmwLock } from "./plugin-facilities.js";
25
25
  import { parseCronSpec, armDelay, nextCronFire, loadScheduleRecords, saveScheduleRecords, } from "./plugin-schedule.js";
26
26
  import { PluginPermissionStore } from "./plugin-permissions.js";
27
27
  import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
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
- import { createProject } from "./plugin-project.js";
37
+ import { createProject, isInsideRoot, realPathOfNearest, } from "./plugin-project.js";
34
38
  import { createHash } from "node:crypto";
35
39
  import { execFile } from "node:child_process";
36
40
  import { promisify } from "node:util";
@@ -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
  // 空串/缺省 = 不改(浏览器侧回显的本来就是有无布尔,前端把“没碰”发成空串)。
@@ -742,16 +958,20 @@ lang, secrets) {
742
958
  if (!secretKeys.has(k))
743
959
  persist[k] = v;
744
960
  const file = join(dir, "storage.json");
745
- let existing = {};
746
- try {
747
- existing = JSON.parse(readFileSync(file, "utf8"));
748
- }
749
- catch {
750
- /* 首次 */
751
- }
752
- const tmp = `${file}.tmp-${process.pid}`;
753
- writeFileSync(tmp, JSON.stringify({ ...existing, settings: persist }));
754
- renameSync(tmp, file);
961
+ // 与 PluginStorage.set/delete 同一把按文件路径的 RMW 锁(见 plugin-facilities.ts
962
+ // 的 withFileRmwLock):两个「读-改-写」者串行,谁也不会拿旧快照抹掉对方刚写的键。
963
+ withFileRmwLock(file, () => {
964
+ let existing = {};
965
+ try {
966
+ existing = JSON.parse(readFileSync(file, "utf8"));
967
+ }
968
+ catch {
969
+ /* 首次 */
970
+ }
971
+ const tmp = `${file}.tmp-${process.pid}`;
972
+ writeFileSync(tmp, JSON.stringify({ ...existing, settings: persist }));
973
+ renameSync(tmp, file);
974
+ });
755
975
  }
756
976
  catch (err) {
757
977
  console.error(`[plugins] settings persist failed (${dir}):`, err);
@@ -1003,6 +1223,17 @@ export class PluginManager {
1003
1223
  this.senders.add(s);
1004
1224
  return () => this.senders.delete(s);
1005
1225
  }
1226
+ /** 当前在线(hello 过)的 clientId 去重集合。插件授权弹窗广播后按它做应答
1227
+ * 来源绑定——广播后才连上的端没见过弹窗,不许代答(index.ts 三类授权应答校验)。 */
1228
+ onlineClientIds() {
1229
+ const out = new Set();
1230
+ for (const s of this.senders) {
1231
+ const cid = s.cid();
1232
+ if (cid)
1233
+ out.add(cid);
1234
+ }
1235
+ return [...out];
1236
+ }
1006
1237
  /** 客户端上行:路由给对应插件的处理器;未知/未激活的插件静默丢弃。
1007
1238
  * 插件代码不可信——同步抛错与返回的 Promise rejection 都必须隔离在
1008
1239
  * 这里,绝不能炸主进程。 */
@@ -1169,6 +1400,11 @@ export class PluginManager {
1169
1400
  return false;
1170
1401
  return !this.domConsentStore.has(pluginId);
1171
1402
  }
1403
+ /** plugin_dom_consent 两步握手的预检(index.ts 用):只有声明了 dom 能力的
1404
+ * 插件才允许生成在途 consent 请求——任意 pluginId 进不来,在途表不膨胀。 */
1405
+ isDomPlugin(pluginId) {
1406
+ return this.domWants.get(pluginId) === true;
1407
+ }
1172
1408
  /** 特权 DOM 授权/撤销(设置面板 plugin_dom_consent)。
1173
1409
  * 返回 { changed }:变了才 epoch+1 重推(浏览器按新 epoch 重拉 bundle,
1174
1410
  * 失败缓存随 syncPluginViews 的 epoch 切换清掉);目标不是 wantsDom 插件
@@ -1229,6 +1465,81 @@ export class PluginManager {
1229
1465
  }
1230
1466
  }
1231
1467
  }
1468
+ /** agent-service 调(P1-5):bash/read 执行前的守卫求值。首个 deny/ask 胜出;
1469
+ * 守卫抛错/超时一律按弃权(不阻断)。无守卫时回 allow,调用方可直接放行。 */
1470
+ async evaluateToolPre(req, lang = "en") {
1471
+ const frozen = {
1472
+ toolName: req.toolName,
1473
+ params: freezeParams(req.params),
1474
+ conversationId: req.conversationId,
1475
+ };
1476
+ for (const p of this.loaded.values()) {
1477
+ for (const h of p.preGuards ?? []) {
1478
+ let raw;
1479
+ try {
1480
+ raw = await withGuardTimeout(Promise.resolve().then(() => h(frozen)));
1481
+ }
1482
+ catch (err) {
1483
+ console.error(`[plugin:${p.info.id}] tool-pre guard failed (abstained):`, err);
1484
+ this.pushRuntimeDiag(p.info.id, `tool-pre guard threw, abstained (${req.toolName})`);
1485
+ continue;
1486
+ }
1487
+ if (raw === undefined)
1488
+ continue;
1489
+ const d = normalizePreDecision(raw);
1490
+ if (!isBlockingDecision(d))
1491
+ continue;
1492
+ this.pushRuntimeDiag(p.info.id, `tool-pre ${d.decision} ${req.toolName} (by ${p.info.id})`);
1493
+ return { verdict: d, pluginId: p.info.id };
1494
+ }
1495
+ }
1496
+ return { verdict: { decision: "allow" } };
1497
+ }
1498
+ /** agent-service 调(P1-5):bash/read 执行后的守卫合并。逐个顺序合进结果;
1499
+ * 抛错/超时的那个被跳过(其余继续)。返回合并后的 content(无编辑回 undefined)。 */
1500
+ async evaluateToolPost(req, lang = "en") {
1501
+ let content = Array.isArray(req.result?.content)
1502
+ ? [...req.result.content]
1503
+ : undefined;
1504
+ const base = { ...req.result, ...(content ? { content: [...content] } : {}) };
1505
+ let touched = false;
1506
+ const pluginIds = [];
1507
+ const frozenReq = {
1508
+ toolName: req.toolName,
1509
+ params: freezeParams(req.params),
1510
+ result: req.result,
1511
+ conversationId: req.conversationId,
1512
+ };
1513
+ for (const p of this.loaded.values()) {
1514
+ for (const h of p.postGuards ?? []) {
1515
+ let raw;
1516
+ try {
1517
+ raw = await withGuardTimeout(Promise.resolve().then(() => h(frozenReq)));
1518
+ }
1519
+ catch (err) {
1520
+ console.error(`[plugin:${p.info.id}] tool-post guard failed (skipped):`, err);
1521
+ this.pushRuntimeDiag(p.info.id, `tool-post guard threw, skipped (${req.toolName})`);
1522
+ continue;
1523
+ }
1524
+ const edit = normalizePostEdit(raw);
1525
+ if (!edit)
1526
+ continue;
1527
+ const merged = applyPostEdit(base, edit, lang);
1528
+ base.content = merged.content;
1529
+ Object.assign(base, merged);
1530
+ touched = true;
1531
+ pluginIds.push(p.info.id);
1532
+ this.pushRuntimeDiag(p.info.id, `tool-post edited ${req.toolName} (by ${p.info.id})`);
1533
+ }
1534
+ }
1535
+ if (!touched)
1536
+ return undefined;
1537
+ return { content: base.content, pluginIds };
1538
+ }
1539
+ /** 阻断时给模型看的一句话(pre 守卫命中后由 agent-service 取用)。 */
1540
+ guardDenialText(verdict, pluginId, lang = "en") {
1541
+ return denialText(verdict, pluginId, lang);
1542
+ }
1232
1543
  /** index.ts 注入:读取当前打开对话的快照(轨迹类插件经 host.getActiveConversation 调用)。 */
1233
1544
  conversationProvider = undefined;
1234
1545
  /** index.ts 注入:插件无头调用 agent(微信通道等经 host.chat 调用)。 */
@@ -1351,13 +1662,37 @@ export class PluginManager {
1351
1662
  }
1352
1663
  /** 当前打开对话的快照(无提供者/暂无对话时返回 null)。 */
1353
1664
  getActiveConversation() {
1665
+ let snap;
1354
1666
  try {
1355
- return this.conversationProvider?.() ?? null;
1667
+ snap = this.conversationProvider?.() ?? null;
1356
1668
  }
1357
1669
  catch (err) {
1358
1670
  console.error("[plugins] conversationProvider failed:", err);
1359
1671
  return null;
1360
1672
  }
1673
+ if (!snap)
1674
+ return null;
1675
+ // 交给插件前做防御性拷贝:provider 回的是快照管线的**活引用** —— messages
1676
+ // 数组与元素对象都被 60ms 推送管线缓存复用(lastMessagesArray / uiMessageCache),
1677
+ // 插件原地改一个字段/挪一个元素,污染的就是推给真实客户端的快照。浅拷数组 +
1678
+ // 元素对象 structuredClone;元素是纯 JSON 结构(details 已过 JSON.stringify 闸),
1679
+ // structuredClone 不会失败,JSON 往返只是万一携带不可克隆值时的兜底。
1680
+ return {
1681
+ ...snap,
1682
+ messages: snap.messages.map((m) => {
1683
+ try {
1684
+ return structuredClone(m);
1685
+ }
1686
+ catch {
1687
+ try {
1688
+ return JSON.parse(JSON.stringify(m));
1689
+ }
1690
+ catch {
1691
+ return { ...m }; // 连 JSON 往返都失败(循环引用):至少不共享顶层对象
1692
+ }
1693
+ }
1694
+ }),
1695
+ };
1361
1696
  }
1362
1697
  /** agent-service 调:当前打开对话变了(切历史会话/切 running 对话/新对话)——
1363
1698
  * 轨迹类插件靠它重拉时间线(异常隔离)。 */
@@ -1435,13 +1770,23 @@ export class PluginManager {
1435
1770
  out.push(...table.values());
1436
1771
  return out;
1437
1772
  }
1438
- /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。 */
1773
+ /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
1774
+ * 跨插件重名同样拒绝(与 registerCommand 同口径):工具进会话时按名字合入
1775
+ * (syncPluginToolsIntoSession 的 byName 覆盖),重名意味着后注册者静默抢注
1776
+ * 前者的工具,必须在这里挡下。 */
1439
1777
  registerAgentTool(pluginId, tool) {
1440
1778
  if (!tool || typeof tool.execute !== "function" || !tool.name || !tool.description) {
1441
1779
  console.error(`[plugin:${pluginId}] registerAgentTool: 缺少 name/description/execute,忽略`);
1442
1780
  this.pushRuntimeDiag(pluginId, "registerAgentTool: missing name/description/execute, ignored");
1443
1781
  return () => { };
1444
1782
  }
1783
+ for (const [pid, other] of this.agentTools) {
1784
+ if (pid !== pluginId && other.has(tool.name)) {
1785
+ console.error(`[plugin:${pluginId}] AI 工具 "${tool.name}" 已被插件 ${pid} 注册,忽略重复`);
1786
+ this.pushRuntimeDiag(pluginId, `agent tool "${tool.name}": already registered by plugin ${pid}, ignored`);
1787
+ return () => { };
1788
+ }
1789
+ }
1445
1790
  let table = this.agentTools.get(pluginId);
1446
1791
  if (!table)
1447
1792
  this.agentTools.set(pluginId, (table = new Map()));
@@ -1618,13 +1963,61 @@ export class PluginManager {
1618
1963
  async list(lang) {
1619
1964
  return this.scan(lang);
1620
1965
  }
1966
+ /** 机器可读的注册面目录(P2-7):slot 别名/kind/例子 + 工具目录 +
1967
+ * 宿主方法表 + 当前占用者(现算)。只读装配,无副作用。 */
1968
+ getApiCatalog() {
1969
+ const counts = new Map();
1970
+ const add = (pluginId, slot) => {
1971
+ let m = counts.get(slot);
1972
+ if (!m)
1973
+ counts.set(slot, (m = new Map()));
1974
+ m.set(pluginId, (m.get(pluginId) ?? 0) + 1);
1975
+ };
1976
+ for (const [pid, base] of this.uiBase)
1977
+ for (const it of base.items ?? [])
1978
+ add(pid, it.slot);
1979
+ for (const [pid, rt] of this.uiRuntime)
1980
+ for (const it of rt.items.values())
1981
+ add(pid, it.slot);
1982
+ return buildPluginApiCatalog({
1983
+ slots: [...UI_SLOTS],
1984
+ aliases: { ...UI_SLOT_ALIASES },
1985
+ kinds: [...UI_KINDS],
1986
+ agentTools: AGENT_TOOL_CATALOG.map((t) => ({
1987
+ name: t.name,
1988
+ group: t.group,
1989
+ defaultOn: t.defaultOn,
1990
+ dshVisible: t.dshVisible,
1991
+ })),
1992
+ occupantsOf: (slot) => {
1993
+ const m = counts.get(slot);
1994
+ if (!m)
1995
+ return [];
1996
+ return [...m].map(([pluginId, items]) => ({ pluginId, items }));
1997
+ },
1998
+ });
1999
+ }
1621
2000
  /**
1622
2001
  * attach 时调用:重扫目录 + 激活尚未加载的新插件。
1623
2002
  * 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
1624
2003
  */
1625
2004
  async ensureLoaded(lang) {
2005
+ const l = lang?.() ?? "en";
1626
2006
  const found = await this.scan(lang);
1627
- for (const info of found) {
2007
+ const byId = new Map(found.map((f) => [f.id, f]));
2008
+ const scannedIds = new Set(byId.keys());
2009
+ // P2-8:依赖拓扑排序激活(被依赖者先行;决定启动时机的是依赖,不是目录顺序)。
2010
+ // 边只连本次扫描里存在的对端;缺失的对端由 activate 点名拒绝。环上节点直接拒。
2011
+ const { order, cyclic } = sortByRequires(found.map((f) => f.id), (id) => (byId.get(id)?.requires?.plugins ?? []).filter((d) => scannedIds.has(d)));
2012
+ for (const cycId of cyclic) {
2013
+ const info = byId.get(cycId);
2014
+ if (this.loaded.has(info.id) || this.attempted.has(info.id))
2015
+ continue;
2016
+ const msg = pick(l, `插件 requires 存在循环依赖(${cycId})—— 请先解环再激活`, `Plugin has cyclic requires (${cycId}) — break the cycle first`, "plugins.requires.cycle", { id: cycId });
2017
+ this.setFrozenRefusal(info, msg, `cyclic requires (${cycId})`);
2018
+ }
2019
+ for (const id of order) {
2020
+ const info = byId.get(id);
1628
2021
  if (this.loaded.has(info.id) || this.attempted.has(info.id))
1629
2022
  continue;
1630
2023
  if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
@@ -1638,6 +2031,8 @@ export class PluginManager {
1638
2031
  this.deactivateEntry(id, p);
1639
2032
  }
1640
2033
  }
2034
+ // P2-8 级联:提供方刚坏(被删/失败/被拒)→ 仍在跑的消费方一并反激活 + 教学占位。
2035
+ this.cascadeRequiresRefusals(found, lang);
1641
2036
  return found.map((f) => {
1642
2037
  const base = this.loaded.get(f.id)?.info ?? f;
1643
2038
  // 运行相位(设置面板清单用):宿主持有实例即 active(含激活失败的占位行;
@@ -1650,32 +2045,23 @@ export class PluginManager {
1650
2045
  return rest;
1651
2046
  });
1652
2047
  }
1653
- /** 反激活清理:把该插件名下全部订阅/注册一次收完(工具/命令/watch/定时/
1654
- * 总线/stats/流式——参考 agentToolUnsubscribers 模式,新增订阅一律走这里)。 */
2048
+ /** 反激活清理:把该插件名下全部订阅/注册一次收完。
2049
+ *
2050
+ * 两段:① 副作用栈逆序回卷(插件注册的全部可逆副作用都在里面 —— 工具/命令/路由/
2051
+ * 代理/watch/定时/后台任务/事件订阅/UI 条目…;跑不干净的记一条诊断);
2052
+ * ② 兜底回收全局表里按 pluginId 索引的残留(老宿主激活的实例没有栈、或
2053
+ * 插件绕过 host 自己往表里塞过东西:代理前缀按 id 过滤清一遍,幂等)。 */
1655
2054
  releaseEntry(p) {
1656
- // 该插件注册的代理前缀随反激活一起回收(全局表按 pluginId 过滤;
2055
+ // ① 逆序回卷。失败项已在栈内打过诊断,这里不再重复。
2056
+ const failed = p.effects?.release() ?? [];
2057
+ if (failed.length > 0)
2058
+ console.warn(`[plugin:${p.info.id}] ${failed.length} effect(s) failed to clean up: ${failed.join(", ")}`);
2059
+ // ② 兜底:代理前缀随反激活一起回收(全局表按 pluginId 过滤;
1657
2060
  // Map 迭代中删除是良定义的:删过的条目不会再被访问到)。
1658
2061
  for (const [prefix, hit] of this.proxyRoutes) {
1659
2062
  if (hit.pluginId === p.info.id)
1660
2063
  this.proxyRoutes.delete(prefix);
1661
2064
  }
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
2065
  }
1680
2066
  /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。
1681
2067
  *
@@ -1709,17 +2095,26 @@ export class PluginManager {
1709
2095
  this.epochCounter += 1;
1710
2096
  console.log(`[plugin:${id}] removed`);
1711
2097
  }
1712
- /** 关机时反激活全部插件。 */
2098
+ /** 关机 / reload 时反激活全部插件(dispose 后 loaded 为空,后续 releaseEntry 是 no-op)。 */
1713
2099
  dispose() {
2100
+ const pluginIds = new Set();
1714
2101
  for (const [id, p] of this.loaded) {
2102
+ pluginIds.add(id);
1715
2103
  try {
1716
2104
  p.deactivate?.();
1717
2105
  }
1718
2106
  catch (err) {
1719
2107
  console.error(`[plugin:${id}] deactivate failed:`, err);
1720
2108
  }
1721
- this.releaseEntry(p);
1722
- // 反激活时停掉它注册的常驻后台任务(轮询器等),不留孤儿计时器。
2109
+ }
2110
+ // ① 先回卷全部 effect 栈(含 bgTask 停表、工具/命令/路由/watch/UI 条目注销),
2111
+ // 并把 loaded 清掉 —— 之后逐个走 releaseEntry 的兜底回收。
2112
+ for (const [id, p] of this.loaded) {
2113
+ p.effects?.release();
2114
+ this.loaded.delete(id);
2115
+ }
2116
+ // ② 兜底:停掉任何没经 effect 栈登记的常驻后台任务,不留孤儿计时器。
2117
+ for (const id of pluginIds) {
1723
2118
  for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
1724
2119
  try {
1725
2120
  t.stop?.();
@@ -1729,6 +2124,11 @@ export class PluginManager {
1729
2124
  }
1730
2125
  }
1731
2126
  }
2127
+ // ③ 兜底:清掉插件名下的代理前缀(该插件名下但未经栈登记的残留)。
2128
+ for (const prefix of [...this.proxyRoutes.keys()]) {
2129
+ if (pluginIds.has(this.proxyRoutes.get(prefix).pluginId))
2130
+ this.proxyRoutes.delete(prefix);
2131
+ }
1732
2132
  this.pluginBgTasks.clear();
1733
2133
  this.loaded.clear();
1734
2134
  this.messageHandlers.clear();
@@ -1762,6 +2162,43 @@ export class PluginManager {
1762
2162
  this.domWants.set(name, wantsDom);
1763
2163
  // 本轮 manifest 解析的诊断(重算覆盖;运行时诊断另存在 runtimeDiags)。
1764
2164
  const uiDiags = [];
2165
+ // P1-6:manifest 本体先校验,失败即拒(坏配置不进运行时)。
2166
+ const mv = validatePluginManifest(m, name);
2167
+ for (const w of mv.warnings)
2168
+ uiDiags.push(formatManifestIssue(w));
2169
+ if (mv.errors.length > 0) {
2170
+ for (const e of mv.errors)
2171
+ uiDiags.push(formatManifestIssue(e));
2172
+ const first = mv.errors[0];
2173
+ 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 });
2174
+ console.error(`[plugin:${name}] ${msg}`);
2175
+ this.manifestDiags.set(name, uiDiags);
2176
+ const refused = [...uiDiags, ...(this.runtimeDiags.get(name) ?? [])].slice(0, 100);
2177
+ out.push({
2178
+ id: name,
2179
+ name: typeof m.name === "string" && m.name ? m.name : name,
2180
+ hasClient: existsSync(join(dir, "client", "entry.mjs")),
2181
+ error: msg,
2182
+ view: false,
2183
+ ...(refused.length ? { diagnostics: refused } : {}),
2184
+ });
2185
+ const lpRefused = this.loaded.get(name);
2186
+ if (lpRefused) {
2187
+ if (refused.length)
2188
+ lpRefused.info.diagnostics = [...refused];
2189
+ else
2190
+ delete lpRefused.info.diagnostics;
2191
+ }
2192
+ continue;
2193
+ }
2194
+ // P2-9:声明式设置的三层合并(schema 默认 < 用户 overlay < 面板保存值)。
2195
+ // overlay 先读先洗,警告进诊断(英文短句,随清单下发);secret 永不来自 overlay。
2196
+ const settingsSchemaForScan = parseSettingsSchema(m.settings);
2197
+ const overlayFileForScan = readSettingsOverlayFile(this.dataDir, name);
2198
+ const overlayForScan = cleanOverlaySettings(settingsSchemaForScan, overlayFileForScan.raw, "en");
2199
+ for (const w of [...overlayFileForScan.warnings, ...overlayForScan.warnings])
2200
+ uiDiags.push(`override: ${w}`);
2201
+ const resolvedSettingsForScan = resolveSettingsValues(settingsSchemaForScan, readStoredSettings(dir), overlayForScan.values, { has: (k) => new PluginSecrets(this.dataDir, dir).has(k) });
1765
2202
  out.push({
1766
2203
  id: name,
1767
2204
  name: typeof m.name === "string" && m.name ? m.name : name,
@@ -1792,6 +2229,9 @@ export class PluginManager {
1792
2229
  peerPlugins: Array.isArray(m.peerPlugins)
1793
2230
  ? m.peerPlugins.filter((x) => typeof x === "string" && ID_RE.test(x)).slice(0, 16)
1794
2231
  : undefined,
2232
+ // 硬依赖声明(manifest "requires",P2-8):形状坏的在 P1-6 校验层已拒,
2233
+ // 这里只做宽容解析供展示 + activate 语义判定(缺失/失败/环/版本/能力即拒)。
2234
+ requires: parseRequires(m.requires),
1795
2235
  // 特权 DOM:permissions 含 dom 族 → bundle 默认 403,需用户逐个授权。
1796
2236
  // wantsDom 缓进 domWants(静态门禁查它,不必每次 readdir);error 在未授权时
1797
2237
  // 直接写明原因(tab 置灰 + 设置行提示),授权后下次 scan 自动清除。
@@ -1807,8 +2247,9 @@ export class PluginManager {
1807
2247
  }
1808
2248
  : {}),
1809
2249
  // 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
1810
- settingsSchema: parseSettingsSchema(m.settings),
1811
- settingsValues: storedSettingsValues(dir, parseSettingsSchema(m.settings), new PluginSecrets(this.dataDir, dir)),
2250
+ settingsSchema: settingsSchemaForScan,
2251
+ settingsValues: resolvedSettingsForScan.values,
2252
+ settingsSources: resolvedSettingsForScan.sources,
1812
2253
  // 可渲染的 fenced-code 语言(manifest "renderers")——前端据此按需加载
1813
2254
  renderers: Array.isArray(m.renderers)
1814
2255
  ? m.renderers.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
@@ -1823,6 +2264,39 @@ export class PluginManager {
1823
2264
  composerProviders: Array.isArray(m.composerProviders)
1824
2265
  ? m.composerProviders.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
1825
2266
  : undefined,
2267
+ // 文件单击查看器声明:只下发扩展名/处理器 id/优先级,实际 DOM
2268
+ // 渲染仍由客户端 bundle 提供,避免 manifest 携带可执行内容。
2269
+ fileHandlers: (() => {
2270
+ if (!Array.isArray(m.fileHandlers))
2271
+ return undefined;
2272
+ const rows = m.fileHandlers
2273
+ .filter((x) => Boolean(x) && typeof x === "object" && !Array.isArray(x))
2274
+ .slice(0, 32)
2275
+ .map((x) => {
2276
+ const extensions = Array.isArray(x.extensions)
2277
+ ? x.extensions
2278
+ .filter((e) => typeof e === "string" && /^\.[A-Za-z0-9][A-Za-z0-9._-]*$/.test(e.trim()))
2279
+ .map((e) => e.trim().toLowerCase())
2280
+ .slice(0, 32)
2281
+ : [];
2282
+ if (!extensions.length)
2283
+ return null;
2284
+ const id = typeof x.id === "string" && /^[A-Za-z0-9_-]+$/.test(x.id) ? x.id : "default";
2285
+ return {
2286
+ id,
2287
+ extensions: [...new Set(extensions)],
2288
+ priority: Number.isFinite(Number(x.priority))
2289
+ ? Math.max(-1000, Math.min(1000, Number(x.priority)))
2290
+ : 0,
2291
+ ...(typeof x.label === "string" && x.label.trim() ? { label: x.label.trim().slice(0, 120) } : {}),
2292
+ ...(typeof x.labelEn === "string" && x.labelEn.trim()
2293
+ ? { labelEn: x.labelEn.trim().slice(0, 120) }
2294
+ : {}),
2295
+ };
2296
+ })
2297
+ .filter((x) => x !== null);
2298
+ return rows.length ? rows : undefined;
2299
+ })(),
1826
2300
  // 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
1827
2301
  view: typeof m.view === "boolean" ? m.view : true,
1828
2302
  // 客户端 bundle 是否常驻加载(manifest "preload",缺省 false):无视图
@@ -1910,22 +2384,120 @@ export class PluginManager {
1910
2384
  }
1911
2385
  return out;
1912
2386
  }
2387
+ /** 非 activate 上下文的拒绝占位(P2-8 环/级联):空集合 + error + 诊断,
2388
+ * 与各门控的占位行同形(ensureLoaded 照例跳过,reload 重算刷新)。 */
2389
+ setFrozenRefusal(info, msg, diagEn) {
2390
+ console.error(`[plugin:${info.id}] ${msg}`);
2391
+ this.pushRuntimeDiag(info.id, diagEn);
2392
+ this.loaded.set(info.id, {
2393
+ info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
2394
+ toolHandlers: new Set(),
2395
+ runHandlers: new Set(),
2396
+ convChangeHandlers: new Set(),
2397
+ attachHandlers: new Set(),
2398
+ cwdHandlers: new Set(),
2399
+ httpRoutes: new Map(),
2400
+ settingsHandlers: new Set(),
2401
+ });
2402
+ }
2403
+ /** 级联(P2-8):提供方刚坏掉(被删/失败/被拒)→ 仍在跑的消费方一并反激活 +
2404
+ * 留教学占位。循环直到一轮无变化(链式依赖一轮收敛),返回受影响的 id。
2405
+ * hostApi/families 运行时不变,这里只查 plugins 对端(目录在 + 后端 peer 激活成功)。 */
2406
+ cascadeRequiresRefusals(found, lang) {
2407
+ const l = lang?.() ?? "en";
2408
+ const byId = new Map(found.map((f) => [f.id, f]));
2409
+ const hit = [];
2410
+ for (;;) {
2411
+ let changed = false;
2412
+ for (const [id, p] of [...this.loaded]) {
2413
+ if (p.info.error)
2414
+ continue;
2415
+ const req = byId.get(id)?.requires ?? p.info.requires;
2416
+ if (!req?.plugins?.length)
2417
+ continue;
2418
+ for (const dep of req.plugins) {
2419
+ let why = "";
2420
+ let whyEn = "";
2421
+ if (dep === id) {
2422
+ why = `插件 requires 依赖了自己(${dep})—— 请删掉这条自依赖`;
2423
+ whyEn = `Plugin requires itself (${dep}) — remove the self-dependency`;
2424
+ }
2425
+ else if (!byId.has(dep)) {
2426
+ why = `插件 requires 对等插件「${dep}」但它已不在(被删/被拒)—— 装回来或去掉这条依赖`;
2427
+ whyEn = `Plugin requires peer "${dep}" which is gone (removed or refused) — reinstall it or drop the dependency`;
2428
+ }
2429
+ else if (existsSync(join(this.pluginsDir, dep, "index.mjs"))) {
2430
+ const peer = this.loaded.get(dep);
2431
+ if (!peer || peer.info.error) {
2432
+ why = `插件 requires 对等插件「${dep}」但它已失败/停止:${peer?.info.error ?? "未激活"}—— 修好它或去掉这条依赖`;
2433
+ whyEn = `Plugin requires peer "${dep}" which failed/stopped: ${peer?.info.error ?? "inactive"} — fix it or drop the dependency`;
2434
+ }
2435
+ }
2436
+ if (!why)
2437
+ continue;
2438
+ const msg = pick(l, why, whyEn, "plugins.requires.cascade", { dep });
2439
+ this.deactivateEntry(id, p);
2440
+ this.setFrozenRefusal(byId.get(id) ?? p.info, msg, whyEn);
2441
+ hit.push(id);
2442
+ changed = true;
2443
+ break;
2444
+ }
2445
+ }
2446
+ if (!changed)
2447
+ break;
2448
+ }
2449
+ return hit;
2450
+ }
1913
2451
  async activate(info, lang) {
1914
2452
  const l = lang?.() ?? "en";
1915
2453
  this.attempted.add(info.id);
1916
2454
  const dir = join(this.pluginsDir, info.id);
2455
+ // P1-6:manifest 本体失败即拒(scan 缓存可能过期,这里重读重判,半途注册的副作用不留)。
2456
+ try {
2457
+ const rawManifest = readFileSync(join(dir, "manifest.json"), "utf8");
2458
+ const parsedManifest = JSON.parse(rawManifest);
2459
+ const mvActivate = validatePluginManifest(parsedManifest, info.id);
2460
+ if (mvActivate.errors.length > 0) {
2461
+ const first = mvActivate.errors[0];
2462
+ 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 });
2463
+ console.error(`[plugin:${info.id}] ${msg}`);
2464
+ for (const e of mvActivate.errors)
2465
+ this.pushRuntimeDiag(info.id, formatManifestIssue(e));
2466
+ for (const w of mvActivate.warnings)
2467
+ this.pushRuntimeDiag(info.id, formatManifestIssue(w));
2468
+ this.loaded.set(info.id, {
2469
+ info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
2470
+ toolHandlers: new Set(),
2471
+ runHandlers: new Set(),
2472
+ convChangeHandlers: new Set(),
2473
+ attachHandlers: new Set(),
2474
+ cwdHandlers: new Set(),
2475
+ httpRoutes: new Map(),
2476
+ settingsHandlers: new Set(),
2477
+ });
2478
+ return;
2479
+ }
2480
+ }
2481
+ catch {
2482
+ // 读不到/解析失败 → 走原有的 broken 占位逻辑(scan 里已产出,不在这里重复拒绝)。
2483
+ }
1917
2484
  const handlers = new Set();
1918
2485
  this.messageHandlers.set(info.id, handlers);
1919
2486
  const toolHandlers = new Set();
2487
+ const preGuards = new Set();
2488
+ const postGuards = new Set();
1920
2489
  const runHandlers = new Set();
1921
2490
  const convChangeHandlers = new Set();
1922
2491
  const attachHandlers = new Set();
1923
2492
  const cwdHandlers = new Set();
1924
2493
  const httpRoutes = new Map();
1925
- const unregisterTools = [];
1926
- const unregisterCommands = [];
1927
2494
  const bgTaskTable = new Map();
1928
2495
  const settingsHandlers = new Set();
2496
+ // 可逆副作用栈:本插件的全部注册面(工具/命令/路由/代理/watch/定时/后台任务/
2497
+ // UI 条目/事件订阅…)都经 effects.add 登记,反激活时**逆序**回卷 —— 插件忘写
2498
+ // 注销也不会留下孤儿订阅/定时器/路由(热重载后事件双触发、定时器叠加、watcher
2499
+ // 堆积都是这个漏法的症状)。cleanup 抛错只记诊断,不阻断回卷。
2500
+ const effects = new PluginEffectStack(info.id, (m) => this.pushRuntimeDiag(info.id, m));
1929
2501
  // 宿主 API 版本协商:插件要的比宿主新 → 明确拒绝(而不是让它在运行期
1930
2502
  // 撞 undefined 接口莫名其妙地坏)。与激活失败同一处理:error 字段 + 置灰。
1931
2503
  let apiVersion = 1;
@@ -1956,6 +2528,76 @@ export class PluginManager {
1956
2528
  const permsDeclared = (info.permissions ?? []).slice();
1957
2529
  const strict = permsDeclared.length > 0 || apiVersion >= 2;
1958
2530
  const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
2531
+ // 硬依赖声明(manifest "requires",P2-8):任一条不满足即拒绝激活 + 教学式错误
2532
+ // (peerPlugins 仍是缺失只警告的软依赖)。形状坏的在 P1-6 校验层已提前拒,
2533
+ // 这里只做语义判定:hostApi 下限、能力族已知+已声明、对等插件已安装且激活成功。
2534
+ const req = info.requires;
2535
+ if (req) {
2536
+ const refuseRequires = (zh, en, key, vars) => {
2537
+ const msg = pick(l, zh, en, key, vars);
2538
+ console.error(`[plugin:${info.id}] ${msg}`);
2539
+ this.pushRuntimeDiag(info.id, en);
2540
+ this.loaded.set(info.id, {
2541
+ info: { ...info, error: msg, diagnostics: this.diagnosticsOf(info.id) },
2542
+ toolHandlers,
2543
+ runHandlers,
2544
+ convChangeHandlers,
2545
+ attachHandlers,
2546
+ cwdHandlers,
2547
+ httpRoutes,
2548
+ settingsHandlers: new Set(),
2549
+ });
2550
+ return true;
2551
+ };
2552
+ if (req.hostApi !== undefined && req.hostApi > PLUGIN_API_VERSION) {
2553
+ 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 });
2554
+ return;
2555
+ }
2556
+ let refused = false;
2557
+ for (const f of req.families ?? []) {
2558
+ if (!isKnownPermission(f)) {
2559
+ 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 });
2560
+ refused = true;
2561
+ break;
2562
+ }
2563
+ const fam = f.split(":")[0];
2564
+ if (!permFamilies.has(fam)) {
2565
+ 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 });
2566
+ refused = true;
2567
+ break;
2568
+ }
2569
+ }
2570
+ if (refused)
2571
+ return;
2572
+ for (const dep of req.plugins ?? []) {
2573
+ if (dep === info.id) {
2574
+ refuseRequires(`插件 requires 依赖了自己(${dep})—— 请删掉这条自依赖`, `Plugin requires itself (${dep}) — remove the self-dependency`, "plugins.requires.self", { dep });
2575
+ refused = true;
2576
+ break;
2577
+ }
2578
+ if (!existsSync(join(this.pluginsDir, dep, "manifest.json"))) {
2579
+ refuseRequires(`插件 requires 对等插件「${dep}」但它没安装(plugins/${dep}/ 缺失)—— 先装上它`, `Plugin requires peer "${dep}" which is not installed (plugins/${dep}/ missing) — install it first`, "plugins.requires.peer.missing", { dep });
2580
+ refused = true;
2581
+ break;
2582
+ }
2583
+ if (existsSync(join(this.pluginsDir, dep, "index.mjs"))) {
2584
+ const peer = this.loaded.get(dep);
2585
+ if (!peer) {
2586
+ refuseRequires(`插件 requires 对等插件「${dep}」但它尚未激活 —— 请先解决它的问题`, `Plugin requires peer "${dep}" which is not active yet — fix it first`, "plugins.requires.peer.inactive", { dep });
2587
+ refused = true;
2588
+ break;
2589
+ }
2590
+ if (peer.info.error) {
2591
+ refuseRequires(`插件 requires 对等插件「${dep}」但它激活失败:${peer.info.error}`, `Plugin requires peer "${dep}" which failed to activate: ${peer.info.error}`, "plugins.requires.peer.failed", { dep });
2592
+ refused = true;
2593
+ break;
2594
+ }
2595
+ }
2596
+ // 纯前端对等端(无 index.mjs):目录存在即满足,它没有激活态。
2597
+ }
2598
+ if (refused)
2599
+ return;
2600
+ }
1959
2601
  // 引擎约束(manifest engines["pi-web-ui"]):不满足即拒绝激活(与 apiVersion 超前同级处理)。
1960
2602
  // 解析失败 → 警告放行不阻断;对等依赖缺失 → 只警告不断活。
1961
2603
  const enginesReq = info.engines?.["pi-web-ui"];
@@ -1989,12 +2631,7 @@ export class PluginManager {
1989
2631
  this.pushRuntimeDiag(info.id, `peer plugin missing: ${peer} (warn only, activation continues)`);
1990
2632
  }
1991
2633
  }
1992
- // 新增订阅的取消函数(反激活时经 releaseEntry 统一释放)。
1993
- const watchSubs = [];
1994
- const scheduleSubs = [];
1995
- const busSubs = [];
1996
- const statsSubs = [];
1997
- const streamingSubs = [];
2634
+ // 新增订阅的取消函数(反激活时经 effects 栈统一逆序释放)。
1998
2635
  // 出站网络白名单(scan 解析的 manifest netAllowlist 快照)。
1999
2636
  const netAllow = info.netAllowlist ?? [];
2000
2637
  // 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
@@ -2013,6 +2650,23 @@ export class PluginManager {
2013
2650
  return abs;
2014
2651
  throw new Error(`目录未授权:先 await host.fs.requestAccess(dir)(${abs})`);
2015
2652
  };
2653
+ /** 写类操作(write/remove)的 realpath 复核:allowAbs 是纯字符串判定,
2654
+ * 授权目录里的符号链接/junction 能把写入/递归删除引到授权范围之外。
2655
+ * 取目标最近已存在祖先的 realpath,要求它仍落在(工作区或该插件任一
2656
+ * 已授权目录)的 realpath 内。读/list/stat 保持字符串校验(高频路径,
2657
+ * 且读不存在「把内容写到别处」的风险)。 */
2658
+ const assertRealInsideGrant = async (abs) => {
2659
+ const targetReal = realPathOfNearest(abs);
2660
+ if (!targetReal)
2661
+ throw new Error(`无法解析真实路径:${abs}`);
2662
+ const granted = self.grants.list().find((g) => g.pluginId === info.id)?.paths ?? [];
2663
+ for (const r of [self.cwdValue, ...granted]) {
2664
+ const rootReal = realPathOfNearest(resolve(r)) ?? resolve(r);
2665
+ if (isInsideRoot(rootReal, targetReal))
2666
+ return;
2667
+ }
2668
+ throw new Error(`路径越界(符号链接指向授权范围之外):${abs}`);
2669
+ };
2016
2670
  const crossDirFs = {
2017
2671
  list: async (absDir) => {
2018
2672
  const abs = allowAbs(absDir);
@@ -2027,11 +2681,13 @@ export class PluginManager {
2027
2681
  },
2028
2682
  write: async (absPath, data) => {
2029
2683
  const abs = allowAbs(absPath);
2684
+ await assertRealInsideGrant(abs);
2030
2685
  await mkdir(dirname(abs), { recursive: true });
2031
2686
  await writeFile(abs, data);
2032
2687
  },
2033
2688
  remove: async (absPath) => {
2034
2689
  const abs = allowAbs(absPath);
2690
+ await assertRealInsideGrant(abs);
2035
2691
  await rm(abs, { recursive: true, force: true });
2036
2692
  },
2037
2693
  stat: async (absPath) => {
@@ -2155,6 +2811,18 @@ export class PluginManager {
2155
2811
  toolHandlers.add(h);
2156
2812
  return () => toolHandlers.delete(h);
2157
2813
  },
2814
+ onToolPre: (h) => {
2815
+ if (!can("tools"))
2816
+ return () => { };
2817
+ preGuards.add(h);
2818
+ return () => preGuards.delete(h);
2819
+ },
2820
+ onToolPost: (h) => {
2821
+ if (!can("tools"))
2822
+ return () => { };
2823
+ postGuards.add(h);
2824
+ return () => postGuards.delete(h);
2825
+ },
2158
2826
  onRunEvent: (h) => {
2159
2827
  runHandlers.add(h);
2160
2828
  return () => runHandlers.delete(h);
@@ -2366,13 +3034,7 @@ export class PluginManager {
2366
3034
  },
2367
3035
  registerCommand: (cmd) => {
2368
3036
  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
- };
3037
+ return effects.add(`command:/${String(cmd?.name ?? "?").replace(/^\/+/, "")}`, off);
2376
3038
  },
2377
3039
  storage,
2378
3040
  secrets,
@@ -2389,8 +3051,9 @@ export class PluginManager {
2389
3051
  self.pushRuntimeDiag(info.id, `route: invalid method/path (method=${String(method)} path=${String(path)}), ignored`);
2390
3052
  return () => { };
2391
3053
  }
2392
- httpRoutes.set(`${m} ${path}`, handler);
2393
- return () => httpRoutes.delete(`${m} ${path}`);
3054
+ const key = `${m} ${path}`;
3055
+ httpRoutes.set(key, handler);
3056
+ return effects.add(`route:${key}`, () => httpRoutes.delete(key));
2394
3057
  },
2395
3058
  registerProxy: (prefix, target) => {
2396
3059
  if (!can("http"))
@@ -2401,22 +3064,16 @@ export class PluginManager {
2401
3064
  self.pushRuntimeDiag(info.id, `registerProxy: invalid prefix/target or taken (prefix=${String(prefix)}), ignored`);
2402
3065
  return () => { };
2403
3066
  }
2404
- return () => {
3067
+ return effects.add(`proxy:${p}`, () => {
2405
3068
  self.unregisterProxy(info.id, p);
2406
- };
3069
+ });
2407
3070
  },
2408
3071
  // 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
2409
3072
  registerAgentTool: (tool) => {
2410
3073
  if (!can("tools"))
2411
3074
  return () => { };
2412
3075
  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
- };
3076
+ return effects.add(`agentTool:${String(tool?.name ?? "?")}`, off);
2420
3077
  },
2421
3078
  dir,
2422
3079
  dataDir: this.dataDir,
@@ -2495,8 +3152,7 @@ export class PluginManager {
2495
3152
  /* already closed */
2496
3153
  }
2497
3154
  };
2498
- watchSubs.push(off);
2499
- return off;
3155
+ return effects.add(`watch:${String(relPath)}`, off);
2500
3156
  },
2501
3157
  },
2502
3158
  project: {
@@ -2543,6 +3199,13 @@ export class PluginManager {
2543
3199
  }
2544
3200
  };
2545
3201
  fire();
3202
+ effects.add(`bgTask:${id}`, () => {
3203
+ if (bgTaskTable.delete(id)) {
3204
+ if (bgTaskTable.size === 0)
3205
+ self.pluginBgTasks.delete(info.id);
3206
+ fire();
3207
+ }
3208
+ });
2546
3209
  return {
2547
3210
  update: (next) => {
2548
3211
  if (!bgTaskTable.has(id))
@@ -2600,13 +3263,13 @@ export class PluginManager {
2600
3263
  }
2601
3264
  if (added.length)
2602
3265
  void self.pushToAll().catch(() => { });
2603
- return () => {
3266
+ return effects.add(`ui:register(${added.join(",") || "none"})`, () => {
2604
3267
  if (!added.length)
2605
3268
  return;
2606
3269
  for (const id of added)
2607
3270
  self.removeUiItem(info.id, id);
2608
3271
  void self.pushToAll().catch(() => { });
2609
- };
3272
+ });
2610
3273
  },
2611
3274
  update: (id, patch) => {
2612
3275
  if (!can("ui"))
@@ -2618,8 +3281,23 @@ export class PluginManager {
2618
3281
  self.pushRuntimeDiag(info.id, `ui.update: unknown id "${String(id).slice(0, 32)}", ignored`);
2619
3282
  return;
2620
3283
  }
2621
- const merged = { ...base, ...patch, id, slot: base.slot };
2622
- self.uiRuntimeFor(info.id).items.set(id, merged);
3284
+ // 与注册同一条校验管线:patch 合进 base 后整体过 parseUiItem,
3285
+ // 非法字段按 parse 语义丢弃/回落(未知键清掉、iconSvg 重新归一化、
3286
+ // 超长截断、progress 夹取),id 与 slot 不可被 patch 改写。
3287
+ const diags = [];
3288
+ const mergedRaw = typeof patch === "object" && patch !== null && !Array.isArray(patch)
3289
+ ? { ...base, ...patch, id, slot: base.slot }
3290
+ : { ...base, id, slot: base.slot };
3291
+ const parsed = parseUiItem(mergedRaw, base.slot, diags);
3292
+ for (const m of diags)
3293
+ self.pushRuntimeDiag(info.id, `ui.update: ${m}`);
3294
+ if (!parsed) {
3295
+ // 合并结果不合法(典型:patch 把 label 改成空)——按现有 parse 语义
3296
+ // 视为无效更新,保留原条目。
3297
+ self.pushRuntimeDiag(info.id, `ui.update: merged item invalid, update ignored`);
3298
+ return;
3299
+ }
3300
+ self.uiRuntimeFor(info.id).items.set(id, parsed);
2623
3301
  void self.pushToAll().catch(() => { });
2624
3302
  },
2625
3303
  remove: (id) => {
@@ -2643,7 +3321,13 @@ export class PluginManager {
2643
3321
  },
2644
3322
  list: () => self.uiOf(info.id) ?? { items: [], arrange: [] },
2645
3323
  },
2646
- getSettings: () => runtimeSettingsValues(dir, info.settingsSchema ?? [], secrets),
3324
+ getSettings: () => {
3325
+ // P2-9:运行时视角同样走三层合并(警告已在 scan 落诊断,这里静默用值)。
3326
+ const schema = info.settingsSchema ?? [];
3327
+ const overlayFile = readSettingsOverlayFile(self.dataDir, info.id);
3328
+ const overlay = cleanOverlaySettings(schema, overlayFile.raw, "en");
3329
+ return runtimeSettingsValues(dir, schema, secrets, overlay.values);
3330
+ },
2647
3331
  onSettingsChanged: (h) => {
2648
3332
  settingsHandlers.add(h);
2649
3333
  return () => settingsHandlers.delete(h);
@@ -2915,7 +3599,7 @@ export class PluginManager {
2915
3599
  }
2916
3600
  };
2917
3601
  // 反激活只停表、不断持久化:下次 activate 重调 schedule() 即按落盘声明重建。
2918
- scheduleSubs.push(cancelTimer);
3602
+ effects.add(`schedule:${sid}`, cancelTimer);
2919
3603
  return off;
2920
3604
  },
2921
3605
  models: {
@@ -2938,64 +3622,27 @@ export class PluginManager {
2938
3622
  },
2939
3623
  onStats: (h) => {
2940
3624
  self.statsHandlers.add(h);
2941
- const off = () => {
3625
+ return effects.add("onStats", () => {
2942
3626
  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
- };
3627
+ });
2951
3628
  },
2952
3629
  onStreaming: (h) => {
2953
3630
  self.streamingHandlers.add(h);
2954
- const off = () => {
3631
+ return effects.add("onStreaming", () => {
2955
3632
  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
- };
3633
+ });
2964
3634
  },
2965
3635
  net: {
2966
3636
  fetch: async (url, init) => {
2967
3637
  if (!can("net"))
2968
3638
  return { ok: false, error: '插件未声明能力 "net"(manifest.permissions)——请求被拒' };
2969
- try {
2970
- const u = new URL(String(url));
2971
- if (u.protocol !== "http:" && u.protocol !== "https:") {
2972
- return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
2973
- }
3639
+ return pluginNetFetch(url, init, {
2974
3640
  // 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。
2975
3641
  // 用户动态批准的主机(host.requestPermission)同样放行,免改 manifest 重装。
2976
- const hostname = u.hostname.toLowerCase();
2977
- const allowed = netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) ||
2978
- self.permGrants.has(info.id, "net", { host: hostname });
2979
- if (!allowed)
2980
- return {
2981
- ok: false,
2982
- error: `net: 主机 ${u.hostname} 未授权(manifest.netAllowlist 或 host.requestPermission 申请)`,
2983
- };
2984
- if (init?.body !== undefined && Buffer.byteLength(String(init.body), "utf8") > 1024 * 1024) {
2985
- return { ok: false, error: "net: body 超过 1MB 上限" };
2986
- }
2987
- const res = await globalThis.fetch(String(url), {
2988
- method: init?.method ?? "GET",
2989
- ...(init?.headers ? { headers: init.headers } : {}),
2990
- ...(init?.body !== undefined ? { body: init.body } : {}),
2991
- signal: AbortSignal.timeout(15_000),
2992
- });
2993
- const text = (await res.text()).slice(0, 512 * 1024);
2994
- return { ok: true, status: res.status, text };
2995
- }
2996
- catch (err) {
2997
- return { ok: false, error: err.message };
2998
- }
3642
+ hostAllowed: (hostname) => netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`)) ||
3643
+ self.permGrants.has(info.id, "net", { host: hostname }),
3644
+ fetchImpl: (input, reqInit) => globalThis.fetch(input, reqInit),
3645
+ });
2999
3646
  },
3000
3647
  },
3001
3648
  events: {
@@ -3038,18 +3685,11 @@ export class PluginManager {
3038
3685
  if (!set)
3039
3686
  self.busHandlers.set(t, (set = new Set()));
3040
3687
  set.add(handler);
3041
- const off = () => {
3688
+ return effects.add(`bus:${t}`, () => {
3042
3689
  set.delete(handler);
3043
3690
  if (set.size === 0)
3044
3691
  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
- };
3692
+ });
3053
3693
  },
3054
3694
  },
3055
3695
  log: (levelOrArg, ...args) => {
@@ -3068,6 +3708,14 @@ export class PluginManager {
3068
3708
  else
3069
3709
  console.log(line);
3070
3710
  },
3711
+ effect: (label, dispose) => {
3712
+ if (typeof dispose !== "function")
3713
+ return () => { };
3714
+ const name = String(label ?? "")
3715
+ .trim()
3716
+ .slice(0, 64) || "anonymous";
3717
+ return effects.add(`plugin:${name}`, dispose);
3718
+ },
3071
3719
  };
3072
3720
  try {
3073
3721
  // Node 对同一 URL 的 import() 永远返回缓存模块——追加 epoch 作查询串
@@ -3083,17 +3731,13 @@ export class PluginManager {
3083
3731
  info: { ...info, ...(combined ? { diagnostics: combined } : {}) },
3084
3732
  deactivate: typeof ret === "function" ? ret : undefined,
3085
3733
  toolHandlers,
3734
+ preGuards,
3735
+ postGuards,
3086
3736
  runHandlers,
3087
3737
  convChangeHandlers,
3088
3738
  attachHandlers,
3089
3739
  cwdHandlers,
3090
- agentToolUnsubscribers: unregisterTools,
3091
- commandUnsubscribers: unregisterCommands,
3092
- watchUnsubscribers: watchSubs,
3093
- scheduleUnsubscribers: scheduleSubs,
3094
- busUnsubscribers: busSubs,
3095
- statsUnsubscribers: statsSubs,
3096
- streamingUnsubscribers: streamingSubs,
3740
+ effects,
3097
3741
  httpRoutes,
3098
3742
  permsDeclared,
3099
3743
  permFamilies,
@@ -3108,9 +3752,14 @@ export class PluginManager {
3108
3752
  void this.maybeConsentNotice(info, dir, permsDeclared);
3109
3753
  }
3110
3754
  catch (err) {
3755
+ // 激活失败:先把这一轮已经登记的可逆副作用逆序回卷(半途注册的工具/路由/
3756
+ // 定时器不能留着 —— 插件已经坏了,谁也不会来撤它们),再留一条错误占位行。
3757
+ const failed = effects.release();
3111
3758
  httpRoutes.clear();
3112
3759
  self.activatingGates.delete(info.id);
3113
3760
  this.pushRuntimeDiag(info.id, `activate failed: ${err.message}`);
3761
+ if (failed.length > 0)
3762
+ this.pushRuntimeDiag(info.id, `activate failed with ${failed.length} effect(s) not cleaned: ${failed.join(", ")}`);
3114
3763
  this.loaded.set(info.id, {
3115
3764
  info: { ...info, error: err.message, diagnostics: this.diagnosticsOf(info.id) },
3116
3765
  toolHandlers,
@@ -3221,3 +3870,99 @@ export function syncPluginToolsIntoSession(session, defs, prevNames) {
3221
3870
  session._refreshToolRegistry();
3222
3871
  return new Set(defs.map((d) => d.name));
3223
3872
  }
3873
+ /** net.fetch 的手动重定向跳数上限。 */
3874
+ const NET_FETCH_MAX_REDIRECTS = 5;
3875
+ /**
3876
+ * net.fetch 的白名单 + 手动重定向循环(抽出为纯可注入函数,vitest 直测)。
3877
+ *
3878
+ * 为什么不用 redirect:"follow":跟随重定向不复查白名单,会把「只授权了 A 主机」
3879
+ * 变成「A 主机一跳把你带到任意主机/内网地址」(经典白名单绕过跳板)。所以:
3880
+ * - 每一跳(含首次)的目标 host 都重新过 allowHost(manifest 白名单 + 用户动态批准);
3881
+ * - redirect:"manual",至多 NET_FETCH_MAX_REDIRECTS 跳;
3882
+ * - 跨宿主重定向剥掉 authorization/cookie 头(浏览器同款语义,防凭据外带);
3883
+ * - 303 一律转 GET 并丢 body;其余 3xx 保持原 method/body(307/308 语义);
3884
+ * - 响应文本整体回给插件的现状保留(512KB 截断不变);
3885
+ * - timeout 是整条请求链(含所有跳)共享的墙钟预算(与原实现 15s 同量级)。
3886
+ */
3887
+ export async function pluginNetFetch(url, init, deps) {
3888
+ const deadline = Date.now() + Math.max(1000, Number(deps.timeoutMs ?? 15_000));
3889
+ let current = String(url ?? "");
3890
+ let method = init?.method;
3891
+ let headers = init?.headers;
3892
+ let body = init?.body;
3893
+ for (let hop = 0;; hop++) {
3894
+ let u;
3895
+ try {
3896
+ u = new URL(current);
3897
+ }
3898
+ catch {
3899
+ return { ok: false, error: `net: 无效 URL ${current}` };
3900
+ }
3901
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
3902
+ return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
3903
+ }
3904
+ // 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。每一跳都查。
3905
+ if (!deps.hostAllowed(u.hostname.toLowerCase())) {
3906
+ return {
3907
+ ok: false,
3908
+ error: `net: 主机 ${u.hostname} 未授权(manifest.netAllowlist 或 host.requestPermission 申请)`,
3909
+ };
3910
+ }
3911
+ if (body !== undefined && Buffer.byteLength(String(body), "utf8") > 1024 * 1024) {
3912
+ return { ok: false, error: "net: body 超过 1MB 上限" };
3913
+ }
3914
+ let res;
3915
+ try {
3916
+ res = await deps.fetchImpl(current, {
3917
+ ...(method ? { method } : {}),
3918
+ ...(headers ? { headers } : {}),
3919
+ ...(body !== undefined ? { body } : {}),
3920
+ redirect: "manual",
3921
+ signal: AbortSignal.timeout(Math.max(1000, deadline - Date.now())),
3922
+ });
3923
+ }
3924
+ catch (err) {
3925
+ return { ok: false, error: err.message };
3926
+ }
3927
+ // 3xx 且带 location → 手动跳下一跳(目标 host 重新过上面的白名单)。
3928
+ if (res.status >= 300 && res.status < 400) {
3929
+ const loc = res.headers.get("location");
3930
+ if (loc) {
3931
+ if (hop >= NET_FETCH_MAX_REDIRECTS) {
3932
+ return { ok: false, error: `net: 重定向超过 ${NET_FETCH_MAX_REDIRECTS} 跳上限` };
3933
+ }
3934
+ let next;
3935
+ try {
3936
+ next = new URL(loc, u);
3937
+ }
3938
+ catch {
3939
+ return { ok: false, error: `net: 重定向目标无效 ${loc}` };
3940
+ }
3941
+ if (next.hostname.toLowerCase() !== u.hostname.toLowerCase() && headers) {
3942
+ // 跨宿主:凭据类头不外带。
3943
+ const stripped = {};
3944
+ for (const [k, v] of Object.entries(headers)) {
3945
+ const key = k.toLowerCase();
3946
+ if (key === "authorization" || key === "cookie")
3947
+ continue;
3948
+ stripped[k] = v;
3949
+ }
3950
+ headers = stripped;
3951
+ }
3952
+ if (res.status === 303) {
3953
+ method = "GET";
3954
+ body = undefined;
3955
+ }
3956
+ current = next.toString();
3957
+ continue;
3958
+ }
3959
+ }
3960
+ try {
3961
+ const text = (await res.text()).slice(0, 512 * 1024);
3962
+ return { ok: true, status: res.status, text };
3963
+ }
3964
+ catch (err) {
3965
+ return { ok: false, error: err.message };
3966
+ }
3967
+ }
3968
+ }