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
@@ -5,7 +5,8 @@
5
5
  * 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
6
6
  * 经 FilesHost 回调与 ClientSession 解耦。
7
7
  */
8
- import { mkdirSync, statSync, writeFileSync, watch } from "node:fs";
8
+ import { mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
9
+ import { homedir } from "node:os";
9
10
  import { resolve, relative, sep } from "node:path";
10
11
  import { pick } from "./i18n.js";
11
12
  import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
@@ -14,6 +15,36 @@ export const IS_WIN32 = process.platform === "win32";
14
15
  /** 机器根虚拟路径:工作区「上一级」到达此处,列出所有盘符(Windows)/ "/"(posix)。
15
16
  * 这是 wire 字面量,前端 web/src/components/{RightPanel,FooterBar}.tsx 里同值使用。 */
16
17
  export const MACHINE_ROOT = "@root";
18
+ /** 桌面目录(wire 格式):存在且为目录才返回,否则空串(前端不渲染 🖥️)。
19
+ * Linux 优先读 XDG user-dirs(中文环境可能是 ~/桌面),其余平台即 ~/Desktop。 */
20
+ export function desktopDirWire(homeWire) {
21
+ const cands = [];
22
+ if (process.platform === "linux") {
23
+ try {
24
+ const conf = readFileSync(resolve(homedir(), ".config", "user-dirs.dirs"), "utf8");
25
+ const m = /XDG_DESKTOP_DIR="([^"]+)"/.exec(conf);
26
+ if (m) {
27
+ const dir = m[1].replace(/\$HOME/g, homeWire);
28
+ if (dir.startsWith("/"))
29
+ cands.push(dir);
30
+ }
31
+ }
32
+ catch {
33
+ // 无 XDG 配置就回落 ~/Desktop
34
+ }
35
+ }
36
+ cands.push(`${homeWire}/Desktop`);
37
+ for (const c of cands) {
38
+ try {
39
+ if (statSync(wireToAbs(c)).isDirectory())
40
+ return c;
41
+ }
42
+ catch {
43
+ // 不存在就试下一个
44
+ }
45
+ }
46
+ return "";
47
+ }
17
48
  /** wire 路径统一用 "/"。绝对 = posix "/...";win32 还有 "C:/..." / 裸 "C:"。
18
49
  * 机器浏览(越过工作区根换盘符)发送这些路径;工作区相对树不会产生它们(Windows
19
50
  * 文件名不能含 ":",相对路径经 relative() 归一化后也不以 "/" 开头)。 */
@@ -918,6 +949,233 @@ export class FilesService {
918
949
  });
919
950
  }
920
951
  }
