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.
- package/CHANGELOG.md +62 -0
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/http-proxy.js +11 -1
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +3 -2
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/dark-teal.css +20 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-18jK6al2.js +0 -364
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
package/dist/server/plugins.js
CHANGED
|
@@ -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
|
-
/**
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
function
|
|
614
|
-
const
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
|
840
|
+
return { values, sources };
|
|
634
841
|
}
|
|
635
|
-
/**
|
|
636
|
-
*
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
849
|
+
return parsed.settings;
|
|
644
850
|
}
|
|
645
851
|
}
|
|
646
852
|
catch {
|
|
647
853
|
/* 无存储文件 = 全默认 */
|
|
648
854
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
879
|
+
return { value: n };
|
|
655
880
|
}
|
|
656
|
-
|
|
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
|
|
669
|
-
const
|
|
670
|
-
if (
|
|
671
|
-
return {
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1654
|
-
*
|
|
1994
|
+
/** 反激活清理:把该插件名下全部订阅/注册一次收完。
|
|
1995
|
+
*
|
|
1996
|
+
* 两段:① 副作用栈逆序回卷(插件注册的全部可逆副作用都在里面 —— 工具/命令/路由/
|
|
1997
|
+
* 代理/watch/定时/后台任务/事件订阅/UI 条目…;跑不干净的记一条诊断);
|
|
1998
|
+
* ② 兜底回收全局表里按 pluginId 索引的残留(老宿主激活的实例没有栈、或
|
|
1999
|
+
* 插件绕过 host 自己往表里塞过东西:代理前缀按 id 过滤清一遍,幂等)。 */
|
|
1655
2000
|
releaseEntry(p) {
|
|
1656
|
-
//
|
|
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
|
-
|
|
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:
|
|
1811
|
-
settingsValues:
|
|
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
|
-
// 新增订阅的取消函数(反激活时经
|
|
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
|
-
|
|
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
|
-
|
|
2393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|