pi-web-ui 0.86.2 → 0.87.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 (36) hide show
  1. package/CHANGELOG.md +59 -21
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  34. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  35. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  36. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
@@ -17,17 +17,20 @@
17
17
  * - activate 抛错只标记 error 字段并记日志,绝不影响主进程。
18
18
  */
19
19
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
20
- import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import { existsSync, readFileSync, renameSync, watch as fsWatch, writeFileSync } from "node:fs";
21
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
- import { pathToFileURL } from "node:url";
22
+ import { pathToFileURL, fileURLToPath } from "node:url";
23
23
  import { pick } from "./i18n.js";
24
24
  import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
25
25
  import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
26
26
  import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
27
+ import { PluginDomConsent, declarationWantsDom } from "./plugin-dom.js";
27
28
  // 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
28
29
  import { normalizeWorkspaceRoots } from "./client-state.js";
29
30
  import { createProject } from "./plugin-project.js";
30
31
  import { createHash } from "node:crypto";
32
+ import { execFile } from "node:child_process";
33
+ import { promisify } from "node:util";
31
34
  /** 合法插件 id:字母/数字/下划线/连字符,防路径穿越(同 themes.ts 的做法)。 */
32
35
  const ID_RE = /^[A-Za-z0-9_-]+$/;
33
36
  /** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
@@ -42,6 +45,73 @@ const MESSAGE_TIMEOUT_MS = 30_000;
42
45
  /** host.fs 被能力门控拒绝时的共享 rejected promise(类型对齐用)。 */
43
46
  const NO_FS_PROMISE = Promise.reject(new Error('插件未声明能力 "fs"(manifest.permissions)——请求被拒'));
44
47
  NO_FS_PROMISE.catch(() => { }); // 避免未处理 rejection 噪音;调用方 await 时拿到错误
48
+ /** 只读插件(仅声明 fs:read)撞到写操作时的共享 rejected promise:提示缺 fs/fs:write。 */
49
+ const NO_FS_WRITE_PROMISE = Promise.reject(new Error('缺少写能力:需要 "fs"/"fs:write"(manifest.permissions)——请求被拒'));
50
+ NO_FS_WRITE_PROMISE.catch(() => { }); // 同上:调用方 await 时拿到错误
51
+ /** host.bash 的执行体:execFile 直跑(不过 shell),与宿主其它 git/scm 查询同口径。 */
52
+ const execFileAsync = promisify(execFile);
53
+ /** 简单版本号三元组(x.y.z,忽略 -prerelease/+build 后缀)。 */
54
+ function parseVer(v) {
55
+ const m = String(v ?? "")
56
+ .trim()
57
+ .match(/^(\d+)\.(\d+)\.(\d+)$/);
58
+ if (!m)
59
+ return null;
60
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
61
+ }
62
+ function cmpVerTuple(a, b) {
63
+ for (let i = 0; i < 3; i++) {
64
+ if (a[i] !== b[i])
65
+ return a[i] - b[i];
66
+ }
67
+ return 0;
68
+ }
69
+ /** manifest engines["pi-web-ui"] 约束判定(支持 >=x.y.z / ^x.y.z / =x.y.z/裸 x.y.z)。
70
+ * 返回 null = 约束或当前版本解析失败(调用方警告放行,不阻断激活)。 */
71
+ export function satisfiesEnginesConstraint(constraint, current) {
72
+ const c = String(constraint ?? "").trim();
73
+ let kind, req;
74
+ if (c.startsWith(">=")) {
75
+ kind = ">=";
76
+ req = c.slice(2).trim();
77
+ }
78
+ else if (c.startsWith("^")) {
79
+ kind = "^";
80
+ req = c.slice(1).trim();
81
+ }
82
+ else if (c.startsWith("=")) {
83
+ kind = "=";
84
+ req = c.slice(1).trim();
85
+ }
86
+ else {
87
+ kind = "=";
88
+ req = c;
89
+ }
90
+ const r = parseVer(req);
91
+ const cur = parseVer(current);
92
+ if (!r || !cur)
93
+ return null;
94
+ if (kind === ">=")
95
+ return cmpVerTuple(cur, r) >= 0;
96
+ if (kind === "=")
97
+ return cmpVerTuple(cur, r) === 0;
98
+ return cur[0] === r[0] && cmpVerTuple(cur, r) >= 0; // ^:同主版本且不低于
99
+ }
100
+ /** 宿主自身版本(package.json version,读一次缓存;读不到回 null——调用方放行)。 */
101
+ let cachedHostVersion;
102
+ export function readHostVersion() {
103
+ if (cachedHostVersion !== undefined)
104
+ return cachedHostVersion;
105
+ try {
106
+ const here = dirname(fileURLToPath(import.meta.url));
107
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
108
+ cachedHostVersion = typeof pkg.version === "string" ? pkg.version : null;
109
+ }
110
+ catch {
111
+ cachedHostVersion = null;
112
+ }
113
+ return cachedHostVersion;
114
+ }
45
115
  // ---------------------------------------------------------------------------
46
116
  // 声明式设置 schema(manifest "settings")
47
117
  // ---------------------------------------------------------------------------
@@ -65,6 +135,14 @@ const UI_SLOTS = new Set([
65
135
  "contextmenu.session",
66
136
  "contextmenu.file",
67
137
  "settings.pages",
138
+ "leftpanel.sessions",
139
+ "chat.header",
140
+ "chat.empty",
141
+ "file.preview.toolbar",
142
+ "terminal.toolbar",
143
+ "scm.toolbar",
144
+ "goalbar.actions",
145
+ "notice.actions",
68
146
  ]);
69
147
  /** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
70
148
  const UI_SLOT_ALIASES = {
@@ -76,7 +154,24 @@ const UI_SLOT_ALIASES = {
76
154
  settings: "settings.pages",
77
155
  };
78
156
  /** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
79
- const UI_KINDS = new Set(["view", "action", "badge", "menu", "page", "organizer", "divider"]);
157
+ const UI_KINDS = new Set([
158
+ "view",
159
+ "action",
160
+ "badge",
161
+ "menu",
162
+ "page",
163
+ "organizer",
164
+ "divider",
165
+ "toggle",
166
+ "input",
167
+ "progress",
168
+ ]);
169
+ /** 合法的对齐取值(UiAlign):起首/居中/行尾,非法值丢弃(条目保留,align 回缺省)。 */
170
+ const UI_ALIGNS = new Set(["start", "center", "end"]);
171
+ /** 原始值 → 合法 UiAlign(非法/缺失一律 undefined,调用方填缺省)。 */
172
+ export function parseUiAlign(raw) {
173
+ return typeof raw === "string" && UI_ALIGNS.has(raw) ? raw : undefined;
174
+ }
80
175
  function trimStr(v, max = 60) {
81
176
  return typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
82
177
  }