952
+ /* ---- 文件树右键菜单的文件操作(`contextmenu.file`,见 protocol.ts file_*) ---- */
953
+ /** 右键文件操作共用的路径解析:绝对 wire(机器浏览)直接转原生绝对路径;
954
+ * 相对路径限定在工作区内(越界 → null,与 readFile/writeFile 同口径)。
955
+ * 空串(工作区根须由调用方特判)与机器根 "@root" 本身不可作为操作对象 → null。 */
956
+ resolveOpTarget(raw) {
957
+ const wire = normWirePath(raw.trim());
958
+ if (!wire || wire === MACHINE_ROOT)
959
+ return null;
960
+ if (isAbsoluteWirePath(wire))
961
+ return { abs: wireToAbs(wire) };
962
+ const w = workspacePath(resolve(this.host.getCwd()), wire);
963
+ return w ? { abs: w.abs } : null;
964
+ }
965
+ /** 文件名清洗(与 uploadFile 同口径再收紧):只取 basename,去 Windows 非法字符;
966
+ * 空/纯点/尾点空格/Windows 保留名 → null(建了删不掉的东西不如直接拒绝)。 */
967
+ sanitizeName(name) {
968
+ const base = name.split(/[\\/]/).pop() ?? "";
969
+ const safe = base
970
+ .replace(/[/:*?"<>|\x00-\x1f]/g, "_")
971
+ .trim()
972
+ .slice(0, 200);
973
+ if (!safe || safe === "." || safe === ".." || /[. ]$/.test(safe))
974
+ return null;
975
+ if (IS_WIN32 && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(safe))
976
+ return null;
977
+ return safe;
978
+ }
979
+ /** wire 路径的父目录(wire 字符串层面切分,保证 file_changed 与前端 currentPath 同形)。
980
+ * 工作区根 "" / 机器根 / posix 根 "/" / 盘符根 → null(这些不可重命名/删除/作复制源)。 */
981
+ wireParent(wire) {
982
+ const w = normWirePath(wire.trim());
983
+ if (!w || w === MACHINE_ROOT || w === "/" || /^[A-Za-z]:$/.test(w))
984
+ return null;
985
+ const i = w.lastIndexOf("/");
986
+ if (i < 0)
987
+ return ""; // 工作区相对单层 → 工作区根
988
+ if (i === 0)
989
+ return "/"; // "/a" → "/"
990
+ return w.slice(0, i);
991
+ }
992
+ /** destDir(wire,空串 = 工作区根)→ 原生绝对目录;不存在/非目录 → null(调用方报错)。 */
993
+ async resolveOpDir(dir) {
994
+ const fsp = await import("node:fs/promises");
995
+ if (!dir.trim())
996
+ return { abs: resolve(this.host.getCwd()), wire: "" };
997
+ const t = this.resolveOpTarget(dir);
998
+ if (!t)
999
+ return null;
1000
+ const st = await fsp.stat(t.abs).catch(() => null);
1001
+ if (!st?.isDirectory())
1002
+ return null;
1003
+ return { abs: t.abs, wire: normWirePath(dir.trim()) };
1004
+ }
1005
+ /** 在 dir 下新建空文件或空文件夹。已存在不覆盖(报错);成功后对 dir 推 file_changed。 */
1006
+ async createEntry(dir, name, kind) {
1007
+ const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
1008
+ try {
1009
+ const fsp = await import("node:fs/promises");
1010
+ const { join } = await import("node:path");
1011
+ const safe = this.sanitizeName(name);
1012
+ if (!safe) {
1013
+ err(`文件名不合法:${name}`, `Invalid name: ${name}`);
1014
+ return;
1015
+ }
1016
+ const target = await this.resolveOpDir(dir);
1017
+ if (!target) {
1018
+ err(`目录不存在或超出工作区:${dir || "根目录"}`, `Directory not found: ${dir || "root"}`);
1019
+ return;
1020
+ }
1021
+ const abs = join(target.abs, safe);
1022
+ if (await fsp.stat(abs).catch(() => null)) {
1023
+ err(`已存在:${safe}`, `Already exists: ${safe}`);
1024
+ return;
1025
+ }
1026
+ if (kind === "dir")
1027
+ await fsp.mkdir(abs);
1028
+ else
1029
+ await fsp.writeFile(abs, "");
1030
+ this.host.emit({
1031
+ type: "notice",
1032
+ level: "info",
1033
+ text: kind === "dir" ? `已新建文件夹:${safe}` : `已新建文件:${safe}`,
1034
+ textEn: kind === "dir" ? `Folder created: ${safe}` : `File created: ${safe}`,
1035
+ });
1036
+ this.host.emit({ type: "file_changed", path: target.wire });
1037
+ }
1038
+ catch (e) {
1039
+ err(`新建失败:${e.message}`, `Create failed: ${e.message}`);
1040
+ }
1041
+ }
1042
+ /** 同目录内重命名(newName 只取 basename,不跨目录)。成功后对父目录推 file_changed。 */
1043
+ async renameEntry(path, newName) {
1044
+ const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
1045
+ try {
1046
+ const fsp = await import("node:fs/promises");
1047
+ const { join, dirname } = await import("node:path");
1048
+ const t = this.resolveOpTarget(path);
1049
+ const parent = this.wireParent(path);
1050
+ if (!t || parent === null) {
1051
+ err(`此处不可重命名:${path}`, `Cannot rename here: ${path}`);
1052
+ return;
1053
+ }
1054
+ const safe = this.sanitizeName(newName);
1055
+ if (!safe) {
1056
+ err(`新名称不合法:${newName}`, `Invalid name: ${newName}`);
1057
+ return;
1058
+ }
1059
+ const dest = join(dirname(t.abs), safe);
1060
+ if (await fsp.stat(dest).catch(() => null)) {
1061
+ err(`已存在:${safe}`, `Already exists: ${safe}`);
1062
+ return;
1063
+ }
1064
+ await fsp.rename(t.abs, dest);
1065
+ this.host.emit({
1066
+ type: "notice",
1067
+ level: "info",
1068
+ text: `已重命名为:${safe}`,
1069
+ textEn: `Renamed to: ${safe}`,
1070
+ });
1071
+ this.host.emit({ type: "file_changed", path: parent });
1072
+ }
1073
+ catch (e) {
1074
+ err(`重命名失败:${e.message}`, `Rename failed: ${e.message}`);
1075
+ }
1076
+ }
1077
+ /** 删除文件或目录(目录递归删)。工作区根/机器根/盘符根拒绝;成功后对父目录推 file_changed。 */
1078
+ async deleteEntry(path) {
1079
+ const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
1080
+ try {
1081
+ const fsp = await import("node:fs/promises");
1082
+ const t = this.resolveOpTarget(path);
1083
+ const parent = this.wireParent(path);
1084
+ if (!t || parent === null) {
1085
+ err(`此处不可删除:${path}`, `Cannot delete here: ${path}`);
1086
+ return;
1087
+ }
1088
+ await fsp.rm(t.abs, { recursive: true, force: true });
1089
+ this.host.emit({
1090
+ type: "notice",
1091
+ level: "info",
1092
+ text: `已删除:${path.split(/[\\/]/).pop() ?? path}`,
1093
+ textEn: `Deleted: ${path.split(/[\\/]/).pop() ?? path}`,
1094
+ });
1095
+ this.host.emit({ type: "file_changed", path: parent });
1096
+ }
1097
+ catch (e) {
1098
+ err(`删除失败:${e.message}`, `Delete failed: ${e.message}`);
1099
+ }
1100
+ }
1101
+ /** 复制或移动(move=true 即剪切粘贴)。destDir 与源同目录即「创建副本」;
1102
+ * 重名自动加 " copy" 后缀;目录搬进自身/子目录拒绝;跨盘移动回落为复制+删源。 */
1103
+ async copyEntry(src, destDir, move) {
1104
+ const err = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
1105
+ try {
1106
+ const fsp = await import("node:fs/promises");
1107
+ const { join, basename, sep } = await import("node:path");
1108
+ const s = this.resolveOpTarget(src);
1109
+ const srcParent = this.wireParent(src);
1110
+ if (!s || srcParent === null) {
1111
+ err(`此处不可${move ? "移动" : "复制"}:${src}`, `Cannot ${move ? "move" : "copy"}: ${src}`);
1112
+ return;
1113
+ }
1114
+ const target = await this.resolveOpDir(destDir);
1115
+ if (!target) {
1116
+ err(`目标目录不存在:${destDir || "根目录"}`, `Target directory not found: ${destDir || "root"}`);
1117
+ return;
1118
+ }
1119
+ // 目录搬进自身或子目录 → 无限递归,必须拒绝(文件无此问题,但统一判一次)。
1120
+ if (target.abs === s.abs || target.abs.startsWith(s.abs + sep)) {
1121
+ err("不可复制/移动到自身或子目录", "Cannot copy/move into itself");
1122
+ return;
1123
+ }
1124
+ const base = basename(s.abs);
1125
+ let dest = join(target.abs, base);
1126
+ if (!move)
1127
+ dest = await this.dedupeCopyDest(dest);
1128
+ else if (await fsp.stat(dest).catch(() => null)) {
1129
+ err(`目标已存在:${base}`, `Already exists at target: ${base}`);
1130
+ return;
1131
+ }
1132
+ const verb = move ? ["已移动", "Moved"] : ["已复制", "Copied"];
1133
+ if (move) {
1134
+ try {
1135
+ await fsp.rename(s.abs, dest);
1136
+ }
1137
+ catch (e) {
1138
+ // 跨盘/跨挂载点 rename 报 EXDEV → 回落复制+删源(与文件管理器同行为)。
1139
+ if (e.code !== "EXDEV")
1140
+ throw e;
1141
+ await fsp.cp(s.abs, dest, { recursive: true, force: false });
1142
+ await fsp.rm(s.abs, { recursive: true, force: true });
1143
+ }
1144
+ }
1145
+ else {
1146
+ await fsp.cp(s.abs, dest, { recursive: true, force: false });
1147
+ }
1148
+ this.host.emit({
1149
+ type: "notice",
1150
+ level: "info",
1151
+ text: `${verb[0]}:${base}`,
1152
+ textEn: `${verb[1]}: ${base}`,
1153
+ });
1154
+ this.host.emit({ type: "file_changed", path: target.wire });
1155
+ if (move && target.wire !== srcParent)
1156
+ this.host.emit({ type: "file_changed", path: srcParent });
1157
+ }
1158
+ catch (e) {
1159
+ err(`${move ? "移动" : "复制"}失败:${e.message}`, `${move ? "Move" : "Copy"} failed: ${e.message}`);
1160
+ }
1161
+ }
1162
+ /** 副本目标去重:"a.txt" → "a copy.txt" → "a copy 2.txt"…(目录/无后缀同理)。 */
1163
+ async dedupeCopyDest(dest) {
1164
+ const fsp = await import("node:fs/promises");
1165
+ const { join, dirname, basename, extname } = await import("node:path");
1166
+ if (!(await fsp.stat(dest).catch(() => null)))
1167
+ return dest;
1168
+ const dir = dirname(dest);
1169
+ const base = basename(dest);
1170
+ const ext = extname(base);
1171
+ const stem = ext ? base.slice(0, -ext.length) : base;
1172
+ for (let i = 1; i < 100; i++) {
1173
+ const cand = join(dir, `${stem} copy${i === 1 ? "" : ` ${i}`}${ext}`);
1174
+ if (!(await fsp.stat(cand).catch(() => null)))
1175
+ return cand;
1176
+ }
1177
+ return join(dir, `${stem} copy ${Date.now()}${ext}`);
1178
+ }
921
1179
  /**
922
1180
  * Path completion for the cwd input: expand ~/relative paths, list the parent
923
1181
  * directory, and return prefix matches (dirs first, capped).
@@ -44,6 +44,7 @@ import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
44
44
  import { PluginInstaller } from "./plugin-installer.js";
45
45
  import { syncPluginCatalog } from "./plugin-catalog-sync.js";
46
46
  import { McpBridge } from "./mcp-bridge.js";
47
+ import { createMcpHotReload } from "./mcp-hot-reload.js";
47
48
  /** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
48
49
  * 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
49
50
  * 而不只是经由 bin/pi-web-ui.mjs 的 env 转发。bin 仍是主入口,此处仅作兜底。 */
@@ -223,8 +224,8 @@ if (AUTH_TOKEN) {
223
224
  : "unauthorized: PI_WEB_TOKEN required (?token=…)");
224
225
  });
