@youweichen/pi-web-ui 0.45.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,893 @@
1
+ /**
2
+ * pi-web-ui 插件管理器 —— 可选界面组件的加载与桥接。
3
+ *
4
+ * 一个插件 = <dataDir>/plugins/<id>/ 目录:
5
+ * manifest.json 元数据 { id?, name, version?, description? }(id 缺省取目录名)
6
+ * index.mjs 服务端入口(可选):export default { activate(host) → deactivate? }
7
+ * client/ 前端资源(可选),经 /plugins/<id>/client/* 以静态文件暴露;
8
+ * entry.mjs 视图入口:export default { mount(el, ctx) → cleanup? }
9
+ *
10
+ * 设计要点:
11
+ * - 不装即不存在:目录不在就没有任何协议/UI 痕迹;每次客户端 attach 时重扫目录,
12
+ * 新丢进来的插件无需重启服务即可出现在顶栏(import 只做一次并缓存)。
13
+ * - id 必须匹配 ID_RE,防路径穿越;client 静态服务同样逐段校验。
14
+ * - host 窄接口:broadcast(pluginId, payload) 广播 plugin_data、onMessage 注册
15
+ * 客户端上行处理、dataDir/cwd/log 环境。发送通道由 index.ts 注入(每个 socket
16
+ * 的 send 函数),插件本身不接触 ws。
17
+ * - activate 抛错只标记 error 字段并记日志,绝不影响主进程。
18
+ */
19
+ import { readdir, readFile, stat } from "node:fs/promises";
20
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
+ import { join, resolve, sep } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
24
+ import { createHash } from "node:crypto";
25
+ /** 合法插件 id:字母/数字/下划线/连字符,防路径穿越。 */
26
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
27
+ /** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
28
+ * 插件能拿到明确的「请升级 pi-web-ui」而不是在新接口上莫名 undefined。 */
29
+ export const PLUGIN_API_VERSION = 1;
30
+ /** 消息处理器超时:仅作为不再等待的日志阈值(响应由 handler 自己发出)。 */
31
+ const MESSAGE_TIMEOUT_MS = 30_000;
32
+ /** host.fs 被能力门控拒绝时的共享 rejected promise(类型对齐用)。 */
33
+ const NO_FS_PROMISE = Promise.reject(new Error('插件未声明能力 "fs"(manifest.permissions)——请求被拒'));
34
+ NO_FS_PROMISE.catch(() => { }); // 避免未处理 rejection 噪音;调用方 await 时拿到错误
35
+ // ---------------------------------------------------------------------------
36
+ // 声明式设置 schema(manifest "settings")
37
+ // ---------------------------------------------------------------------------
38
+ const SETTING_TYPES = new Set(["text", "password", "number", "boolean", "select"]);
39
+ /** 解析 manifest.settings → 合法 schema(坏字段跳过,最多 32 个)。 */
40
+ function parseSettingsSchema(raw) {
41
+ if (!Array.isArray(raw))
42
+ return [];
43
+ const out = [];
44
+ for (const f of raw) {
45
+ if (!f || typeof f !== "object")
46
+ continue;
47
+ const o = f;
48
+ const key = typeof o.key === "string" ? o.key.trim() : "";
49
+ const type = typeof o.type === "string" ? o.type : "";
50
+ if (!key || !SETTING_TYPES.has(type) || out.some((x) => x.key === key))
51
+ continue;
52
+ const field = {
53
+ key,
54
+ type: type,
55
+ label: typeof o.label === "string" && o.label ? o.label : key,
56
+ ...(o.default !== undefined ? { default: o.default } : {}),
57
+ ...(typeof o.min === "number" ? { min: o.min } : {}),
58
+ ...(typeof o.max === "number" ? { max: o.max } : {}),
59
+ ...(Array.isArray(o.options)
60
+ ? { options: o.options.filter((x) => typeof x === "string") }
61
+ : {}),
62
+ ...(typeof o.hint === "string" ? { hint: o.hint } : {}),
63
+ };
64
+ out.push(field);
65
+ if (out.length >= 32)
66
+ break;
67
+ }
68
+ return out;
69
+ }
70
+ /** 从 <pluginDir>/storage.json 读 settings 存值,按 schema 并默认值。 */
71
+ function storedSettingsValues(dir, schema) {
72
+ const out = {};
73
+ let stored = {};
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(join(dir, "storage.json"), "utf8"));
76
+ if (parsed && typeof parsed === "object" && parsed.settings && typeof parsed.settings === "object") {
77
+ stored = parsed.settings;
78
+ }
79
+ }
80
+ catch {
81
+ /* 无存储文件 = 全默认 */
82
+ }
83
+ for (const f of schema)
84
+ out[f.key] = stored[f.key] ?? f.default;
85
+ return out;
86
+ }
87
+ /** 校验并写回 settings(storage.json 的 settings 键,原子写);返回错误信息或 null。 */
88
+ function saveSettingsValues(dir, schema, values) {
89
+ const clean = {};
90
+ for (const f of schema) {
91
+ const v = values?.[f.key];
92
+ if (f.type === "number") {
93
+ const n = v === undefined ? Number(f.default ?? 0) : Number(v);
94
+ if (!Number.isFinite(n) || (f.min !== undefined && n < f.min) || (f.max !== undefined && n > f.max)) {
95
+ return { error: `${f.label} 超出范围`, clean };
96
+ }
97
+ clean[f.key] = n;
98
+ }
99
+ else if (f.type === "boolean") {
100
+ clean[f.key] = v === undefined ? Boolean(f.default) : Boolean(v);
101
+ }
102
+ else if (f.type === "select") {
103
+ if (v !== undefined && !f.options?.includes(String(v)))
104
+ return { error: `${f.label} 值非法`, clean };
105
+ clean[f.key] = v === undefined ? f.default : String(v);
106
+ }
107
+ else {
108
+ clean[f.key] = v === undefined ? (f.default ?? "") : String(v);
109
+ }
110
+ }
111
+ try {
112
+ // 保留 storage.json 里其它键(插件自己的数据),只动 settings。
113
+ const file = join(dir, "storage.json");
114
+ let existing = {};
115
+ try {
116
+ existing = JSON.parse(readFileSync(file, "utf8"));
117
+ }
118
+ catch {
119
+ /* 首次 */
120
+ }
121
+ const tmp = `${file}.tmp-${process.pid}`;
122
+ writeFileSync(tmp, JSON.stringify({ ...existing, settings: clean }));
123
+ renameSync(tmp, file);
124
+ }
125
+ catch (err) {
126
+ console.error(`[plugins] settings persist failed (${dir}):`, err);
127
+ }
128
+ return { clean };
129
+ }
130
+ export class PluginManager {
131
+ dataDir;
132
+ loaded = new Map();
133
+ /** 已 import 过但无入口/失败的目录——避免重复 import 与重复报错。 */
134
+ attempted = new Set();
135
+ senders = new Set();
136
+ messageHandlers = new Map();
137
+ /** 插件注册的 AI 工具:pluginId → (name → 定义)。宿主经 agentTools() 读取。 */
138
+ agentTools = new Map();
139
+ /** AI 工具集合变化回调(index.ts 接到 AgentService,把新工具推入活跃会话)。 */
140
+ onAgentToolsChanged = undefined;
141
+ /** 插件斜杠命令注册表:pluginId → (name → 定义)。宿主经 listCommands() 读取。 */
142
+ pluginCommands = new Map();
143
+ /** 命令集合变化回调(index.ts 接到 AgentService,刷新各客户端命令目录)。 */
144
+ onCommandsChanged = undefined;
145
+ /** 插件常驻任务:pluginId → Map<taskId, PluginBgTask>。宿主经 bgTasks() 读取。 */
146
+ pluginBgTasks = new Map();
147
+ /** 任务集合变化回调(index.ts 接到 AgentService,重推 bg_servers)。 */
148
+ onBgTasksChanged = undefined;
149
+ /** 服务端重载纪元:每次 reload() +1,前端用作 import 缓存击穿参数。 */
150
+ epochCounter = 0;
151
+ /** 当前全局工作区(host.cwd 的背后存储)——随 notifyCwd 更新。 */
152
+ cwdValue;
153
+ constructor(dataDir, cwd) {
154
+ this.dataDir = dataDir;
155
+ this.cwdValue = resolve(cwd);
156
+ }
157
+ /** index.ts 在客户端 set_cwd 成功后调用:更新全局工作区并扇出给
158
+ * 所有已激活插件的 onCwdChange 钩子(异常隔离,不炸主进程)。 */
159
+ notifyCwd(next) {
160
+ const abs = resolve(next);
161
+ if (abs === this.cwdValue)
162
+ return; // 幂等:重复通知/同路径 no-op
163
+ this.cwdValue = abs;
164
+ for (const [id, p] of this.loaded) {
165
+ for (const h of p.cwdHandlers) {
166
+ try {
167
+ h(abs);
168
+ }
169
+ catch (err) {
170
+ console.error(`[plugin:${id}] cwd-change handler failed:`, err);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ get pluginsDir() {
176
+ return join(this.dataDir, "plugins");
177
+ }
178
+ /** 全部插件注册的斜杠命令(按插件 id 稳定排序)。 */
179
+ listCommands() {
180
+ const out = [];
181
+ for (const id of [...this.pluginCommands.keys()].sort()) {
182
+ out.push(...this.pluginCommands.get(id).values());
183
+ }
184
+ return out;
185
+ }
186
+ /** 按名查找命令(供 prompt() 拦截执行;找不到返回 null)。 */
187
+ findCommand(name) {
188
+ for (const [pluginId, table] of this.pluginCommands) {
189
+ if (table.has(name))
190
+ return { def: table.get(name), pluginId };
191
+ }
192
+ return null;
193
+ }
194
+ /** 全部插件注册的常驻后台任务(扁平化为 BgServer 形状)。 */
195
+ bgTasks() {
196
+ const out = [];
197
+ for (const [pluginId, table] of this.pluginBgTasks) {
198
+ for (const t of table.values()) {
199
+ out.push({
200
+ taskId: t.id,
201
+ plugin: pluginId,
202
+ since: t.since,
203
+ name: t.label,
204
+ ...(t.status ? { status: t.status } : {}),
205
+ });
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ /** 停止一个插件任务(kill_background_server with taskId);返回是否命中。 */
211
+ stopPluginBgTask(taskId) {
212
+ for (const [pluginId, table] of this.pluginBgTasks) {
213
+ const t = table.get(taskId);
214
+ if (!t)
215
+ continue;
216
+ try {
217
+ t.stop?.();
218
+ }
219
+ catch (err) {
220
+ console.error(`[plugin:${pluginId}] background task ${taskId} stop failed:`, err);
221
+ }
222
+ table.delete(taskId);
223
+ if (table.size === 0)
224
+ this.pluginBgTasks.delete(pluginId);
225
+ try {
226
+ this.onBgTasksChanged?.();
227
+ }
228
+ catch { }
229
+ return true;
230
+ }
231
+ return false;
232
+ }
233
+ /** 保存某插件的声明式设置(⚙ 面板 → plugin_settings 消息):按 schema 校验、
234
+ * 原子写 storage.json 的 settings 键、通知插件 onSettingsChanged、重推清单
235
+ * 让前端回显。返回错误信息或 null(成功)。 */
236
+ savePluginSettings(pluginId, values) {
237
+ if (!ID_RE.test(pluginId))
238
+ return { error: "非法的插件 id" };
239
+ const dir = join(this.pluginsDir, pluginId);
240
+ const info = this.loaded.get(pluginId)?.info;
241
+ const schema = info?.settingsSchema ?? [];
242
+ if (!schema.length)
243
+ return { error: "该插件没有声明式设置(manifest 未声明 settings)" };
244
+ const { error, clean } = saveSettingsValues(dir, schema, values);
245
+ if (error)
246
+ return { error };
247
+ // 通知插件(异常隔离)
248
+ for (const h of this.loaded.get(pluginId)?.settingsHandlers ?? []) {
249
+ try {
250
+ h(clean);
251
+ }
252
+ catch (err) {
253
+ console.error(`[plugin:${pluginId}] onSettingsChanged handler failed:`, err);
254
+ }
255
+ }
256
+ // 重推 plugins 清单(含新 settingsValues),前端回显。
257
+ void this.pushToAll().catch(() => { });
258
+ return {};
259
+ }
260
+ /** 当前重载纪元(随 plugins 消息下发)。 */
261
+ get epoch() {
262
+ return this.epochCounter;
263
+ }
264
+ addSender(send, cid) {
265
+ const s = { cid, send };
266
+ this.senders.add(s);
267
+ return () => this.senders.delete(s);
268
+ }
269
+ /** 客户端上行:路由给对应插件的处理器;未知/未激活的插件静默丢弃。
270
+ * 插件代码不可信——同步抛错与返回的 Promise rejection 都必须隔离在
271
+ * 这里,绝不能炸主进程。 */
272
+ handleMessage(pluginId, payload, from) {
273
+ if (!ID_RE.test(pluginId))
274
+ return;
275
+ const handlers = this.messageHandlers.get(pluginId);
276
+ if (!handlers)
277
+ return;
278
+ for (const h of handlers) {
279
+ try {
280
+ const ret = h(payload, from);
281
+ if (ret instanceof Promise) {
282
+ ret.catch((err) => {
283
+ console.error(`[plugin:${pluginId}] async message handler failed:`, err);
284
+ });
285
+ // 超时护栏:响应由 handler 自己 sendTo/broadcast 发出,超时只是记
286
+ // 日志不再等待——绝不能让单条消息把客户端 pending 管线无限拖死。
287
+ const timer = setTimeout(() => {
288
+ console.error(`[plugin:${pluginId}] message handler 超时(>${MESSAGE_TIMEOUT_MS}ms),已不再等待`);
289
+ }, MESSAGE_TIMEOUT_MS);
290
+ void ret.finally(() => clearTimeout(timer));
291
+ }
292
+ }
293
+ catch (err) {
294
+ console.error(`[plugin:${pluginId}] message handler failed:`, err);
295
+ }
296
+ }
297
+ }
298
+ /** 首次安装/能力变更时提醒在线用户(marker 文件记录上次激活时的声明)。 */
299
+ async maybeConsentNotice(info, dir, perms) {
300
+ try {
301
+ const markerFile = join(dir, ".pi-approved");
302
+ const key = createHash("sha256").update(JSON.stringify(perms)).digest("hex").slice(0, 32);
303
+ let prev = "";
304
+ try {
305
+ prev = JSON.parse(readFileSync(markerFile, "utf8"))?.key ?? "";
306
+ }
307
+ catch {
308
+ /* 无 marker = 首次安装 */
309
+ }
310
+ if (prev === key)
311
+ return; // 同版本能力清单,不再打扰
312
+ const list = perms.length ? perms.join(", ") : "无";
313
+ this.notifyAll(perms.length ? "warning" : "info", `插件「${info.name}」已激活(${prev ? "能力清单变更" : "首次安装"};声明能力:${list})——请确认来源可信`);
314
+ writeFileSync(markerFile, JSON.stringify({ v: 1, key, perms }), "utf8");
315
+ }
316
+ catch (err) {
317
+ console.error(`[plugin:${info.id}] consent notice failed:`, err);
318
+ }
319
+ }
320
+ /** index.ts 的 /plugins-api/:id/* 挂载点转发到这里:找到对应插件的已注册
321
+ * 路由并执行;未知插件/路径 → 404,handler 抛错 → 500(不炸进程)。 */
322
+ handleHttp(pluginId, method, pathIn, req, res) {
323
+ if (!ID_RE.test(pluginId)) {
324
+ res.status(404).end("plugin not found");
325
+ return;
326
+ }
327
+ const table = this.loaded.get(pluginId)?.httpRoutes;
328
+ const path = "/" + pathIn.replace(/^\/+/, "");
329
+ const handler = table?.get(`${method.toUpperCase()} ${path}`);
330
+ if (!handler) {
331
+ res.status(404).end("not found");
332
+ return;
333
+ }
334
+ try {
335
+ handler(req, res);
336
+ }
337
+ catch (err) {
338
+ console.error(`[plugin:${pluginId}] http ${method} ${path} failed:`, err);
339
+ if (!res.headersSent)
340
+ res.status(500).end("internal error");
341
+ else
342
+ res.end();
343
+ }
344
+ }
345
+ broadcast(pluginId, payload) {
346
+ this.deliverAll({ type: "plugin_data", pluginId, payload });
347
+ }
348
+ /** 系统通知:发给所有 socket(复用 notice 消息,前端 toast 展示)。 */
349
+ notifyAll(level, text) {
350
+ this.deliverAll({ type: "notice", level, text });
351
+ }
352
+ /** 给指定客户端定向发一条插件消息;找不到该 socket 时静默忽略。 */
353
+ sendTo(clientId, pluginId, payload) {
354
+ for (const s of this.senders) {
355
+ if (s.cid() !== clientId)
356
+ continue;
357
+ try {
358
+ s.send({ type: "plugin_data", pluginId, payload });
359
+ }
360
+ catch {
361
+ /* dead socket */
362
+ }
363
+ }
364
+ }
365
+ /** 目录清单 + 当前 epoch 推给所有 socket。 */
366
+ async pushToAll() {
367
+ const list = await this.scan();
368
+ this.deliverAll({ type: "plugins", plugins: list, epoch: this.epochCounter });
369
+ }
370
+ /** 服务端热重载:反激活全部 → 清缓存 → 重扫重激活 → epoch+1。
371
+ * 返回新目录清单(含激活结果)。重激活后的插件实例是新模块,
372
+ * 内存状态为初始值——逐个客户端触发 onAttach 让它们重推自身状态。 */
373
+ async reload() {
374
+ this.dispose();
375
+ this.attempted.clear();
376
+ this.epochCounter += 1;
377
+ const list = await this.ensureLoaded();
378
+ for (const s of this.senders) {
379
+ const cid = s.cid();
380
+ if (cid)
381
+ this.notifyAttach(cid);
382
+ }
383
+ return list;
384
+ }
385
+ /** 每个客户端 attach 后调用:让各插件向该客户端推送自身完整状态。
386
+ * 异常隔离——单个插件钩子报错不影响其他插件与其他钩子。 */
387
+ notifyAttach(clientId) {
388
+ for (const [id, p] of this.loaded) {
389
+ for (const h of p.attachHandlers) {
390
+ try {
391
+ h(clientId);
392
+ }
393
+ catch (err) {
394
+ console.error(`[plugin:${id}] onAttach handler failed:`, err);
395
+ }
396
+ }
397
+ }
398
+ }
399
+ /** agent-service 调:把 SDK 工具执行事件扇出给所有插件(异常隔离)。 */
400
+ emitToolEvent(ev) {
401
+ for (const p of this.loaded.values()) {
402
+ for (const h of p.toolHandlers) {
403
+ try {
404
+ h(ev);
405
+ }
406
+ catch (err) {
407
+ console.error(`[plugin:${p.info.id}] tool-event handler failed:`, err);
408
+ }
409
+ }
410
+ }
411
+ }
412
+ /** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序)。 */
413
+ getAgentTools() {
414
+ const out = [];
415
+ for (const table of [...this.agentTools.values()].sort())
416
+ out.push(...table.values());
417
+ return out;
418
+ }
419
+ /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。 */
420
+ registerAgentTool(pluginId, tool) {
421
+ if (!tool || typeof tool.execute !== "function" || !tool.name || !tool.description) {
422
+ console.error(`[plugin:${pluginId}] registerAgentTool: 缺少 name/description/execute,忽略`);
423
+ return () => { };
424
+ }
425
+ let table = this.agentTools.get(pluginId);
426
+ if (!table)
427
+ this.agentTools.set(pluginId, (table = new Map()));
428
+ if (table.has(tool.name)) {
429
+ console.error(`[plugin:${pluginId}] AI 工具 "${tool.name}" 重复注册,忽略`);
430
+ return () => { };
431
+ }
432
+ table.set(tool.name, tool);
433
+ console.log(`[plugin:${pluginId}] registered AI tool: ${tool.name}`);
434
+ try {
435
+ this.onAgentToolsChanged?.();
436
+ }
437
+ catch (err) {
438
+ console.error("[plugins] onAgentToolsChanged failed:", err);
439
+ }
440
+ return () => {
441
+ if (table.delete(tool.name)) {
442
+ if (table.size === 0)
443
+ this.agentTools.delete(pluginId);
444
+ try {
445
+ this.onAgentToolsChanged?.();
446
+ }
447
+ catch {
448
+ /* shutting down */
449
+ }
450
+ }
451
+ };
452
+ }
453
+ /** 注册斜杠命令:跨插件重名拒绝(先注册者胜出),onCommandsChanged 通知目录刷新。 */
454
+ registerCommand(pluginId, cmd) {
455
+ const name = String(cmd?.name ?? "").replace(/^\/+/, ""); // 容忍误带的前导 /
456
+ if (!/^[a-zA-Z][a-zA-Z0-9:_-]*$/.test(name)) {
457
+ console.error(`[plugin:${pluginId}] registerCommand: 非法名称「${cmd?.name}」(需字母开头,允许字母数字:_-),忽略`);
458
+ return () => { };
459
+ }
460
+ if (typeof cmd?.run !== "function") {
461
+ console.error(`[plugin:${pluginId}] registerCommand: ${name} 缺少 run,忽略`);
462
+ return () => { };
463
+ }
464
+ for (const [pid, table] of this.pluginCommands) {
465
+ if (table.has(name) && pid !== pluginId) {
466
+ console.error(`[plugin:${pluginId}] 命令 /${name} 已被插件 ${pid} 注册,忽略重复`);
467
+ return () => { };
468
+ }
469
+ }
470
+ let table = this.pluginCommands.get(pluginId);
471
+ if (!table)
472
+ this.pluginCommands.set(pluginId, (table = new Map()));
473
+ if (table.has(name)) {
474
+ console.error(`[plugin:${pluginId}] 命令 /${name} 重复注册,忽略`);
475
+ return () => { };
476
+ }
477
+ const def = { ...cmd, name };
478
+ table.set(name, def);
479
+ console.log(`[plugin:${pluginId}] registered command: /${name}`);
480
+ try {
481
+ this.onCommandsChanged?.();
482
+ }
483
+ catch (err) {
484
+ console.error("[plugins] onCommandsChanged failed:", err);
485
+ }
486
+ return () => {
487
+ if (table.delete(name)) {
488
+ if (table.size === 0)
489
+ this.pluginCommands.delete(pluginId);
490
+ try {
491
+ this.onCommandsChanged?.();
492
+ }
493
+ catch {
494
+ /* shutting down */
495
+ }
496
+ }
497
+ };
498
+ }
499
+ deliverAll(msg) {
500
+ for (const s of this.senders) {
501
+ try {
502
+ s.send(msg);
503
+ }
504
+ catch {
505
+ /* dead socket — index.ts cleans it up */
506
+ }
507
+ }
508
+ }
509
+ /** 当前目录清单(重扫 manifest,不重新 import)。 */
510
+ async list() {
511
+ return this.scan();
512
+ }
513
+ /**
514
+ * attach 时调用:重扫目录 + 激活尚未加载的新插件。
515
+ * 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
516
+ */
517
+ async ensureLoaded() {
518
+ const found = await this.scan();
519
+ for (const info of found) {
520
+ if (this.loaded.has(info.id) || this.attempted.has(info.id))
521
+ continue;
522
+ if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
523
+ continue; // 纯前端插件
524
+ await this.activate(info);
525
+ }
526
+ // 已被删除的插件:调用 deactivate 并移出缓存
527
+ for (const [id, p] of [...this.loaded]) {
528
+ if (!found.some((f) => f.id === id)) {
529
+ this.deactivateEntry(id, p);
530
+ }
531
+ }
532
+ return found.map((f) => this.loaded.get(f.id)?.info ?? f);
533
+ }
534
+ /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。 */
535
+ deactivateEntry(id, p) {
536
+ try {
537
+ p.deactivate?.();
538
+ }
539
+ catch (err) {
540
+ console.error(`[plugin:${id}] deactivate failed:`, err);
541
+ }
542
+ for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
543
+ try {
544
+ off();
545
+ }
546
+ catch {
547
+ /* already gone */
548
+ }
549
+ }
550
+ this.loaded.delete(id);
551
+ this.messageHandlers.delete(id);
552
+ console.log(`[plugin:${id}] removed`);
553
+ }
554
+ /** 关机时反激活全部插件。 */
555
+ dispose() {
556
+ for (const [id, p] of this.loaded) {
557
+ try {
558
+ p.deactivate?.();
559
+ }
560
+ catch (err) {
561
+ console.error(`[plugin:${id}] deactivate failed:`, err);
562
+ }
563
+ for (const off of [...(p.agentToolUnsubscribers ?? []), ...(p.commandUnsubscribers ?? [])]) {
564
+ try {
565
+ off();
566
+ }
567
+ catch {
568
+ /* shutting down */
569
+ }
570
+ }
571
+ // 反激活时停掉它注册的常驻后台任务(轮询器等),不留孤儿计时器。
572
+ for (const t of this.pluginBgTasks.get(id)?.values() ?? []) {
573
+ try {
574
+ t.stop?.();
575
+ }
576
+ catch { }
577
+ }
578
+ }
579
+ this.pluginBgTasks.clear();
580
+ this.loaded.clear();
581
+ this.messageHandlers.clear();
582
+ }
583
+ /** 读 manifest 清单;坏目录(无 manifest/id 非法)直接跳过。 */
584
+ async scan() {
585
+ let names;
586
+ try {
587
+ names = await readdir(this.pluginsDir);
588
+ }
589
+ catch {
590
+ return []; // 目录不存在 = 没装任何插件
591
+ }
592
+ const out = [];
593
+ for (const name of names.sort()) {
594
+ if (!ID_RE.test(name))
595
+ continue;
596
+ const dir = join(this.pluginsDir, name);
597
+ try {
598
+ if (!(await stat(dir)).isDirectory())
599
+ continue;
600
+ const raw = await readFile(join(dir, "manifest.json"), "utf8");
601
+ const m = JSON.parse(raw);
602
+ out.push({
603
+ id: name,
604
+ name: typeof m.name === "string" && m.name ? m.name : name,
605
+ version: typeof m.version === "string" ? m.version : undefined,
606
+ description: typeof m.description === "string" ? m.description : undefined,
607
+ icon: typeof m.icon === "string" && m.icon.trim() ? m.icon.trim() : undefined,
608
+ hasClient: existsSync(join(dir, "client", "entry.mjs")),
609
+ error: this.loaded.get(name)?.info.error,
610
+ // manifest 声明的能力清单(fs/net/tools…)——设置面板展示用
611
+ permissions: Array.isArray(m.permissions)
612
+ ? m.permissions.filter((p) => typeof p === "string" && p.length > 0).slice(0, 16)
613
+ : undefined,
614
+ // 声明式设置 schema + 当前存值(⚙ 面板自动渲染表单用)
615
+ settingsSchema: parseSettingsSchema(m.settings),
616
+ settingsValues: storedSettingsValues(dir, parseSettingsSchema(m.settings)),
617
+ // 安装来源(pi-web-ui install 写入的 .pi-source.json)——
618
+ // 设置面板据此显示「更新」按钮;手工拷入的插件没有此文件。
619
+ source: await readFile(join(dir, ".pi-source.json"), "utf8")
620
+ .then((raw) => {
621
+ try {
622
+ const s = JSON.parse(raw);
623
+ return typeof s.source === "string" && s.source ? s.source : undefined;
624
+ }
625
+ catch {
626
+ return undefined;
627
+ }
628
+ })
629
+ .catch(() => undefined),
630
+ });
631
+ }
632
+ catch {
633
+ continue; // 无 manifest / JSON 坏 —— 不是插件
634
+ }
635
+ }
636
+ return out;
637
+ }
638
+ async activate(info) {
639
+ this.attempted.add(info.id);
640
+ const dir = join(this.pluginsDir, info.id);
641
+ const handlers = new Set();
642
+ this.messageHandlers.set(info.id, handlers);
643
+ const toolHandlers = new Set();
644
+ const attachHandlers = new Set();
645
+ const cwdHandlers = new Set();
646
+ const httpRoutes = new Map();
647
+ const unregisterTools = [];
648
+ const unregisterCommands = [];
649
+ const bgTaskTable = new Map();
650
+ const settingsHandlers = new Set();
651
+ // 宿主 API 版本协商:插件要的比宿主新 → 明确拒绝(而不是让它在运行期
652
+ // 撞 undefined 接口莫名其妙地坏)。与激活失败同一处理:error 字段 + 置灰。
653
+ let apiVersion = 1;
654
+ try {
655
+ apiVersion = Number(JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8")).apiVersion ?? 1) || 1;
656
+ }
657
+ catch { }
658
+ if (apiVersion > PLUGIN_API_VERSION) {
659
+ const msg = `插件要求宿主 API v${apiVersion},当前宿主 v${PLUGIN_API_VERSION} —— 请升级 pi-web-ui`;
660
+ console.error(`[plugin:${info.id}] ${msg}`);
661
+ this.loaded.set(info.id, { info: { ...info, error: msg }, toolHandlers, attachHandlers, cwdHandlers, httpRoutes, settingsHandlers: new Set() });
662
+ return;
663
+ }
664
+ // 能力声明:写了 permissions → 严格模式(受控宿主 API 按声明族强制执行);
665
+ // 未写且 apiVersion < 2 → 旧全权模式(首次使用受控 API 时警告一次,v2 起默认拒绝)。
666
+ const permsDeclared = (info.permissions ?? []).slice();
667
+ const strict = permsDeclared.length > 0 || apiVersion >= 2;
668
+ const permFamilies = new Set(permsDeclared.map((x) => x.split(":")[0]));
669
+ const p = { info, toolHandlers, attachHandlers, cwdHandlers, commandUnsubscribers: unregisterCommands, httpRoutes, settingsHandlers };
670
+ p.permsDeclared = permsDeclared;
671
+ p.permFamilies = permFamilies;
672
+ p.legacyWarned = false;
673
+ // 每插件的私有设施:KV 存储 + 加密 secrets + 依赖自动补装(单飞)。
674
+ const storage = new PluginStorage(join(dir, "storage.json"));
675
+ const secrets = new PluginSecrets(this.dataDir, dir);
676
+ // 受限工作区文件访问(能力 "fs" 门控;根随 set_cwd 活值移动)。
677
+ const workspaceFs = new WorkspaceFS(() => self.cwdValue);
678
+ /** 能力门控:严格模式下查声明族;旧模式放行但每个激活期只警告一次。
679
+ * 返回 false = 已记日志,调用方应拒绝。 */
680
+ const can = (family) => {
681
+ if (permFamilies.has(family))
682
+ return true;
683
+ if (!strict) {
684
+ if (!p.legacyWarned) {
685
+ p.legacyWarned = true;
686
+ console.warn(`[plugin:${info.id}] manifest 未声明 permissions(旧格式全权模式)——已放行 "${family}";apiVersion 2 起将默认拒绝,请尽快声明`);
687
+ }
688
+ return true;
689
+ }
690
+ console.error(`[plugin:${info.id}] 缺少能力声明 "${family}"(manifest.permissions)——请求被拒`);
691
+ return false;
692
+ };
693
+ const self = this; // 对象字面量 getter 里不能用插件宿主的 this
694
+ const host = {
695
+ broadcast: (payload) => this.broadcast(info.id, payload),
696
+ notify: (level, text) => this.notifyAll(level, text),
697
+ sendTo: (clientId, payload) => this.sendTo(clientId, info.id, payload),
698
+ onMessage: (h) => {
699
+ handlers.add(h);
700
+ return () => handlers.delete(h);
701
+ },
702
+ onToolEvent: (h) => {
703
+ toolHandlers.add(h);
704
+ return () => toolHandlers.delete(h);
705
+ },
706
+ onAttach: (h) => {
707
+ attachHandlers.add(h);
708
+ return () => attachHandlers.delete(h);
709
+ },
710
+ onCwdChange: (h) => {
711
+ cwdHandlers.add(h);
712
+ return () => cwdHandlers.delete(h);
713
+ },
714
+ registerCommand: (cmd) => {
715
+ const off = this.registerCommand(info.id, cmd);
716
+ unregisterCommands.push(off);
717
+ return () => {
718
+ const i = unregisterCommands.indexOf(off);
719
+ if (i >= 0)
720
+ unregisterCommands.splice(i, 1);
721
+ off();
722
+ };
723
+ },
724
+ storage,
725
+ secrets,
726
+ ensureDeps: (specs, opts) => ensurePluginDeps(dir, specs ?? [], opts?.onProgress),
727
+ route: (method, path, handler) => {
728
+ if (!can("http"))
729
+ return () => { };
730
+ const m = String(method ?? "GET").toUpperCase();
731
+ if (!["GET", "POST", "PUT", "DELETE"].includes(m) || typeof path !== "string" || !path.startsWith("/") || typeof handler !== "function") {
732
+ console.error(`[plugin:${info.id}] route: 非法参数(method=${method} path=${path}),忽略`);
733
+ return () => { };
734
+ }
735
+ httpRoutes.set(`${m} ${path}`, handler);
736
+ return () => httpRoutes.delete(`${m} ${path}`);
737
+ },
738
+ // 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
739
+ registerAgentTool: (tool) => {
740
+ if (!can("tools"))
741
+ return () => { };
742
+ const off = this.registerAgentTool(info.id, tool);
743
+ unregisterTools.push(off);
744
+ return () => {
745
+ const i = unregisterTools.indexOf(off);
746
+ if (i >= 0)
747
+ unregisterTools.splice(i, 1);
748
+ off();
749
+ };
750
+ },
751
+ dir,
752
+ dataDir: this.dataDir,
753
+ get cwd() {
754
+ return self.cwdValue;
755
+ },
756
+ fs: {
757
+ list: (relDir) => (can("fs") ? workspaceFs.list(relDir) : NO_FS_PROMISE),
758
+ read: (p) => (can("fs") ? workspaceFs.read(p) : NO_FS_PROMISE),
759
+ readText: (p, max) => (can("fs") ? workspaceFs.readText(p, max) : NO_FS_PROMISE),
760
+ write: (p, data) => (can("fs") ? workspaceFs.write(p, data) : NO_FS_PROMISE),
761
+ remove: (p) => (can("fs") ? workspaceFs.remove(p) : NO_FS_PROMISE),
762
+ },
763
+ registerBackgroundTask: (task) => {
764
+ const id = String(task?.id ?? "").trim();
765
+ if (!id || bgTaskTable.has(id)) {
766
+ console.error(`[plugin:${info.id}] registerBackgroundTask: 非法/重复 id「${task?.id}」,忽略`);
767
+ return { update: () => { }, unregister: () => { } };
768
+ }
769
+ const entry = {
770
+ id,
771
+ label: String(task?.label ?? id),
772
+ since: Date.now(),
773
+ ...(typeof task?.stop === "function" ? { stop: task.stop } : {}),
774
+ ...(typeof task?.status === "string" ? { status: task.status } : {}),
775
+ };
776
+ bgTaskTable.set(id, entry);
777
+ this.pluginBgTasks.set(info.id, bgTaskTable);
778
+ const fire = () => {
779
+ try {
780
+ this.onBgTasksChanged?.();
781
+ }
782
+ catch { }
783
+ };
784
+ fire();
785
+ return {
786
+ update: (next) => {
787
+ if (!bgTaskTable.has(id))
788
+ return;
789
+ if (next.label !== undefined)
790
+ entry.label = String(next.label);
791
+ if (next.status !== undefined)
792
+ entry.status = next.status;
793
+ if (typeof next.stop === "function")
794
+ entry.stop = next.stop;
795
+ fire();
796
+ },
797
+ unregister: () => {
798
+ if (bgTaskTable.delete(id)) {
799
+ if (bgTaskTable.size === 0)
800
+ this.pluginBgTasks.delete(info.id);
801
+ fire();
802
+ }
803
+ },
804
+ };
805
+ },
806
+ getSettings: () => storedSettingsValues(dir, info.settingsSchema ?? []),
807
+ onSettingsChanged: (h) => {
808
+ settingsHandlers.add(h);
809
+ return () => settingsHandlers.delete(h);
810
+ },
811
+ log: (...args) => console.log(`[plugin:${info.id}]`, ...args),
812
+ };
813
+ try {
814
+ // Node 对同一 URL 的 import() 永远返回缓存模块——追加 epoch 作查询串
815
+ // 击穿缓存,让 plugins_reload 后的重新激活能拿到磁盘上的新代码。
816
+ const mod = (await import(pathToFileURL(join(dir, "index.mjs")).href + `?e=${this.epochCounter}`));
817
+ const ret = await mod.default?.activate?.(host);
818
+ this.loaded.set(info.id, {
819
+ info: { ...info },
820
+ deactivate: typeof ret === "function" ? ret : undefined,
821
+ toolHandlers,
822
+ attachHandlers,
823
+ cwdHandlers,
824
+ agentToolUnsubscribers: unregisterTools,
825
+ commandUnsubscribers: unregisterCommands,
826
+ httpRoutes,
827
+ settingsHandlers,
828
+ });
829
+ console.log(`[plugin:${info.id}] activated (v${info.version ?? "?"})`);
830
+ // 首次安装/能力变更提醒(尽力而为):<dir>/.pi-approved 记录上次激活时
831
+ // 的能力清单——新装或 permissions 变更后向在线客户端推一条警告通知,
832
+ // 用户装前可见、日常启动不打扰。
833
+ void this.maybeConsentNotice(info, dir, permsDeclared);
834
+ }
835
+ catch (err) {
836
+ httpRoutes.clear();
837
+ this.loaded.set(info.id, {
838
+ info: { ...info, error: err.message },
839
+ toolHandlers,
840
+ attachHandlers,
841
+ cwdHandlers,
842
+ httpRoutes,
843
+ settingsHandlers: new Set(),
844
+ });
845
+ console.error(`[plugin:${info.id}] activate failed:`, err);
846
+ }
847
+ }
848
+ }
849
+ /**
850
+ * 把 /plugins/:id/client/<rest> 安全映射到 <pluginsDir>/<id>/client/<rest>。
851
+ * 返回绝对路径;任何越界/非法 id 返回 null(调用方回 404)。
852
+ */
853
+ export function resolvePluginClientFile(pluginsDir, id, rest) {
854
+ if (!ID_RE.test(id))
855
+ return null;
856
+ const root = resolve(join(pluginsDir, id, "client"));
857
+ // rest 由 express 路由保证不带 "..",但双保险:resolve 后必须仍在 root 内
858
+ const abs = resolve(root, rest);
859
+ if (abs !== root && !abs.startsWith(root + sep))
860
+ return null;
861
+ return abs;
862
+ }
863
+ /**
864
+ * 把插件 AI 工具定义同步进一个「会话状对象」(SDK AgentSession 的结构子集:
865
+ * 内部 _customTools 数组 + _refreshToolRegistry()——refresh 会重读数组,且新
866
+ * 工具名自动加入活跃集)。新增/更新/移除三向 diff;对象不兼容(SDK 改名)返回
867
+ * null 由调用方静默降级。返回新的已注入名单。
868
+ *
869
+ * 纯函数、不 import SDK —— vitest 直接测(tests/unit/plugin-tools.test.ts)。
870
+ */
871
+ export function syncPluginToolsIntoSession(session, defs, prevNames) {
872
+ if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
873
+ return null;
874
+ const byName = new Map(session._customTools.map((d) => [d.name, d]));
875
+ let changed = false;
876
+ for (const d of defs) {
877
+ if (byName.get(d.name) !== d) {
878
+ byName.set(d.name, d);
879
+ changed = true;
880
+ }
881
+ }
882
+ for (const name of prevNames) {
883
+ if (!defs.some((d) => d.name === name) && byName.has(name)) {
884
+ byName.delete(name);
885
+ changed = true;
886
+ }
887
+ }
888
+ if (!changed)
889
+ return new Set(defs.map((d) => d.name));
890
+ session._customTools = [...byName.values()];
891
+ session._refreshToolRegistry();
892
+ return new Set(defs.map((d) => d.name));
893
+ }