@@ -109,10 +204,12 @@ export function parseUiItem(raw, slot) {
109
204
  ? o.when.filter((x) => typeof x === "string" && x.trim().length > 0).slice(0, 8)
110
205
  : undefined;
111
206
  const num = Number(o.order);
207
+ const align = parseUiAlign(o.align);
112
208
  return {
113
209
  id,
114
210
  slot: slot,
115
211
  label,
212
+ ...(align ? { align } : {}),
116
213
  ...(trimStr(o.labelEn, 60) ? { labelEn: trimStr(o.labelEn, 60) } : {}),
117
214
  ...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
118
215
  ...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
@@ -126,6 +223,11 @@ export function parseUiItem(raw, slot) {
126
223
  ...(trimStr(o.view, 64) ? { view: trimStr(o.view, 64) } : {}),
127
224
  ...(when?.length ? { when } : {}),
128
225
  ...(trimStr(o.badge, 24) ? { badge: trimStr(o.badge, 24) } : {}),
226
+ ...(typeof o.checked === "boolean" ? { checked: o.checked } : {}),
227
+ ...(typeof o.value === "string" ? { value: o.value.slice(0, 500) } : {}),
228
+ ...(typeof o.progress === "number" && Number.isFinite(o.progress)
229
+ ? { progress: Math.max(0, Math.min(100, o.progress)) }
230
+ : {}),
129
231
  };
130
232
  }
131
233
  /**
@@ -183,6 +285,7 @@ export function parseUiArrange(raw) {
183
285
  continue;
184
286
  const slotRaw = trimStr(o.slot, 32);
185
287
  const num = Number(o.order);
288
+ const align = parseUiAlign(o.align);
186
289
  out.push({
187
290
  id,
188
291
  ...(slotRaw && UI_SLOTS.has(slotRaw) ? { slot: slotRaw } : {}),
@@ -190,6 +293,7 @@ export function parseUiArrange(raw) {
190
293
  ...(o.hide === false ? { hide: false } : {}),
191
294
  ...(trimStr(o.group, 40) ? { group: trimStr(o.group, 40) } : {}),
192
295
  ...(Number.isFinite(num) ? { order: num } : {}),
296
+ ...(align ? { align } : {}),
193
297
  ...(trimStr(o.label, 60) ? { label: trimStr(o.label, 60) } : {}),
194
298
  ...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
195
299
  ...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
@@ -345,6 +449,10 @@ export class PluginManager {
345
449
  workspaceRoots = [];
346
450
  /** 插件目录授权表(<dataDir>/plugin-grants.json,issue #146)。 */
347
451
  grants;
452
+ /** 特权 DOM 授权表(<dataDir>/plugin-dom.json):wantsDom 插件的 bundle 门禁。 */
453
+ domConsentStore;
454
+ /** scan() 顺手维护的 wantsDom 快照:静态门禁查它,不必每次 readdir。未知 id = 不拦。 */
455
+ domWants = new Map();
348
456
  /** 由 index.ts 注入:向浏览器请求「插件要访问这个目录」的用户确认。 */
349
457
  pathAccessRequester = undefined;
350
458
  /** 由 index.ts 注入:授权表**变了**(新授权落表)时触发 —— 设置面板的「已授权目录」
@@ -361,6 +469,7 @@ export class PluginManager {
361
469
  this.builtinCatalogPath = builtinCatalogPath;
362
470
  this.cwdValue = resolve(cwd);
363
471
  this.grants = new PluginGrantsStore(dataDir);
472
+ this.domConsentStore = new PluginDomConsent(dataDir);
364
473
  }
365
474
  /** index.ts 在客户端 set_cwd 成功后调用:更新全局工作区并扇出给
366
475
  * 所有已激活插件的 onCwdChange 钩子(异常隔离,不炸主进程)。 */
@@ -645,6 +754,30 @@ export class PluginManager {
645
754
  const list = await this.scan();
646
755
  this.deliverAll({ type: "plugins", plugins: list, epoch: this.epochCounter });
647
756
  }
757
+ /** index.ts 静态门禁查这个:wantsDom 且未授权 → 403。 */
758
+ isDomBundleBlocked(pluginId) {
759
+ if (!this.domWants.get(pluginId))
760
+ return false;
761
+ return !this.domConsentStore.has(pluginId);
762
+ }
763
+ /** 特权 DOM 授权/撤销(设置面板 plugin_dom_consent)。
764
+ * 返回 { changed }:变了才 epoch+1 重推(浏览器按新 epoch 重拉 bundle,
765
+ * 失败缓存随 syncPluginViews 的 epoch 切换清掉);目标不是 wantsDom 插件
766
+ * 或 id 非法 → { changed: false, error }。 */
767
+ async setDomConsent(pluginId, granted) {
768
+ const id = String(pluginId ?? "").trim();
769
+ if (!ID_RE.test(id))
770
+ return { changed: false, error: "非法插件 id" };
771
+ if (!this.domWants.get(id))
772
+ return { changed: false, error: "该插件未声明 dom 能力,无需授权" };
773
+ const changed = this.domConsentStore.set(id, granted);
774
+ if (!changed)
775
+ return { changed: false };
776
+ // 授权前后 bundle 的 403/200 状态翻转:epoch+1 让浏览器丢掉旧模块缓存重拉。
777
+ this.epochCounter += 1;
778
+ void this.pushToAll().catch(() => { });
779
+ return { changed: true };
780
+ }
648
781
  /** 服务端热重载:反激活全部 → 清缓存 → 重扫重激活 → epoch+1。
649
782
  * 返回新目录清单(含激活结果)。重激活后的插件实例是新模块,
650
783
  * 内存状态为初始值——逐个客户端触发 onAttach 让它们重推自身状态。 */
@@ -691,6 +824,111 @@ export class PluginManager {
691
824
  conversationProvider = undefined;
692
825
  /** index.ts 注入:插件无头调用 agent(微信通道等经 host.chat 调用)。 */
693
826
  chatProvider = undefined;
827
+ /** 由 index.ts 接入 agent-service:对话列表(host.conversations.list 的底层)。
828
+ * 同步数组与 Promise 都收(index.ts 接线是 async 的),host 侧归一化。无注入回 []。 */
829
+ conversationLister = undefined;
830
+ /** 由 index.ts 接入 agent-service:对话搜索(host.conversations.search 的底层)。无注入时
831
+ * host 回退用 conversationLister 做标题过滤;两者都无回 []。 */
832
+ conversationSearcher = undefined;
833
+ /** 由 index.ts 接入 agent-service:向指定对话发用户消息(host.prompt 的底层)。
834
+ * 无注入回 {ok:false},绝不抛错。 */
835
+ conversationWriter = undefined;
836
+ /** 由 index.ts 接入 agent-service:插队指定对话的运行(host.steer 的底层)。无注入回 {ok:false}。 */
837
+ runSteerer = undefined;
838
+ /** 由 index.ts 接入 agent-service:中止指定对话的运行(host.abortRun 的底层)。无注入回 {ok:false}。 */
839
+ runAborter = undefined;
840
+ /** 由 index.ts 接入 agent-service:模型列表(host.models.list 的底层)。无注入回 []。 */
841
+ modelLister = undefined;
842
+ /** 会话统计订阅(host.onStats 注册到这里;emitStats 异常隔离扇出)。 */
843
+ statsHandlers = new Set();
844
+ /** 流式增量订阅(host.onStreaming 注册到这里;emitStreaming 只透传,发送方负责节流)。 */
845
+ streamingHandlers = new Set();
846
+ /** 插件间总线订阅:topic → 处理器集合(host.events.on 注册;反激活时清理该插件全部订阅)。 */
847
+ busHandlers = new Map();
848
+ /** host.chatWait 的 run_end 等待者(emitRunEvent 里按 conversationId 唤醒)。 */
849
+ chatWaiters = [];
850
+ /** 激活期插件的能力门控快照(activate 开头写入、落盘 loaded 或失败即删):
851
+ * canUse 在 host 对象可用之前(loaded.set 之前)也要能判定。 */
852
+ activatingGates = new Map();
853
+ /** 会话统计扇出(异常隔离;订阅者崩了只记日志)。发送方(如定期推送快照统计处)负责节流。 */
854
+ emitStats(s) {
855
+ for (const h of [...this.statsHandlers]) {
856
+ try {
857
+ h(s);
858
+ }
859
+ catch (err) {
860
+ console.error("[plugins] stats handler failed:", err);
861
+ }
862
+ }
863
+ }
864
+ /** 流式增量透传(异常隔离;宿主不做节流,由发送方保证频率)。 */
865
+ emitStreaming(ev) {
866
+ for (const h of [...this.streamingHandlers]) {
867
+ try {
868
+ h(ev);
869
+ }
870
+ catch (err) {
871
+ console.error("[plugins] streaming handler failed:", err);
872
+ }
873
+ }
874
+ }
875
+ /** 能力门控统一入口(原 activate 内联 can() 的抽出):
876
+ * - 声明 "fs" = 读写全开(向后兼容);仅声明 "fs:read" 时 "fs" 全量不算拥有
877
+ * (读放行、写与 project.create 被拒,错误提示缺 fs/fs:write);
878
+ * - 其它族精确匹配;"dom:anchor" 与 "dom" 互不相干(各管各的族);
879
+ * - 未声明 permissions 且 apiVersion<2 = 旧全权模式(警告一次后放行)。 */
880
+ canUse(pluginId, family) {
881
+ const rec = this.activatingGates.get(pluginId) ?? this.loaded.get(pluginId);
882
+ const fam = String(family ?? "");
883
+ const deny = (what) => {
884
+ console.error(`[plugin:${pluginId}] 缺少能力声明 "${what}"(manifest.permissions)——请求被拒`);
885
+ return false;
886
+ };
887
+ if (!rec)
888
+ return deny(fam);
889
+ const full = new Set(rec.permsDeclared ?? []);
890
+ const strict = rec.strictMode ?? (rec.permsDeclared?.length ?? 0) > 0;
891
+ const legacy = (what) => {
892
+ if (!strict) {
893
+ if (!rec.legacyWarned) {
894
+ rec.legacyWarned = true;
895
+ console.warn(`[plugin:${pluginId}] manifest 未声明 permissions(旧格式全权模式)——已放行 "${what}";apiVersion 2 起将默认拒绝,请尽快声明`);
896
+ }
897
+ return true;
898
+ }
899
+ return deny(what);
900
+ };
901
+ if (fam === "fs:read" || fam === "fs:write") {
902
+ if (full.has("fs") || full.has(fam))
903
+ return true;
904
+ return legacy(fam);
905
+ }
906
+ if (fam === "fs") {
907
+ if (full.has("fs"))
908
+ return true;
909
+ return legacy(fam);
910
+ }
911
+ if ((rec.permFamilies ?? new Set()).has(fam))
912
+ return true;
913
+ return legacy(fam);
914
+ }
915
+ /** host.chatWait 等待指定对话的 run_end(默认 120s 超时由调用方钳制后传入)。 */
916
+ waitRunEnd(conversationId, timeoutMs) {
917
+ return new Promise((resolve) => {
918
+ const entry = {
919
+ conversationId,
920
+ done: (ended) => {
921
+ clearTimeout(timer);
922
+ const i = this.chatWaiters.indexOf(entry);
923
+ if (i >= 0)
924
+ this.chatWaiters.splice(i, 1);
925
+ resolve(ended);
926
+ },
927
+ };
928
+ const timer = setTimeout(() => entry.done(false), timeoutMs);
929
+ this.chatWaiters.push(entry);
930
+ });
931
+ }
694
932
  /** 当前打开对话的快照(无提供者/暂无对话时返回 null)。 */
695
933
  getActiveConversation() {
696
934
  try {
@@ -718,8 +956,21 @@ export class PluginManager {
718
956
  }
719
957
  }
720
958
  /** agent-service 调:把运行轨迹事件扇出给所有插件(异常隔离,
721
- * 与 emitToolEvent 同级;订阅者崩了只记日志,不影响主流程)。 */
959
+ * 与 emitToolEvent 同级;订阅者崩了只记日志,不影响主流程)。
960
+ * run_end 顺带唤醒 host.chatWait 的等待者(按 conversationId 匹配)。 */
722
961
  emitRunEvent(ev) {
962
+ if (ev.type === "run_end" && ev.conversationId) {
963
+ for (const w of [...this.chatWaiters]) {
964
+ if (w.conversationId !== ev.conversationId)
965
+ continue;
966
+ try {
967
+ w.done(true);
968
+ }
969
+ catch (err) {
970
+ console.error(`[plugins] chatWait waiter failed:`, err);
971
+ }
972
+ }
973
+ }
723
974
  for (const p of this.loaded.values()) {
724
975
  if (p.runHandlers.size === 0)
725
976
  continue;
@@ -884,6 +1135,27 @@ export class PluginManager {
884
1135
  }
885
1136
  return found.map((f) => this.loaded.get(f.id)?.info ?? f);
886
1137
  }
1138
+ /** 反激活清理:把该插件名下全部订阅/注册一次收完(工具/命令/watch/定时/
1139
+ * 总线/stats/流式——参考 agentToolUnsubscribers 模式,新增订阅一律走这里)。 */
1140
+ releaseEntry(p) {
1141
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
1142
+ for (const off of [
1143
+ ...(p.agentToolUnsubscribers ?? []),
1144
+ ...(p.commandUnsubscribers ?? []),
1145
+ ...(p.watchUnsubscribers ?? []),
1146
+ ...(p.scheduleUnsubscribers ?? []),
1147
+ ...(p.busUnsubscribers ?? []),
1148
+ ...(p.statsUnsubscribers ?? []),
1149
+ ...(p.streamingUnsubscribers ?? []),
1150
+ ]) {
1151
+ try {
1152
+ off();
1153
+ }
1154
+ catch {
1155
+ /* already gone */
1156
+ }
1157
+ }
1158
+ }
887
1159
  /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。
888
1160
  *
889
1161
  * 注意这里必须把 id 从 attempted 里摘掉:目录一时不在(`pi-web-ui install --force`
@@ -898,15 +1170,7 @@ export class PluginManager {
898
1170
  catch (err) {
899
1171
  console.error(`[plugin:${id}] deactivate failed:`, err);
900
1172
  }
901
- // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
902
- for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
903
- try {
904
- off();
905
- }
906
- catch {
907
- /* already gone */
908
- }
909
- }
1173
+ this.releaseEntry(p);
910
1174
  this.loaded.delete(id);
911
1175
  this.messageHandlers.delete(id);
912
1176
  this.attempted.delete(id);
@@ -928,14 +1192,7 @@ export class PluginManager {
928
1192
  catch (err) {
929
1193
  console.error(`[plugin:${id}] deactivate failed:`, err);
930
1194
  }
931
- for (const off of [...(p.agentToolUnsubscribers ?? []), ...(p.commandUnsubscribers ?? [])]) {
932
- try {
933
- off();
934
- }
935
- catch {
936
- /* shutting down */
937
- }
938
- }
1195
+ this.releaseEntry(p);
939
1196
  // 反激活时停掉它注册的常驻后台任务(轮询器等),不留孤儿计时器。
940
1197
  for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
941
1198
  try {
@@ -966,7 +1223,10 @@ export class PluginManager {
966
1223
  if (!(await stat(dir)).isDirectory())
967
1224
  continue;
968
1225
  const raw = await readFile(join(dir, "manifest.json"), "utf8");
1226
+ // wantsDom 快照供静态门禁用(manifest 坏了 → JSON.parse 抛 → catch 跳过,旧快照残留无害:门禁只拦「快照里要且没授权」的)。
969
1227
  const m = JSON.parse(raw);
1228
+ const wantsDom = declarationWantsDom(m.permissions);
1229
+ this.domWants.set(name, wantsDom);
970
1230
  out.push({
971
1231
  id: name,
972
1232
  name: typeof m.name === "string" && m.name ? m.name : name,
@@ -979,6 +1239,37 @@ export class PluginManager {
979
1239
  permissions: Array.isArray(m.permissions)
980
1240
  ? m.permissions.filter((p) => typeof p === "string" && p.length > 0).slice(0, 16)
981
1241
  : undefined,
1242
+ // 出站网络白名单(permissions 含 "net" 时生效;空/缺省 = 全拒,fail-closed)。
1243
+ netAllowlist: Array.isArray(m.netAllowlist)
1244
+ ? m.netAllowlist
1245
+ .filter((x) => typeof x === "string" && x.trim().length > 0)
1246
+ .map((x) => x.trim().toLowerCase())
1247
+ .slice(0, 32)
1248
+ : undefined,
1249
+ // 引擎约束({ "pi-web-ui": ">=x.y.z" },不满足即拒绝激活,见 activate)。
1250
+ engines: m.engines && typeof m.engines === "object" && !Array.isArray(m.engines)
1251
+ ? Object.fromEntries(Object.entries(m.engines)
1252
+ .filter(([, v]) => typeof v === "string")
1253
+ .slice(0, 8))
1254
+ : undefined,
1255
+ // 可选对等依赖(其它插件 id,缺失只警告不断活,见 activate)。
1256
+ peerPlugins: Array.isArray(m.peerPlugins)
1257
+ ? m.peerPlugins.filter((x) => typeof x === "string" && ID_RE.test(x)).slice(0, 16)
1258
+ : undefined,
1259
+ // 特权 DOM:permissions 含 dom 族 → bundle 默认 403,需用户逐个授权。
1260
+ // wantsDom 缓进 domWants(静态门禁查它,不必每次 readdir);error 在未授权时
1261
+ // 直接写明原因(tab 置灰 + 设置行提示),授权后下次 scan 自动清除。
1262
+ ...(wantsDom
1263
+ ? {
1264
+ wantsDom: true,
1265
+ domGranted: this.domConsentStore.has(name),
1266
+ ...(this.domConsentStore.has(name)
1267
+ ? {}
1268
+ : {
1269
+ error: this.loaded.get(name)?.info.error ?? "需授权完全 DOM 访问后加载(设置 → 界面插件 → 授权)",
1270
+ }),
1271
+ }
1272
+ : {}),
982
1273
  // 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
983
1274
  settingsSchema: parseSettingsSchema(m.settings),
984
1275
  settingsValues: storedSettingsValues(dir, parseSettingsSchema(m.settings)),
@@ -986,6 +1277,16 @@ export class PluginManager {
986
1277
  renderers: Array.isArray(m.renderers)
987
1278
  ? m.renderers.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
988
1279
  : undefined,
1280
+ // 自定义消息部件 / 附件卡 / 输入框补全源(与 renderers 同一过滤口径)
1281
+ messageWidgets: Array.isArray(m.messageWidgets)
1282
+ ? m.messageWidgets.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
1283
+ : undefined,
1284
+ attachmentCards: Array.isArray(m.attachmentCards)
1285
+ ? m.attachmentCards.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
1286
+ : undefined,
1287
+ composerProviders: Array.isArray(m.composerProviders)
1288
+ ? m.composerProviders.filter((r) => typeof r === "string" && r.length > 0).slice(0, 32)
1289
+ : undefined,
989
1290
  // 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
990
1291
  view: typeof m.view === "boolean" ? m.view : true,
991
1292
  // 插件对宿主 UI 的贡献(manifest "ui":slot 框架 + 整理意图,issue #146)。
@@ -1027,6 +1328,11 @@ export class PluginManager {
1027
1328
  continue; // 无 manifest / JSON 坏 —— 不是插件
1028
1329
  }
1029
1330
  }
1331
+ // 删掉的目录不同时清快照:门禁会把不存在的 id 当 403,正确的应该是 404。
1332
+ for (const key of this.domWants.keys()) {
1333
+ if (!out.some((p) => p.id === key))
1334
+ this.domWants.delete(key);
1335
+ }
1030
1336
  return out;
1031
1337
  }
1032
1338
  async activate(info, lang) {
@@ -1072,20 +1378,44 @@ export class PluginManager {
1072
1378
  const permsDeclared = (info.permissions ?? []).slice();
1073
1379
  const strict = permsDeclared.length > 0 || apiVersion >= 2;
1074
1380
  const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
1075
- const p = {
1076
- info,
1077
- toolHandlers,
1078
- runHandlers,
1079
- convChangeHandlers,
1080
- attachHandlers,
1081
- cwdHandlers,
1082
- commandUnsubscribers: unregisterCommands,
1083
- httpRoutes,
1084
- settingsHandlers,
1085
- };
1086
- p.permsDeclared = permsDeclared;
1087
- p.permFamilies = permFamilies;
1088
- p.legacyWarned = false;
1381
+ // 引擎约束(manifest engines["pi-web-ui"]):不满足即拒绝激活(与 apiVersion 超前同级处理)。
1382
+ // 解析失败 → 警告放行不阻断;对等依赖缺失 → 只警告不断活。
1383
+ const enginesReq = info.engines?.["pi-web-ui"];
1384
+ if (typeof enginesReq === "string" && enginesReq.trim()) {
1385
+ const hostVer = readHostVersion();
1386
+ const verdict = hostVer ? satisfiesEnginesConstraint(enginesReq, hostVer) : null;
1387
+ if (verdict === false) {
1388
+ const msg = pick(l, `插件要求 pi-web-ui ${enginesReq},当前宿主版本 ${hostVer} —— 请升级 pi-web-ui`, `Plugin requires pi-web-ui ${enginesReq} but the host is ${hostVer} — please upgrade pi-web-ui`, "plugins.host.engines.mismatch", { enginesReq, hostVer });
1389
+ console.error(`[plugin:${info.id}] ${msg}`);
1390
+ this.loaded.set(info.id, {
1391
+ info: { ...info, error: msg },
1392
+ toolHandlers,
1393
+ runHandlers,
1394
+ convChangeHandlers,
1395
+ attachHandlers,
1396
+ cwdHandlers,
1397
+ httpRoutes,
1398
+ settingsHandlers: new Set(),
1399
+ });
1400
+ return;
1401
+ }
1402
+ if (verdict === null) {
1403
+ console.warn(`[plugin:${info.id}] engines 约束「${enginesReq}」解析失败,已放行(不阻断激活)`);
1404
+ }
1405
+ }
1406
+ for (const peer of info.peerPlugins ?? []) {
1407
+ if (!existsSync(join(this.pluginsDir, peer))) {
1408
+ console.warn(`[plugin:${info.id}] 对等插件缺失:${peer}(仅警告,不阻断激活)`);
1409
+ }
1410
+ }
1411
+ // 新增订阅的取消函数(反激活时经 releaseEntry 统一释放)。
1412
+ const watchSubs = [];
1413
+ const scheduleSubs = [];
1414
+ const busSubs = [];
1415
+ const statsSubs = [];
1416
+ const streamingSubs = [];
1417
+ // 出站网络白名单(scan 解析的 manifest netAllowlist 快照)。
1418
+ const netAllow = info.netAllowlist ?? [];
1089
1419
  // 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
1090
1420
  const storage = new PluginStorage(join(dir, "storage.json"));
1091
1421
  const secrets = new PluginSecrets(this.dataDir, dir);
@@ -1124,22 +1454,32 @@ export class PluginManager {
1124
1454
  await rm(abs, { recursive: true, force: true });
1125
1455
  },
1126
1456
  };
1127
- /** 能力门控:严格模式下查声明族;旧模式放行但每个激活期只警告一次。
1457
+ const self = this; // 对象字面量 getter 里不能用插件宿主的 this (oxlint no-this-alias: 誤報, getter closure 需要 host)
1458
+ // 能力门控快照:host 对象可用之前(loaded.set 之前)canUse 也要能判定。
1459
+ self.activatingGates.set(info.id, { permsDeclared, permFamilies, strictMode: strict, legacyWarned: false });
1460
+ /** 能力门控:统一走 canUse(fs 读写分级/旧全权警告都在里面)。
1128
1461
  * 返回 false = 已记日志,调用方应拒绝。 */
1129
- const can = (family) => {
1130
- if (permFamilies.has(family))
1131
- return true;
1132
- if (!strict) {
1133
- if (!p.legacyWarned) {
1134
- p.legacyWarned = true;
1135
- console.warn(`[plugin:${info.id}] manifest 未声明 permissions(旧格式全权模式)——已放行 "${family}";apiVersion 2 起将默认拒绝,请尽快声明`);
1136
- }
1137
- return true;
1462
+ const can = (family) => self.canUse(info.id, family);
1463
+ /** 读门:"fs" 全开,或精确声明 "fs:read"。 */
1464
+ const canRead = () => self.canUse(info.id, "fs:read");
1465
+ /** 写门:"fs" 全开,或精确声明 "fs:write"(只读插件写即拒,提示缺 fs/fs:write)。 */
1466
+ const canWrite = () => self.canUse(info.id, "fs:write");
1467
+ /** 无头调用落地(host.chat host.chatWait 共用):能力 "chat" 门控 +
1468
+ * 文本校验 + chatProvider 投递。宿主未接入时拒绝(不抛到插件侧,由调用方包 {ok:false})。 */
1469
+ const sendChat = (req) => {
1470
+ if (!can("chat"))
1471
+ return Promise.reject(new Error(`插件未声明能力 "chat"(manifest.permissions)——请求被拒`));
1472
+ if (!self.chatProvider) {
1473
+ return Promise.reject(new Error("宿主未提供无头调用(chatProvider 未接入)——请升级 pi-web-ui"));
1138
1474
  }
1139
- console.error(`[plugin:${info.id}] 缺少能力声明 "${family}"(manifest.permissions)——请求被拒`);
1140
- return false;
1475
+ const text = String(req?.text ?? "").trim();
1476
+ if (!text)
1477
+ return Promise.reject(new Error("chat: text 为空"));
1478
+ if (text.length > 8000)
1479
+ return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
1480
+ const accountId = String(req?.accountId ?? "default").slice(0, 64);
1481
+ return self.chatProvider(info.id, { text, accountId });
1141
1482
  };
1142
- const self = this; // 对象字面量 getter 里不能用插件宿主的 this (oxlint no-this-alias: 誤報, getter closure 需要 host)
1143
1483
  const host = {
1144
1484
  broadcast: (payload) => this.broadcast(info.id, payload),
1145
1485
  notify: (level, text, textEn) => this.notifyAll(level, text, textEn),
@@ -1161,19 +1501,114 @@ export class PluginManager {
1161
1501
  return () => convChangeHandlers.delete(h);
1162
1502
  },
1163
1503
  getActiveConversation: () => self.getActiveConversation(),
1164
- chat: (req) => {
1165
- if (!can("chat"))
1166
- return Promise.reject(new Error(`插件未声明能力 "chat"(manifest.permissions)——请求被拒`));
1167
- if (!self.chatProvider) {
1168
- return Promise.reject(new Error("宿主未提供无头调用(chatProvider 未接入)——请升级 pi-web-ui"));
1504
+ chat: (req) => sendChat(req),
1505
+ chatWait: async (req, opts) => {
1506
+ try {
1507
+ const sent = await sendChat(req);
1508
+ const cid = sent?.conversationId ?? "";
1509
+ if (!cid)
1510
+ return { ok: false, error: "无头调用未返回 conversationId" };
1511
+ // 默认 120s 超时;上下钳制防 100ms 误杀与无限等待。
1512
+ const timeoutMs = Math.max(1000, Math.min(Number(opts?.timeoutMs ?? 120_000) || 120_000, 600_000));
1513
+ const ended = await self.waitRunEnd(cid, timeoutMs);
1514
+ if (!ended)
1515
+ return { ok: false, conversationId: cid, error: `等待运行结束超时(约${Math.round(timeoutMs / 1000)}s)` };
1516
+ return { ok: true, conversationId: cid };
1517
+ }
1518
+ catch (err) {
1519
+ return { ok: false, error: err.message };
1520
+ }
1521
+ },
1522
+ conversations: {
1523
+ list: () => {
1524
+ try {
1525
+ const r = self.conversationLister?.();
1526
+ if (r instanceof Promise) {
1527
+ return r.catch((err) => {
1528
+ console.error(`[plugin:${info.id}] conversationLister failed:`, err);
1529
+ return [];
1530
+ });
1531
+ }
1532
+ return r ?? [];
1533
+ }
1534
+ catch (err) {
1535
+ console.error(`[plugin:${info.id}] conversationLister failed:`, err);
1536
+ return [];
1537
+ }
1538
+ },
1539
+ get: (id) => {
1540
+ try {
1541
+ // conversationProvider 只给当前打开对话:id 对上才回,否则 null。
1542
+ const s = self.getActiveConversation();
1543
+ return s && s.conversationId === String(id) ? s : null;
1544
+ }
1545
+ catch {
1546
+ return null;
1547
+ }
1548
+ },
1549
+ search: (query, limit) => {
1550
+ try {
1551
+ const q = String(query ?? "");
1552
+ const n = Math.max(1, Math.min(Number(limit ?? 20) || 20, 100));
1553
+ if (self.conversationSearcher) {
1554
+ const r = self.conversationSearcher(q, n);
1555
+ if (r instanceof Promise)
1556
+ return r.catch(() => []);
1557
+ return r ?? [];
1558
+ }
1559
+ // 无 searcher 注入时退化为 lister 标题过滤;两者都无回 []。
1560
+ const lq = q.trim().toLowerCase();
1561
+ if (!lq)
1562
+ return [];
1563
+ const pick = (arr) => arr
1564
+ .filter((c) => c.title.toLowerCase().includes(lq))
1565
+ .slice(0, n)
1566
+ .map((c) => ({ id: c.id, title: c.title }));
1567
+ const all = self.conversationLister?.();
1568
+ if (all instanceof Promise)
1569
+ return all.then(pick, () => []);
1570
+ return pick(all ?? []);
1571
+ }
1572
+ catch {
1573
+ return [];
1574
+ }
1575
+ },
1576
+ },
1577
+ prompt: async (conversationId, req) => {
1578
+ try {
1579
+ if (!self.conversationWriter)
1580
+ return { ok: false, error: "宿主未接入对话写入(仅标准 pi 引擎支持)" };
1581
+ const text = String(req?.text ?? "");
1582
+ if (!text.trim())
1583
+ return { ok: false, error: "投递文本为空" };
1584
+ if (text.length > 8000)
1585
+ return { ok: false, error: "投递文本超长(>8000 字),请裁剪后重发" };
1586
+ const atts = Array.isArray(req?.attachments) ? req.attachments.slice(0, 16) : undefined;
1587
+ return await self.conversationWriter(String(conversationId), text, atts);
1588
+ }
1589
+ catch (err) {
1590
+ return { ok: false, error: err.message };
1591
+ }
1592
+ },
1593
+ steer: async (conversationId, text) => {
1594
+ try {
1595
+ if (!self.runSteerer)
1596
+ return { ok: false, error: "宿主未接入运行插队(仅标准 pi 引擎支持)" };
1597
+ return await self.runSteerer(String(conversationId), String(text ?? ""));
1598
+ }
1599
+ catch (err) {
1600
+ return { ok: false, error: err.message };
1601
+ }
1602
+ },
1603
+ abortRun: async (conversationId) => {
1604
+ try {
1605
+ if (!self.runAborter)
1606
+ return { ok: false, error: "宿主未接入运行中止(仅标准 pi 引擎支持)" };
1607
+ return await self.runAborter(String(conversationId));
1608
+ }
1609
+ catch (err) {
1610
+ return { ok: false, error: err.message };
1169
1611
  }
1170
- const text = String(req?.text ?? "").trim();
1171
- if (!text)
1172
- return Promise.reject(new Error("chat: text 为空"));
1173
- if (text.length > 8000)
1174
- return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
1175
- const accountId = String(req?.accountId ?? "default").slice(0, 64);
1176
- return self.chatProvider(info.id, { text, accountId });
1177
1612
  },
1178
1613
  onAttach: (h) => {
1179
1614
  attachHandlers.add(h);
@@ -1229,13 +1664,13 @@ export class PluginManager {
1229
1664
  return self.cwdValue;
1230
1665
  },
1231
1666
  fs: {
1232
- list: (relDir) => (can("fs") ? workspaceFs.list(relDir) : NO_FS_PROMISE),
1233
- read: (p) => (can("fs") ? workspaceFs.read(p) : NO_FS_PROMISE),
1234
- readText: (p, max) => (can("fs") ? workspaceFs.readText(p, max) : NO_FS_PROMISE),
1235
- write: (p, data) => (can("fs") ? workspaceFs.write(p, data) : NO_FS_PROMISE),
1236
- remove: (p) => (can("fs") ? workspaceFs.remove(p) : NO_FS_PROMISE),
1667
+ list: (relDir) => (canRead() ? workspaceFs.list(relDir) : NO_FS_PROMISE),
1668
+ read: (p) => (canRead() ? workspaceFs.read(p) : NO_FS_PROMISE),
1669
+ readText: (p, max) => (canRead() ? workspaceFs.readText(p, max) : NO_FS_PROMISE),
1670
+ write: (p, data) => (canWrite() ? workspaceFs.write(p, data) : NO_FS_WRITE_PROMISE),
1671
+ remove: (p) => (canWrite() ? workspaceFs.remove(p) : NO_FS_WRITE_PROMISE),
1237
1672
  requestAccess: async (dir, reason) => {
1238
- if (!can("fs"))
1673
+ if (!canRead())
1239
1674
  return false;
1240
1675
  const abs = normalizeGrantPath(String(dir ?? ""));
1241
1676
  if (!abs)
@@ -1260,17 +1695,52 @@ export class PluginManager {
1260
1695
  }
1261
1696
  return ok;
1262
1697
  },
1263
- authorizedDirs: () => (can("fs") ? self.grants.get(info.id) : []),
1264
- listPath: (absDir) => (can("fs") ? crossDirFs.list(absDir) : NO_FS_PROMISE),
1265
- readPath: (absPath) => (can("fs") ? crossDirFs.read(absPath) : NO_FS_PROMISE),
1266
- readTextPath: (absPath, max) => (can("fs") ? crossDirFs.readText(absPath, max) : NO_FS_PROMISE),
1267
- writePath: (absPath, data) => (can("fs") ? crossDirFs.write(absPath, data) : NO_FS_PROMISE),
1268
- removePath: (absPath) => (can("fs") ? crossDirFs.remove(absPath) : NO_FS_PROMISE),
1698
+ authorizedDirs: () => (canRead() ? self.grants.get(info.id) : []),
1699
+ listPath: (absDir) => (canRead() ? crossDirFs.list(absDir) : NO_FS_PROMISE),
1700
+ readPath: (absPath) => (canRead() ? crossDirFs.read(absPath) : NO_FS_PROMISE),
1701
+ readTextPath: (absPath, max) => (canRead() ? crossDirFs.readText(absPath, max) : NO_FS_PROMISE),
1702
+ writePath: (absPath, data) => (canWrite() ? crossDirFs.write(absPath, data) : NO_FS_WRITE_PROMISE),
1703
+ removePath: (absPath) => (canWrite() ? crossDirFs.remove(absPath) : NO_FS_WRITE_PROMISE),
1704
+ watch: (relPath, handler) => {
1705
+ if (!canRead())
1706
+ throw new Error('插件未声明读能力 "fs"/"fs:read"(manifest.permissions)——请求被拒');
1707
+ if (typeof handler !== "function")
1708
+ throw new Error("watch: handler 必须是函数");
1709
+ // 锚定活 cwd 根:目标必须在工作区内(复用 WorkspaceFS 的越界校验思想)。
1710
+ const target = resolve(self.cwdValue, String(relPath ?? ""));
1711
+ if (!self.isInsideWorkspace(target))
1712
+ throw new Error(`路径越界:${String(relPath)}`);
1713
+ const watcher = fsWatch(target, (eventType, filename) => {
1714
+ try {
1715
+ handler({ type: String(eventType), path: String(filename ?? relPath) });
1716
+ }
1717
+ catch (err) {
1718
+ console.error(`[plugin:${info.id}] watch handler failed:`, err);
1719
+ }
1720
+ });
1721
+ watcher.on("error", (err) => console.error(`[plugin:${info.id}] watch failed:`, err));
1722
+ const off = () => {
1723
+ try {
1724
+ watcher.close();
1725
+ }
1726
+ catch {
1727
+ /* already closed */
1728
+ }
1729
+ };
1730
+ watchSubs.push(off);
1731
+ return off;
1732
+ },
1269
1733
  },
1270
1734
  project: {
1271
1735
  create: async (spec) => {
1272
- if (!can("fs"))
1273
- return { ok: false, error: "未声明能力 fs(manifest.permissions)", log: [], dir: "" };
1736
+ // 写门:只读插件(仅 fs:read)与无 fs 声明的一律拒绝,错误提示缺 fs/fs:write。
1737
+ if (!canWrite())
1738
+ return {
1739
+ ok: false,
1740
+ error: '缺少写能力:project.create 需要 "fs"/"fs:write"(manifest.permissions)',
1741
+ log: [],
1742
+ dir: "",
1743
+ };
1274
1744
  const dir = normalizeGrantPath(String(spec?.dir ?? ""));
1275
1745
  if (!dir)
1276
1746
  return { ok: false, error: "项目目录必须是绝对路径", log: [], dir: "" };
@@ -1387,6 +1857,231 @@ export class PluginManager {
1387
1857
  settingsHandlers.add(h);
1388
1858
  return () => settingsHandlers.delete(h);
1389
1859
  },
1860
+ scm: {
1861
+ status: async () => {
1862
+ if (!canRead())
1863
+ throw new Error('插件未声明读能力 "fs"/"fs:read"(manifest.permissions)——请求被拒');
1864
+ try {
1865
+ const mod = await import("./scm.js");
1866
+ return await mod.scmStatus(self.cwdValue);
1867
+ }
1868
+ catch (err) {
1869
+ return { ok: false, error: err.message };
1870
+ }
1871
+ },
1872
+ log: async (path, limit) => {
1873
+ if (!canRead())
1874
+ throw new Error('插件未声明读能力 "fs"/"fs:read"(manifest.permissions)——请求被拒');
1875
+ try {
1876
+ const mod = await import("./scm.js");
1877
+ const all = await mod.scmHistory(self.cwdValue);
1878
+ const n = Math.max(1, Math.min(Number(limit ?? 50) || 50, 200));
1879
+ return all.slice(0, n);
1880
+ }
1881
+ catch (err) {
1882
+ return { ok: false, error: err.message };
1883
+ }
1884
+ },
1885
+ },
1886
+ bash: async (cmd, opts) => {
1887
+ // 门控语义沿用 registerAgentTool:无 "tools" 声明即拒绝(结果对象形态,不断路抛错)。
1888
+ if (!can("tools"))
1889
+ return { ok: false, output: "", error: '插件未声明能力 "tools"(manifest.permissions)——请求被拒' };
1890
+ try {
1891
+ const parts = String(cmd ?? "")
1892
+ .trim()
1893
+ .split(/\s+/)
1894
+ .filter(Boolean);
1895
+ const file = parts[0];
1896
+ if (!file)
1897
+ return { ok: false, output: "", error: "bash: cmd 为空" };
1898
+ const cwd = opts?.cwd ? resolve(self.cwdValue, opts.cwd) : self.cwdValue;
1899
+ if (!self.isInsideWorkspace(cwd))
1900
+ return { ok: false, output: "", error: `工作目录越界:${opts?.cwd}` };
1901
+ const timeout = Math.max(1000, Math.min(Number(opts?.timeoutMs ?? 60_000) || 60_000, 600_000));
1902
+ const { stdout, stderr } = await execFileAsync(file, parts.slice(1), {
1903
+ cwd,
1904
+ timeout,
1905
+ windowsHide: true,
1906
+ maxBuffer: 4 * 1024 * 1024,
1907
+ encoding: "utf8",
1908
+ });
1909
+ const output = `${stdout ?? ""}${stderr ? `\n${stderr}` : ""}`.slice(0, 256 * 1024);
1910
+ return { ok: true, output, exitCode: 0 };
1911
+ }
1912
+ catch (err) {
1913
+ const e = err;
1914
+ const out = [typeof e.stdout === "string" ? e.stdout : "", typeof e.stderr === "string" ? e.stderr : ""]
1915
+ .filter(Boolean)
1916
+ .join("\n")
1917
+ .slice(0, 256 * 1024);
1918
+ return {
1919
+ ok: false,
1920
+ output: out,
1921
+ ...(typeof e.code === "number" ? { exitCode: e.code } : {}),
1922
+ error: String(e.message ?? err).slice(0, 2000),
1923
+ };
1924
+ }
1925
+ },
1926
+ schedule: (cronOrMs, fn) => {
1927
+ if (typeof fn !== "function")
1928
+ throw new Error("schedule: fn 必须是函数");
1929
+ let ms;
1930
+ if (typeof cronOrMs === "number") {
1931
+ ms = Math.max(10_000, Math.floor(cronOrMs) || 10_000);
1932
+ }
1933
+ else if (typeof cronOrMs === "string") {
1934
+ const m = cronOrMs.trim().match(/^\*\/(\d+)\s+\*\s+\*\s+\*\s+\*$/);
1935
+ if (!m)
1936
+ throw new Error(`schedule: 不支持的 cron 形状「${cronOrMs}」(仅支持 "*/N * * * *" 分钟步长)`);
1937
+ ms = Math.max(10_000, Number(m[1]) * 60_000);
1938
+ }
1939
+ else {
1940
+ throw new Error("schedule: 参数必须是间隔毫秒数或 cron 字符串");
1941
+ }
1942
+ ms = Math.min(ms, 2_147_483_647); // setInterval 上限(约 24.8 天),防溢出立即触发
1943
+ const timer = setInterval(() => {
1944
+ try {
1945
+ fn();
1946
+ }
1947
+ catch (err) {
1948
+ console.error(`[plugin:${info.id}] scheduled task failed:`, err);
1949
+ }
1950
+ }, ms);
1951
+ timer.unref?.();
1952
+ const off = () => clearInterval(timer);
1953
+ scheduleSubs.push(off);
1954
+ return off;
1955
+ },
1956
+ models: {
1957
+ list: () => {
1958
+ try {
1959
+ const r = self.modelLister?.();
1960
+ if (r instanceof Promise) {
1961
+ return r.catch((err) => {
1962
+ console.error(`[plugin:${info.id}] modelLister failed:`, err);
1963
+ return [];
1964
+ });
1965
+ }
1966
+ return r ?? [];
1967
+ }
1968
+ catch (err) {
1969
+ console.error(`[plugin:${info.id}] modelLister failed:`, err);
1970
+ return [];
1971
+ }
1972
+ },
1973
+ },
1974
+ onStats: (h) => {
1975
+ self.statsHandlers.add(h);
1976
+ const off = () => {
1977
+ self.statsHandlers.delete(h);
1978
+ };
1979
+ statsSubs.push(off);
1980
+ return () => {
1981
+ const i = statsSubs.indexOf(off);
1982
+ if (i >= 0)
1983
+ statsSubs.splice(i, 1);
1984
+ off();
1985
+ };
1986
+ },
1987
+ onStreaming: (h) => {
1988
+ self.streamingHandlers.add(h);
1989
+ const off = () => {
1990
+ self.streamingHandlers.delete(h);
1991
+ };
1992
+ streamingSubs.push(off);
1993
+ return () => {
1994
+ const i = streamingSubs.indexOf(off);
1995
+ if (i >= 0)
1996
+ streamingSubs.splice(i, 1);
1997
+ off();
1998
+ };
1999
+ },
2000
+ net: {
2001
+ fetch: async (url, init) => {
2002
+ if (!can("net"))
2003
+ return { ok: false, error: '插件未声明能力 "net"(manifest.permissions)——请求被拒' };
2004
+ try {
2005
+ const u = new URL(String(url));
2006
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
2007
+ return { ok: false, error: `net: 不支持的协议 ${u.protocol}` };
2008
+ }
2009
+ // 白名单:主机相等或 .后缀匹配;空表即全拒(fail-closed)。
2010
+ const hostname = u.hostname.toLowerCase();
2011
+ const allowed = netAllow.some((entry) => hostname === entry || hostname.endsWith(`.${entry}`));
2012
+ if (!allowed)
2013
+ return { ok: false, error: `net: 主机 ${u.hostname} 不在白名单(manifest.netAllowlist)` };
2014
+ if (init?.body !== undefined && Buffer.byteLength(String(init.body), "utf8") > 1024 * 1024) {
2015
+ return { ok: false, error: "net: body 超过 1MB 上限" };
2016
+ }
2017
+ const res = await globalThis.fetch(String(url), {
2018
+ method: init?.method ?? "GET",
2019
+ ...(init?.headers ? { headers: init.headers } : {}),
2020
+ ...(init?.body !== undefined ? { body: init.body } : {}),
2021
+ signal: AbortSignal.timeout(15_000),
2022
+ });
2023
+ const text = (await res.text()).slice(0, 512 * 1024);
2024
+ return { ok: true, status: res.status, text };
2025
+ }
2026
+ catch (err) {
2027
+ return { ok: false, error: err.message };
2028
+ }
2029
+ },
2030
+ },
2031
+ events: {
2032
+ emit: (topic, payload) => {
2033
+ const t = String(topic ?? "");
2034
+ if (!t)
2035
+ return;
2036
+ // 载荷 JSON 往返截断:4KB 内还原对象,超了取截断后的字符串化形态。
2037
+ let p;
2038
+ try {
2039
+ const s = JSON.stringify(payload);
2040
+ p = s.length > 4096 ? s.slice(0, 4096) : JSON.parse(s);
2041
+ }
2042
+ catch {
2043
+ try {
2044
+ p = String(payload).slice(0, 4096);
2045
+ }
2046
+ catch {
2047
+ p = undefined;
2048
+ }
2049
+ }
2050
+ const ev = { topic: t, from: info.id, payload: p };
2051
+ const handlers = self.busHandlers.get(t);
2052
+ if (!handlers)
2053
+ return;
2054
+ for (const h of [...handlers]) {
2055
+ try {
2056
+ h(ev);
2057
+ }
2058
+ catch (err) {
2059
+ console.error(`[plugin:${info.id}] bus handler failed:`, err);
2060
+ }
2061
+ }
2062
+ },
2063
+ on: (topic, handler) => {
2064
+ const t = String(topic ?? "");
2065
+ if (!t || typeof handler !== "function")
2066
+ return () => { };
2067
+ let set = self.busHandlers.get(t);
2068
+ if (!set)
2069
+ self.busHandlers.set(t, (set = new Set()));
2070
+ set.add(handler);
2071
+ const off = () => {
2072
+ set.delete(handler);
2073
+ if (set.size === 0)
2074
+ self.busHandlers.delete(t);
2075
+ };
2076
+ busSubs.push(off);
2077
+ return () => {
2078
+ const i = busSubs.indexOf(off);
2079
+ if (i >= 0)
2080
+ busSubs.splice(i, 1);
2081
+ off();
2082
+ };
2083
+ },
2084
+ },
1390
2085
  log: (...args) => console.log(`[plugin:${info.id}]`, ...args),
1391
2086
  };
1392
2087
  try {
@@ -1394,6 +2089,8 @@ export class PluginManager {
1394
2089
  // 击穿缓存,让 plugins_reload 后的重新激活能拿到磁盘上的新代码。
1395
2090
  const mod = (await import(pathToFileURL(join(dir, "index.mjs")).href + `?e=${this.epochCounter}`));
1396
2091
  const ret = await mod.default?.activate?.(host);
2092
+ const gateWarned = self.activatingGates.get(info.id)?.legacyWarned;
2093
+ self.activatingGates.delete(info.id);
1397
2094
  this.loaded.set(info.id, {
1398
2095
  info: { ...info },
1399
2096
  deactivate: typeof ret === "function" ? ret : undefined,
@@ -1404,7 +2101,16 @@ export class PluginManager {
1404
2101
  cwdHandlers,
1405
2102
  agentToolUnsubscribers: unregisterTools,
1406
2103
  commandUnsubscribers: unregisterCommands,
2104
+ watchUnsubscribers: watchSubs,
2105
+ scheduleUnsubscribers: scheduleSubs,
2106
+ busUnsubscribers: busSubs,
2107
+ statsUnsubscribers: statsSubs,
2108
+ streamingUnsubscribers: streamingSubs,
1407
2109
  httpRoutes,
2110
+ permsDeclared,
2111
+ permFamilies,
2112
+ strictMode: strict,
2113
+ legacyWarned: gateWarned,
1408
2114
  settingsHandlers,
1409
2115
  });
1410
2116
  console.log(`[plugin:${info.id}] activated (v${info.version ?? "?"})`);
@@ -1415,6 +2121,7 @@ export class PluginManager {
1415
2121
  }
1416
2122
  catch (err) {
1417
2123
  httpRoutes.clear();
2124
+ self.activatingGates.delete(info.id);
1418
2125
  this.loaded.set(info.id, {
1419
2126
  info: { ...info, error: err.message },
1420
2127
  toolHandlers,