225
226
  }
226
- /** 引擎选择:PI_WEB_ENGINE=pi|dsh(默认 pi)。重启生效。 */
227
- const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
227
+ /** 引擎选择:--engine pi|dsh > PI_WEB_ENGINE > 默认 pi。重启生效。 */
228
+ const ENGINE = (cliFlag("--engine") ?? process.env.PI_WEB_ENGINE) === "dsh" ? "dsh" : "pi";
228
229
  /** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
229
230
  const MANAGED = isManaged();
230
231
  /** Who started this process: a platform service manager (launchd / systemd /
@@ -490,6 +491,12 @@ app.all(["/plugins-api/:id/*", "/plugins-api/:id"], (req, res) => {
490
491
  app.get("/plugins/:id/client/*", (req, res) => {
491
492
  // express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
492
493
  const rest = String(req.params[0] ?? "");
494
+ // 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
495
+ // (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
496
+ if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
497
+ res.status(403).end("dom access not granted (settings > plugins > grant)");
498
+ return;
499
+ }
493
500
  const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
494
501
  if (!abs) {
495
502
  res.status(404).end("plugin not found");
@@ -733,12 +740,37 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
733
740
  });
734
741
  // 用户点了「允许」→ 授权表变了 → 立刻重推给所有在线客户端(设置面板「已授权目录」即时可见)。
735
742
  pluginMgr.onGrantsChanged = () => pushPluginGrants();
743
+ /** 广播通知条给全部在线客户端(全局事件,不属于某个 ClientSession —— 如 mcp.json 坏)。 */
744
+ function pushNoticeToAll(level, text, textEn) {
745
+ const payload = JSON.stringify({ type: "notice", level, text, textEn });
746
+ for (const client of wss.clients) {
747
+ if (client.readyState !== WebSocket.OPEN)
748
+ continue;
749
+ try {
750
+ client.send(payload);
751
+ }
752
+ catch {
753
+ /* 死连接:index.ts 自己会清理 */
754
+ }
755
+ }
756
+ }
736
757
  // MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
