pi-web-ui 0.32.0 → 0.34.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.
@@ -97,8 +97,31 @@ export class ClientStateStore {
97
97
  { path: cwd, lastUsed: now },
98
98
  ...state.projects.filter((p) => p.path !== cwd),
99
99
  ].slice(0, 30);
100
+ // Opening the workspace again clears its removal tombstone.
101
+ if (state.removedProjects?.length) {
102
+ state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
103
+ }
104
+ this.save();
105
+ }
106
+ /** Drop one workspace from the recent-project list (user-requested removal).
107
+ * Records a tombstone too: pushProjects() re-discovers cwds from session
108
+ * files on every listing, so without it the entry would instantly reappear. */
109
+ removeProject(clientId, cwd) {
110
+ const all = this.load();
111
+ const state = (all[clientId] ??= { projects: [] });
112
+ state.projects = state.projects.filter((p) => p.path !== cwd);
113
+ if (state.lastCwd === cwd)
114
+ delete state.lastCwd;
115
+ const removed = new Set(state.removedProjects ?? []);
116
+ removed.add(cwd);
117
+ state.removedProjects = [...removed];
100
118
  this.save();
101
119
  }
120
+ /** Tombstoned projects (explicitly removed by the user) for filtering the
121
+ * merged recent-project list. */
122
+ getRemovedProjects(clientId) {
123
+ return this.load()[clientId]?.removedProjects ?? [];
124
+ }
102
125
  /** Last-used goal/review prefs for a client, or undefined if never set. */
103
126
  getGoalPrefs(clientId) {
104
127
  const s = this.load()[clientId];
@@ -152,12 +175,15 @@ export class ClientStateStore {
152
175
  disabledSkills: s?.settings?.disabledSkills ?? [],
153
176
  disabledExtensions: s?.settings?.disabledExtensions ?? [],
154
177
  terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
178
+ terminalBash: s?.settings?.terminalBash ?? false,
179
+ terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
155
180
  visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
156
181
  visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
157
182
  visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
158
183
  visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
159
184
  reviewPrompt: s?.settings?.reviewPrompt ?? "",
160
185
  reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
186
+ disabledPlugins: s?.settings?.disabledPlugins ?? [],
161
187
  };
162
188
  }
163
189
  /** Persist the client's settings-panel state (partial merge). */
@@ -171,6 +197,8 @@ export class ClientStateStore {
171
197
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
172
198
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
173
199
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
200
+ terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
201
+ terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
174
202
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
175
203
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
176
204
  visionBridgePromptMode: settings.visionBridgePromptMode ??
@@ -179,6 +207,7 @@ export class ClientStateStore {
179
207
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
180
208
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
181
209
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
210
+ disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
182
211
  };
183
212
  this.save();
184
213
  }
@@ -35,6 +35,7 @@ import { startControlServer } from "./control-socket.js";
35
35
  import { scheduleUploadCleanup } from "./uploads.js";
36
36
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
37
37
  import { listThemes, resolveThemeFile } from "./themes.js";
38
+ import { PluginManager, resolvePluginClientFile } from "./plugins.js";
38
39
  const PORT = Number(process.env.PORT ?? 8787);
39
40
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
40
41
  const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
@@ -209,6 +210,30 @@ app.get("/themes/:id.css", (req, res) => {
209
210
  res.setHeader("Cache-Control", "no-cache");
210
211
  res.sendFile(file);
211
212
  });
213
+ // Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
214
+ // /plugins/<id>/client/* so the frontend can import() plugin views. Only the
215
+ // client/ subtree is exposed — manifest.json and the server-side index.mjs
216
+ // (which may hold credentials) never leave the machine. Registered BEFORE the
217
+ // SPA catch-all below.
218
+ const PLUGINS_DIR = join(DATA_DIR, "plugins");
219
+ app.get("/plugins/:id/client/*", (req, res) => {
220
+ // express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
221
+ const rest = String(req.params[0] ?? "");
222
+ const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
223
+ if (!abs) {
224
+ res.status(404).end("plugin not found");
225
+ return;
226
+ }
227
+ // .mjs 常不在老 mime 表里,手动定 Content-Type 保证 import() 可用
228
+ if (/\.(mjs|js)$/.test(abs)) {
229
+ res.setHeader("Content-Type", "text/javascript; charset=utf-8");
230
+ }
231
+ res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
232
+ res.sendFile(abs, (err) => {
233
+ if (err && !res.headersSent)
234
+ res.status(err.statusCode === 404 ? 404 : 500).end("not found");
235
+ });
236
+ });
212
237
  /** Set in the env of the replacement child spawned by a self-update restart. */