737
758
  // 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
738
759
  const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
760
+ // mcp.json 热加载:保存文件即生效,改完不必再重启服务。两半都在这里收口 —— reload 换入
761
+ // 新的服务器集合(只重启规格真变了的),applyPluginAgentTools 把新工具推给已有会话。
762
+ const mcpHotReload = createMcpHotReload({
763
+ dataDir: DATA_DIR,
764
+ reload: () => mcpBridge.reload(),
765
+ onToolsChanged: () => service.applyPluginAgentTools(),
766
+ onNotice: (level, text, textEn) => pushNoticeToAll(level, text, textEn),
767
+ log: (...a) => console.log(...a),
768
+ });
739
769
  void mcpBridge.load().then(() => {
740
770
  if (mcpBridge.getTools().length)
741
771
  service.applyPluginAgentTools();
772
+ // 播种在 load 之后:否则指纹可能记在 load 读到的版本之前,白重载一次。
773
+ mcpHotReload.start();
742
774
  });
743
775
  // 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
744
776
  service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
@@ -761,6 +793,107 @@ service.pluginCommandsProvider = () => pluginMgr.listCommands();
761
793
  pluginMgr.onBgTasksChanged = () => service.refreshBackgroundServers();
762
794
  service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
763
795
  service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
796
+ // ---------------------------------------------------------------------------
797
+ // 插件扩展点 v2(并行任务在 server/plugins.ts 加 host.conversations/prompt/
798
+ // steer/abortRun/chatWait/fs.watch/scm/bash/schedule/models/onStats/onStreaming/
799
+ // net.fetch/events + conversationLister/conversationSearcher/conversationWriter/
800
+ // runSteerer/runAborter/modelLister + emitStats/emitStreaming 注入点,web/ 侧加
801
+ // plugin-host v8 与新 slot/kind/messageWidget)。本文件只做接线,不实现宿主方法
802
+ // 本身:下面全是注入函数(读 service 现有逻辑组装数据),PluginManager 那边
803
+ // 存在即用、不存在即跳过。约束:全部用 (pm as any).xxx 赋值 + typeof 防御,绝不
804
+ // 假设 PluginManager 已有这些字段(并行任务可能还没合入);每个注入内部再
805
+ // try/catch,DSH 引擎(无 pluginClient/各 ForPlugins 方法)回退空列表或
806
+ // {ok:false},绝不抛错炸进程。
807
+ // ---------------------------------------------------------------------------
808
+ {
809
+ const pm = pluginMgr;
810
+ /** 挑一个客户端会话:标准 pi 引擎走 service.pluginClient(),DSH/未知引擎无此方法即 undefined。 */
811
+ const pickClient = () => {
812
+ try {
813
+ const svc = service;
814
+ return typeof svc.pluginClient === "function" ? svc.pluginClient() : undefined;
815
+ }
816
+ catch {
817
+ return undefined;
818
+ }
819
+ };
820
+ // conversationLister:本客户端运行中对话 + 当前项目历史会话摘要,只读组装
821
+ // {id,title,cwd,kind,isStreaming}。无客户端/方法缺失回空数组(插件显示空态)。
822
+ pm.conversationLister = async () => {
823
+ try {
824
+ const cs = pickClient();
825
+ if (!cs)
826
+ return [];
827
+ const running = typeof cs.listRunningForPlugins === "function" ? cs.listRunningForPlugins() : [];
828
+ const history = typeof cs.listHistoryForPlugins === "function" ? await cs.listHistoryForPlugins(50) : [];
829
+ return [...running, ...history];
830
+ }
831
+ catch {
832
+ return [];
833
+ }
834
+ };
835
+ // conversationSearcher:复用 search_sessions 逻辑,返回前 N 个 {id,title}。
836
+ pm.conversationSearcher = async (query, limit) => {
837
+ try {
838
+ const cs = pickClient();
839
+ if (!cs || typeof cs.searchForPlugins !== "function")
840
+ return [];
841
+ return await cs.searchForPlugins(query, limit ?? 20);
842
+ }
843
+ catch {
844
+ return [];
845
+ }
846
+ };
847
+ // conversationWriter:向指定对话投递 prompt(复用 prompt 路径);找不到对话回 {ok:false,error}。
848
+ pm.conversationWriter = async (id, text) => {
849
+ try {
850
+ const cs = pickClient();
851
+ if (!cs || typeof cs.writeForPlugins !== "function")
852
+ return { ok: false, error: "当前引擎不支持对话投递(仅标准 pi 引擎)" };
853
+ return await cs.writeForPlugins(id, text);
854
+ }
855
+ catch (err) {
856
+ return { ok: false, error: err.message };
857
+ }
858
+ };
859
+ // modelLister:复用现有模型列表,映射 {id,provider,vision}。
860
+ pm.modelLister = async () => {
861
+ try {
862
+ const cs = pickClient();
863
+ if (!cs || typeof cs.listModelsForPlugins !== "function")
864
+ return [];
865
+ return await cs.listModelsForPlugins();
866
+ }
867
+ catch {
868
+ return [];
869
+ }
870
+ };
871
+ // runSteerer:复用 ClientSession.steerForPlugins(sendUserMessage + deliverAs:'steer',
872
+ // 跨客户端查找由方法内部兜底);DSH/未知引擎无此方法即 not supported。
873
+ pm.runSteerer = async (id, text) => {
874
+ try {
875
+ const cs = pickClient();
876
+ if (!cs || typeof cs.steerForPlugins !== "function")
877
+ return { ok: false, error: "not supported" };
878
+ return await cs.steerForPlugins(id, text);
879
+ }
880
+ catch (err) {
881
+ return { ok: false, error: err.message };
882
+ }
883
+ };
884
+ // runAborter:有现成 abort 路径(interruptRun,卡住/空转强制重置语义继承)。
885
+ pm.runAborter = async (id) => {
886
+ try {
887
+ const cs = pickClient();
888
+ if (!cs || typeof cs.abortForPlugins !== "function")
889
+ return { ok: false, error: "not supported" };
890
+ return await cs.abortForPlugins(id);
891
+ }
892
+ catch (err) {
893
+ return { ok: false, error: err.message };
894
+ }
895
+ };
896
+ }
764
897
  // 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
765
898
  // PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
766
899
  service.onClientCwdChanged = (cwd, roots) => {
@@ -912,6 +1045,9 @@ wss.on("connection", (ws) => {
912
1045
  case "queue_remove":
913
1046
  cs.removeQueued(msg.kind, msg.text);
914
1047
  break;
1048
+ case "draft_update":
1049
+ cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
1050
+ break;
915
1051
  case "abort":
916
1052
  void cs.abort();
917
1053
  break;
@@ -931,7 +1067,7 @@ wss.on("connection", (ws) => {
931
1067
  void cs.listBgServers();
932
1068
  break;
933
1069
  case "new_chat":
934
- void cs.newChat();
1070
+ void cs.newChat(msg.preset);
935
1071
  break;
936
1072
  case "edit_message":
937
1073
  void cs.editMessage(msg.messageId, msg.text, msg.attachments);
@@ -1010,6 +1146,18 @@ wss.on("connection", (ws) => {
1010
1146
  case "upload_file":
1011
1147
  void cs.uploadFile(msg.dirPath, msg.name, msg.data);
1012
1148
  break;
1149
+ case "file_create":
1150
+ void cs.createEntry(msg.dir, msg.name, msg.kind);
1151
+ break;
1152
+ case "file_rename":
1153
+ void cs.renameEntry(msg.path, msg.newName);
1154
+ break;
1155
+ case "file_delete":
1156
+ void cs.deleteEntry(msg.path);
1157
+ break;
1158
+ case "file_copy":
1159
+ void cs.copyEntry(msg.src, msg.destDir, msg.move);
1160
+ break;
1013
1161
  case "list_models":
1014
1162
  void cs.listModels();
1015
1163
  break;
@@ -1080,6 +1228,21 @@ wss.on("connection", (ws) => {
1080
1228
  case "clear_provider_api_key":
1081
1229
  void cs.clearProviderApiKey(msg.provider);
1082
1230
  break;
1231
+ case "provider_oauth_start":
1232
+ cs.startProviderOAuth(msg.provider);
1233
+ break;
1234
+ case "provider_oauth_reply":
1235
+ cs.replyProviderOAuth(msg.flowId, msg.promptId, msg.value);
1236
+ break;
1237
+ case "provider_oauth_cancel":
1238
+ cs.cancelProviderOAuth(msg.flowId);
1239
+ break;
1240
+ case "list_provider_oauth_flows":
1241
+ cs.listProviderOAuthFlows();
1242
+ break;
1243
+ case "provider_oauth_logout":
1244
+ void cs.logoutProviderOAuth(msg.provider);
1245
+ break;
1083
1246
  case "list_models_config":
1084
1247
  void cs.listModelsConfig();
1085
1248
  break;
@@ -1265,6 +1428,7 @@ wss.on("connection", (ws) => {
1265
1428
  id: pluginId,
1266
1429
  source: msg.source,
1267
1430
  build: msg.build === true,
1431
+ noBuild: msg.noBuild === true,
1268
1432
  }, {
1269
1433
  lang: jobLang,
1270
1434
  emit: (m) => send(m),
@@ -1305,6 +1469,22 @@ wss.on("connection", (ws) => {
1305
1469
  }
1306
1470
  break;
1307
1471
  }
1472
+ case "plugin_dom_consent": {
1473
+ void pluginMgr
1474
+ .setDomConsent(msg.pluginId, msg.granted === true)
1475
+ .then((r) => {
1476
+ if (r.error)
1477
+ cs?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
1478
+ else if (r.changed)
1479
+ cs?.emitNotice("info", msg.granted === true
1480
+ ? `已授权插件「${msg.pluginId}」完全 DOM 访问`
1481
+ : `已撤销插件「${msg.pluginId}」完全 DOM 访问`, msg.granted === true
1482
+ ? `Granted full DOM access to plugin "${msg.pluginId}"`
1483
+ : `Revoked full DOM access from plugin "${msg.pluginId}"`);
1484
+ })
1485
+ .catch(() => { });
1486
+ break;
1487
+ }
1308
1488
  case "plugin_path_revoke": {
1309
1489
  const removed = pluginMgr.grants.revoke(typeof msg.pluginId === "string" ? msg.pluginId : undefined, typeof msg.path === "string" ? msg.path : undefined);
1310
1490
  cs?.emitNotice("info", `已撤销 ${removed} 条插件目录授权`, `Revoked ${removed} plugin path grant(s)`);
@@ -1336,6 +1516,21 @@ wss.on("connection", (ws) => {
1336
1516
  case "dsh_patches_list":
1337
1517
  void cs.listDshPatches?.();
1338
1518
  break;
1519
+ case "dsh_preset_list":
1520
+ void cs.refreshAgentPresets?.();
1521
+ break;
1522
+ case "dsh_preset_select":
1523
+ void cs.selectAgentPreset?.(msg.preset);
1524
+ break;
1525
+ case "dsh_preset_default":
1526
+ void cs.setDefaultAgentPreset?.(msg.preset);
1527
+ break;
1528
+ case "dsh_permission_set":
1529
+ void cs.setPermissionPreset?.(msg.preset);
1530
+ break;
1531
+ case "dsh_permission_default":
1532
+ void cs.setDefaultPermissionPreset?.(msg.preset);
1533
+ break;
1339
1534
  case "dsh_patches_rescan":
1340
1535
  void cs.rescanDshPatches?.();
1341
1536
  break;
@@ -1514,6 +1709,26 @@ httpServer.listen(PORT, HOST, () => {
1514
1709
  });
1515
1710
  // 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
1516
1711
  scheduleUploadCleanup();
1712
+ // 开机目录预同步(issue #165):PI_WEB_PLUGIN_CATALOG_URL 指向一份插件市场目录文档
1713
+ // (headless/预置场景,不开浏览器也能装插件)。只跑一次、失败只告警不阻断启动;
1714
+ // 条目逐条安装/更新(托管实例上安装会被安装器拒绝,但列表本身仍会更新)。
1715
+ const BOOT_CATALOG_URL = (process.env.PI_WEB_PLUGIN_CATALOG_URL ?? "").trim();
1716
+ if (BOOT_CATALOG_URL) {
1717
+ void syncPluginCatalog(BOOT_CATALOG_URL, { install: true }, {
1718
+ customCatalogPath: pluginMgr.customCatalogPath,
1719
+ pluginsDir: join(DATA_DIR, "plugins"),
1720
+ installer: pluginInstaller,
1721
+ afterWrite: () => reloadPluginsAndPush(),
1722
+ }).then((r) => {
1723
+ if (!r.ok) {
1724
+ console.warn(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步失败(不阻断启动): ${r.error}`);
1725
+ return;
1726
+ }
1727
+ const bad = (r.installed ?? []).filter((i) => !i.ok);
1728
+ console.log(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步完成:安装 ${(r.installed ?? []).length - bad.length} 成功 / ${bad.length} 失败` +
1729
+ (bad.length ? `:${bad.map((i) => `${i.id}(${i.error ?? "?"})`).join(";")}` : ""));
1730
+ });
1731
+ }
1517
1732
  // Local control socket (status / quiesce / unquiesce) — same data dir the
1518
1733
  // CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
1519
1734
  const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
@@ -1527,6 +1742,7 @@ async function shutdown() {
1527
1742
  stopControl();
1528
1743
  pluginMgr.dispose();
1529
1744
  pluginInstaller.dispose();
1745
+ mcpHotReload.dispose();
1530
1746
  mcpBridge.dispose();
1531
1747
  await service.disposeAll();
1532
1748
  wss.close();