213
238
  const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
214
239
  const webDist = join(pkgRoot, "web", "dist");
@@ -340,6 +365,15 @@ const heartbeatTimer = setInterval(() => {
340
365
  const service = new AgentService(CWD,
341
366
  // Per-client persisted UI state: last-used workspace + recent projects.
342
367
  join(DATA_DIR, "client-state.json"));
368
+ // Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
369
+ // attach so freshly dropped plugins appear without a server restart.
370
+ const pluginMgr = new PluginManager(DATA_DIR, CWD);
371
+ // 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
372
+ service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
373
+ // 插件扩展点:插件注册的 AI 工具(registerAgentTool)→ 会话创建时带上 +
374
+ // 变化时动态注入/移除已有会话。
375
+ service.pluginToolsProvider = () => pluginMgr.getAgentTools();
376
+ pluginMgr.onAgentToolsChanged = () => service.applyPluginAgentTools();
343
377
  // ---------------------------------------------------------------------------
344
378
  // Self-update
345
379
  // ---------------------------------------------------------------------------
@@ -449,6 +483,10 @@ wss.on("connection", (ws) => {
449
483
  lastSnapshotBytes = wire.length * 2;
450
484
  ws.send(wire);
451
485
  };
486
+ // Plugins broadcast to every open socket; unregister on close below.
487
+ // Plugins broadcast to every open socket; unregister on close below. The
488
+ // cid getter lets plugins target THIS socket via host.sendTo(clientId).
489
+ const removePluginSender = pluginMgr.addSender(send, () => clientId);
452
490
  const dispatch = (msg) => {
453
491
  if (!clientId) {
454
492
  pending.push(msg);
@@ -505,6 +543,12 @@ wss.on("connection", (ws) => {
505
543
  case "list_projects":
506
544
  void cs.pushProjects();
507
545
  break;
546
+ case "remove_project":
547
+ void cs.removeProject(msg.path);
548
+ break;
549
+ case "delete_session":
550
+ void cs.deleteSession(msg.path);
551
+ break;
508
552
  case "switch_session":
509
553
  void cs.switchSession(msg.path);
510
554
  break;
@@ -643,7 +687,10 @@ wss.on("connection", (ws) => {
643
687
  customSystemPrompt: msg.customSystemPrompt,
644
688
  disabledSkills: msg.disabledSkills,
645
689
  disabledExtensions: msg.disabledExtensions,
690
+ disabledPlugins: msg.disabledPlugins,
646
691
  terminalToolsEnabled: msg.terminalToolsEnabled,
692
+ terminalBash: msg.terminalBash,
693
+ terminalBashIdleMs: msg.terminalBashIdleMs,
647
694
  visionBridgeEnabled: msg.visionBridgeEnabled,
648
695
  visionBridgeModel: msg.visionBridgeModel,
649
696
  visionBridgePromptMode: msg.visionBridgePromptMode,
@@ -655,6 +702,12 @@ wss.on("connection", (ws) => {
655
702
  case "extensions_reload":
656
703
  void cs.reloadExtensions();
657
704
  break;
705
+ case "plugin_message":
706
+ pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
707
+ break;
708
+ case "plugins_reload":
709
+ void pluginMgr.reload().then(() => pluginMgr.pushToAll());
710
+ break;
658
711
  case "save_preset":
659
712
  void cs.savePreset(msg.name);
660
713
  break;
@@ -691,6 +744,12 @@ wss.on("connection", (ws) => {
691
744
  protocolVersion: PROTOCOL_VERSION,
692
745
  });
693
746
  cs.flushSnapshot();
747
+ // Plugin catalog: re-scan + activate new dirs on every attach so
748
+ // freshly dropped plugins show up without a server restart.
749
+ pluginMgr
750
+ .ensureLoaded()
751
+ .then((plugins) => send({ type: "plugins", plugins, epoch: pluginMgr.epoch }))
752
+ .catch(() => { });
694
753
  // Replay anything that arrived while the session was starting.
695
754
  const queued = pending;
696
755
  pending = [];
@@ -725,6 +784,7 @@ wss.on("connection", (ws) => {
725
784
  service.noteSocketClose();
726
785
  closed = true;
727
786
  pending = [];
787
+ removePluginSender();
728
788
  if (snapshotRetryTimer) {
729
789
  clearTimeout(snapshotRetryTimer);
730
790
  snapshotRetryTimer = null;
@@ -779,6 +839,7 @@ async function shutdown() {
779
839
  console.log("\nshutting down…");
780
840
  clearInterval(heartbeatTimer);
781
841
  stopControl();
842
+ pluginMgr.dispose();
782
843
  await service.disposeAll();
783
844
  wss.close();
784
845
  httpServer.close();
@@ -0,0 +1,368 @@
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 } from "node:fs";
21
+ import { join, resolve, sep } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+ /** 合法插件 id:字母/数字/下划线/连字符,防路径穿越(同 themes.ts 的做法)。 */
24
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
25
+ export class PluginManager {
26
+ dataDir;
27
+ cwd;
28
+ loaded = new Map();
29
+ /** 已 import 过但无入口/失败的目录——避免重复 import 与重复报错。 */
30
+ attempted = new Set();
31
+ senders = new Set();
32
+ messageHandlers = new Map();
33
+ /** 插件注册的 AI 工具:pluginId → (name → 定义)。宿主经 agentTools() 读取。 */
34
+ agentTools = new Map();
35
+ /** AI 工具集合变化回调(index.ts 接到 AgentService,把新工具推入活跃会话)。 */
36
+ onAgentToolsChanged = undefined;
37
+ /** 服务端重载纪元:每次 reload() +1,前端用作 import 缓存击穿参数。 */
38
+ epochCounter = 0;
39
+ constructor(dataDir, cwd) {
40
+ this.dataDir = dataDir;
41
+ this.cwd = cwd;
42
+ }
43
+ get pluginsDir() {
44
+ return join(this.dataDir, "plugins");
45
+ }
46
+ /** 当前重载纪元(随 plugins 消息下发)。 */
47
+ get epoch() {
48
+ return this.epochCounter;
49
+ }
50
+ addSender(send, cid) {
51
+ const s = { cid, send };
52
+ this.senders.add(s);
53
+ return () => this.senders.delete(s);
54
+ }
55
+ /** 客户端上行:路由给对应插件的处理器;未知/未激活的插件静默丢弃。 */
56
+ handleMessage(pluginId, payload, from) {
57
+ if (!ID_RE.test(pluginId))
58
+ return;
59
+ const handlers = this.messageHandlers.get(pluginId);
60
+ if (!handlers)
61
+ return;
62
+ for (const h of handlers) {
63
+ try {
64
+ h(payload, from);
65
+ }
66
+ catch (err) {
67
+ console.error(`[plugin:${pluginId}] message handler failed:`, err);
68
+ }
69
+ }
70
+ }
71
+ broadcast(pluginId, payload) {
72
+ this.deliverAll({ type: "plugin_data", pluginId, payload });
73
+ }
74
+ /** 系统通知:发给所有 socket(复用 notice 消息,前端 toast 展示)。 */
75
+ notifyAll(level, text) {
76
+ this.deliverAll({ type: "notice", level, text });
77
+ }
78
+ /** 给指定客户端定向发一条插件消息;找不到该 socket 时静默忽略。 */
79
+ sendTo(clientId, pluginId, payload) {
80
+ for (const s of this.senders) {
81
+ if (s.cid() !== clientId)
82
+ continue;
83
+ try {
84
+ s.send({ type: "plugin_data", pluginId, payload });
85
+ }
86
+ catch {
87
+ /* dead socket */
88
+ }
89
+ }
90
+ }
91
+ /** 目录清单 + 当前 epoch 推给所有 socket。 */
92
+ async pushToAll() {
93
+ const list = await this.scan();
94
+ this.deliverAll({ type: "plugins", plugins: list, epoch: this.epochCounter });
95
+ }
96
+ /** 服务端热重载:反激活全部 → 清缓存 → 重扫重激活 → epoch+1。
97
+ * 返回新目录清单(含激活结果)。 */
98
+ async reload() {
99
+ this.dispose();
100
+ this.attempted.clear();
101
+ this.epochCounter += 1;
102
+ return this.ensureLoaded();
103
+ }
104
+ /** agent-service 调:把 SDK 工具执行事件扇出给所有插件(异常隔离)。 */
105
+ emitToolEvent(ev) {
106
+ for (const p of this.loaded.values()) {
107
+ for (const h of p.toolHandlers) {
108
+ try {
109
+ h(ev);
110
+ }
111
+ catch (err) {
112
+ console.error(`[plugin:${p.info.id}] tool-event handler failed:`, err);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ /** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序)。 */
118
+ getAgentTools() {
119
+ const out = [];
120
+ for (const table of [...this.agentTools.values()].sort())
121
+ out.push(...table.values());
122
+ return out;
123
+ }
124
+ /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。 */
125
+ registerAgentTool(pluginId, tool) {
126
+ if (!tool || typeof tool.execute !== "function" || !tool.name || !tool.description) {
127
+ console.error(`[plugin:${pluginId}] registerAgentTool: 缺少 name/description/execute,忽略`);
128
+ return () => { };
129
+ }
130
+ let table = this.agentTools.get(pluginId);
131
+ if (!table)
132
+ this.agentTools.set(pluginId, (table = new Map()));
133
+ if (table.has(tool.name)) {
134
+ console.error(`[plugin:${pluginId}] AI 工具 "${tool.name}" 重复注册,忽略`);
135
+ return () => { };
136
+ }
137
+ table.set(tool.name, tool);
138
+ console.log(`[plugin:${pluginId}] registered AI tool: ${tool.name}`);
139
+ try {
140
+ this.onAgentToolsChanged?.();
141
+ }
142
+ catch (err) {
143
+ console.error("[plugins] onAgentToolsChanged failed:", err);
144
+ }
145
+ return () => {
146
+ if (table.delete(tool.name)) {
147
+ if (table.size === 0)
148
+ this.agentTools.delete(pluginId);
149
+ try {
150
+ this.onAgentToolsChanged?.();
151
+ }
152
+ catch {
153
+ /* shutting down */
154
+ }
155
+ }
156
+ };
157
+ }
158
+ deliverAll(msg) {
159
+ for (const s of this.senders) {
160
+ try {
161
+ s.send(msg);
162
+ }
163
+ catch {
164
+ /* dead socket — index.ts cleans it up */
165
+ }
166
+ }
167
+ }
168
+ /** 当前目录清单(重扫 manifest,不重新 import)。 */
169
+ async list() {
170
+ return this.scan();
171
+ }
172
+ /**
173
+ * attach 时调用:重扫目录 + 激活尚未加载的新插件。
174
+ * 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
175
+ */
176
+ async ensureLoaded() {
177
+ const found = await this.scan();
178
+ for (const info of found) {
179
+ if (this.loaded.has(info.id) || this.attempted.has(info.id))
180
+ continue;
181
+ if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
182
+ continue; // 纯前端插件
183
+ await this.activate(info);
184
+ }
185
+ // 已被删除的插件:调用 deactivate 并移出缓存
186
+ for (const [id, p] of [...this.loaded]) {
187
+ if (!found.some((f) => f.id === id)) {
188
+ this.deactivateEntry(id, p);
189
+ }
190
+ }
191
+ return found.map((f) => this.loaded.get(f.id)?.info ?? f);
192
+ }
193
+ /** 反激活单个插件:deactivate + 注销 AI 工具 + 清缓存。 */
194
+ deactivateEntry(id, p) {
195
+ try {
196
+ p.deactivate?.();
197
+ }
198
+ catch (err) {
199
+ console.error(`[plugin:${id}] deactivate failed:`, err);
200
+ }
201
+ for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
202
+ try {
203
+ off();
204
+ }
205
+ catch {
206
+ /* already gone */
207
+ }
208
+ }
209
+ this.loaded.delete(id);
210
+ this.messageHandlers.delete(id);
211
+ console.log(`[plugin:${id}] removed`);
212
+ }
213
+ /** 关机时反激活全部插件。 */
214
+ dispose() {
215
+ for (const [id, p] of this.loaded) {
216
+ try {
217
+ p.deactivate?.();
218
+ }
219
+ catch (err) {
220
+ console.error(`[plugin:${id}] deactivate failed:`, err);
221
+ }
222
+ for (const off of [...(p.agentToolUnsubscribers ?? [])]) {
223
+ try {
224
+ off();
225
+ }
226
+ catch {
227
+ /* shutting down */
228
+ }
229
+ }
230
+ }
231
+ this.loaded.clear();
232
+ this.messageHandlers.clear();
233
+ }
234
+ /** 读 manifest 清单;坏目录(无 manifest/id 非法)直接跳过。 */
235
+ async scan() {
236
+ let names;
237
+ try {
238
+ names = await readdir(this.pluginsDir);
239
+ }
240
+ catch {
241
+ return []; // 目录不存在 = 没装任何插件
242
+ }
243
+ const out = [];
244
+ for (const name of names.sort()) {
245
+ if (!ID_RE.test(name))
246
+ continue;
247
+ const dir = join(this.pluginsDir, name);
248
+ try {
249
+ if (!(await stat(dir)).isDirectory())
250
+ continue;
251
+ const raw = await readFile(join(dir, "manifest.json"), "utf8");
252
+ const m = JSON.parse(raw);
253
+ out.push({
254
+ id: name,
255
+ name: typeof m.name === "string" && m.name ? m.name : name,
256
+ version: typeof m.version === "string" ? m.version : undefined,
257
+ description: typeof m.description === "string" ? m.description : undefined,
258
+ icon: typeof m.icon === "string" && m.icon.trim() ? m.icon.trim() : undefined,
259
+ hasClient: existsSync(join(dir, "client", "entry.mjs")),
260
+ error: this.loaded.get(name)?.info.error,
261
+ });
262
+ }
263
+ catch {
264
+ continue; // 无 manifest / JSON 坏 —— 不是插件
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+ async activate(info) {
270
+ this.attempted.add(info.id);
271
+ const dir = join(this.pluginsDir, info.id);
272
+ const handlers = new Set();
273
+ this.messageHandlers.set(info.id, handlers);
274
+ const toolHandlers = new Set();
275
+ const unregisterTools = [];
276
+ const host = {
277
+ broadcast: (payload) => this.broadcast(info.id, payload),
278
+ notify: (level, text) => this.notifyAll(level, text),
279
+ sendTo: (clientId, payload) => this.sendTo(clientId, info.id, payload),
280
+ onMessage: (h) => {
281
+ handlers.add(h);
282
+ return () => handlers.delete(h);
283
+ },
284
+ onToolEvent: (h) => {
285
+ toolHandlers.add(h);
286
+ return () => toolHandlers.delete(h);
287
+ },
288
+ // 包一层:插件反激活时自动注销它注册的全部 AI 工具,不留悬挂项。
289
+ registerAgentTool: (tool) => {
290
+ const off = this.registerAgentTool(info.id, tool);
291
+ unregisterTools.push(off);
292
+ return () => {
293
+ const i = unregisterTools.indexOf(off);
294
+ if (i >= 0)
295
+ unregisterTools.splice(i, 1);
296
+ off();
297
+ };
298
+ },
299
+ dir,
300
+ dataDir: this.dataDir,
301
+ cwd: this.cwd,
302
+ log: (...args) => console.log(`[plugin:${info.id}]`, ...args),
303
+ };
304
+ try {
305
+ const mod = (await import(pathToFileURL(join(dir, "index.mjs")).href));
306
+ const ret = await mod.default?.activate?.(host);
307
+ this.loaded.set(info.id, {
308
+ info: { ...info },
309
+ deactivate: typeof ret === "function" ? ret : undefined,
310
+ toolHandlers,
311
+ agentToolUnsubscribers: unregisterTools,
312
+ });
313
+ console.log(`[plugin:${info.id}] activated (v${info.version ?? "?"})`);
314
+ }
315
+ catch (err) {
316
+ this.loaded.set(info.id, {
317
+ info: { ...info, error: err.message },
318
+ toolHandlers,
319
+ });
320
+ console.error(`[plugin:${info.id}] activate failed:`, err);
321
+ }
322
+ }
323
+ }
324
+ /**
325
+ * 把 /plugins/:id/client/<rest> 安全映射到 <pluginsDir>/<id>/client/<rest>。
326
+ * 返回绝对路径;任何越界/非法 id 返回 null(调用方回 404)。
327
+ */
328
+ export function resolvePluginClientFile(pluginsDir, id, rest) {
329
+ if (!ID_RE.test(id))
330
+ return null;
331
+ const root = resolve(join(pluginsDir, id, "client"));
332
+ // rest 由 express 路由保证不带 "..",但双保险:resolve 后必须仍在 root 内
333
+ const abs = resolve(root, rest);
334
+ if (abs !== root && !abs.startsWith(root + sep))
335
+ return null;
336
+ return abs;
337
+ }
338
+ /**
339
+ * 把插件 AI 工具定义同步进一个「会话状对象」(SDK AgentSession 的结构子集:
340
+ * 内部 _customTools 数组 + _refreshToolRegistry()——refresh 会重读数组,且新
341
+ * 工具名自动加入活跃集)。新增/更新/移除三向 diff;对象不兼容(SDK 改名)返回
342
+ * null 由调用方静默降级。返回新的已注入名单。
343
+ *
344
+ * 纯函数、不 import SDK —— vitest 直接测(tests/unit/plugin-tools.test.ts)。
345
+ */
346
+ export function syncPluginToolsIntoSession(session, defs, prevNames) {
347
+ if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
348
+ return null;
349
+ const byName = new Map(session._customTools.map((d) => [d.name, d]));
350
+ let changed = false;
351
+ for (const d of defs) {
352
+ if (byName.get(d.name) !== d) {
353
+ byName.set(d.name, d);
354
+ changed = true;
355
+ }
356
+ }
357
+ for (const name of prevNames) {
358
+ if (!defs.some((d) => d.name === name) && byName.has(name)) {
359
+ byName.delete(name);
360
+ changed = true;
361
+ }
362
+ }
363
+ if (!changed)
364
+ return new Set(defs.map((d) => d.name));
365
+ session._customTools = [...byName.values()];
366
+ session._refreshToolRegistry();
367
+ return new Set(defs.map((d) => d.name));
368
+ }
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 5;
11
+ export const PROTOCOL_VERSION = 7;
@@ -100,16 +100,20 @@ export class SettingsService {
100
100
  promptMode: this.settings.promptMode,
101
101
  customSystemPrompt: this.settings.customSystemPrompt,
102
102
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
103
+ terminalBash: this.settings.terminalBash,
104
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
103
105
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
104
106
  visionBridgeModel: this.settings.visionBridgeModel,
105
107
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
106
108
  visionBridgePrompt: this.settings.visionBridgePrompt,
107
109
  reviewPrompt: this.settings.reviewPrompt,
108
110
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
111
+ disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
109
112
  // The built-in prompts, so the replace-mode editors can prefill the
110
113
  // text they would otherwise replace (empty until the resource-loader
111
114
  // has run once for the system prompt).
112
115
  defaultSystemPrompt: this.host.effectiveDefaultSystemPrompt(),
116
+ effectiveSystemPrompt: this.host.effectiveSystemPrompt(),
113
117
  visionBridgeDefaultPrompt: SYSTEM_PROMPT,
114
118
  visionModels: this.collectVisionModels(),
115
119
  disabledSkills: [...this.settings.disabledSkills],
@@ -153,9 +157,19 @@ export class SettingsService {
153
157
  if (partial.disabledExtensions !== undefined) {
154
158
  this.settings.disabledExtensions = partial.disabledExtensions;
155
159
  }
160
+ // 插件开关是纯 UI 隐藏(不进 needsReload——运行时无需重载)。
161
+ if (partial.disabledPlugins !== undefined) {
162
+ this.settings.disabledPlugins = partial.disabledPlugins;
163
+ }
156
164
  if (partial.terminalToolsEnabled !== undefined) {
157
165
  this.settings.terminalToolsEnabled = partial.terminalToolsEnabled;
158
166
  }
167
+ if (partial.terminalBash !== undefined) {
168
+ this.settings.terminalBash = partial.terminalBash;
169
+ }
170
+ if (partial.terminalBashIdleMs !== undefined) {
171
+ this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
172
+ }
159
173
  if (partial.visionBridgeEnabled !== undefined) {
160
174
  this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
161
175
  }
@@ -193,6 +207,8 @@ export class SettingsService {
193
207
  disabledSkills: [...this.settings.disabledSkills],
194
208
  disabledExtensions: [...this.settings.disabledExtensions],
195
209
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
210
+ terminalBash: this.settings.terminalBash,
211
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
196
212
  reviewPrompt: this.settings.reviewPrompt,
197
213
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
198
214
  };
@@ -218,6 +234,9 @@ export class SettingsService {
218
234
  disabledExtensions: [...p.disabledExtensions],
219
235
  // 旧版持久化的预设可能没有该字段——保留当前值。
220
236
  terminalToolsEnabled: p.terminalToolsEnabled ?? this.settings.terminalToolsEnabled,
237
+ // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
238
+ terminalBash: p.terminalBash ?? this.settings.terminalBash,
239
+ terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
221
240
  reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
222
241
  reviewDisabledSkills: [
223
242
  ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),