pi-web-ui 0.86.2 → 0.87.1

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 (37) hide show
  1. package/CHANGELOG.md +84 -23
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +377 -25
  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 +535 -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 +244 -5
  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/dist/server/update-check.js +22 -0
  28. package/package.json +1 -1
  29. package/plugins/catalog.json +9 -0
  30. package/themes/dark-teal.css +65 -22
  31. package/web/dist/assets/index-8xCnPMZP.js +374 -0
  32. package/web/dist/assets/index-F86qWlJy.css +41 -0
  33. package/web/dist/index.html +3 -2
  34. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  35. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  36. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  37. 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,21 @@ 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";
48
+ /** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
49
+ * `npm_lifecycle_*` into every child). Anything this server spawns — shells,
50
+ * `pi update` — would otherwise inherit `npm_config_allow_scripts`, which npm
51
+ * maps to its env config layer and rejects in project-scoped installs
52
+ * (EALLOWSCRIPTS). The unit keeps `npm ci && npm run build && npm start`;
53
+ * this is the single scrub point so no wrapper is needed. */
54
+ for (const k of Object.keys(process.env)) {
55
+ if (k === "npm_config_allow_scripts" ||
56
+ k.startsWith("npm_config_") ||
57
+ k.startsWith("npm_package_") ||
58
+ k.startsWith("npm_lifecycle_")) {
59
+ delete process.env[k];
60
+ }
61
+ }
47
62
  /** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
48
63
  * 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
49
64
  * 而不只是经由 bin/pi-web-ui.mjs 的 env 转发。bin 仍是主入口,此处仅作兜底。 */
@@ -223,8 +238,8 @@ if (AUTH_TOKEN) {
223
238
  : "unauthorized: PI_WEB_TOKEN required (?token=…)");
224
239
  });
225
240
  }
226
- /** 引擎选择:PI_WEB_ENGINE=pi|dsh(默认 pi)。重启生效。 */
227
- const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
241
+ /** 引擎选择:--engine pi|dsh > PI_WEB_ENGINE > 默认 pi。重启生效。 */
242
+ const ENGINE = (cliFlag("--engine") ?? process.env.PI_WEB_ENGINE) === "dsh" ? "dsh" : "pi";
228
243
  /** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
229
244
  const MANAGED = isManaged();
230
245
  /** Who started this process: a platform service manager (launchd / systemd /
@@ -490,6 +505,12 @@ app.all(["/plugins-api/:id/*", "/plugins-api/:id"], (req, res) => {
490
505
  app.get("/plugins/:id/client/*", (req, res) => {
491
506
  // express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
492
507
  const rest = String(req.params[0] ?? "");
508
+ // 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
509
+ // (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
510
+ if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
511
+ res.status(403).end("dom access not granted (settings > plugins > grant)");
512
+ return;
513
+ }
493
514
  const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
494
515
  if (!abs) {
495
516
  res.status(404).end("plugin not found");
@@ -733,12 +754,37 @@ pluginMgr.pathAccessRequester = (pluginId, dir, reason) => new Promise((resolve)
733
754
  });
734
755
  // 用户点了「允许」→ 授权表变了 → 立刻重推给所有在线客户端(设置面板「已授权目录」即时可见)。
735
756
  pluginMgr.onGrantsChanged = () => pushPluginGrants();
757
+ /** 广播通知条给全部在线客户端(全局事件,不属于某个 ClientSession —— 如 mcp.json 坏)。 */
758
+ function pushNoticeToAll(level, text, textEn) {
759
+ const payload = JSON.stringify({ type: "notice", level, text, textEn });
760
+ for (const client of wss.clients) {
761
+ if (client.readyState !== WebSocket.OPEN)
762
+ continue;
763
+ try {
764
+ client.send(payload);
765
+ }
766
+ catch {
767
+ /* 死连接:index.ts 自己会清理 */
768
+ }
769
+ }
770
+ }
736
771
  // MCP 工具桥:读取 <dataDir>/mcp.json 启动外部 MCP 服务器(stdio),把它们的
737
772
  // 工具并入与插件工具相同的 customTools 管线;单服务器失败不炸进程。
738
773
  const mcpBridge = new McpBridge(DATA_DIR, (...a) => console.log("[mcp]", ...a));
774
+ // mcp.json 热加载:保存文件即生效,改完不必再重启服务。两半都在这里收口 —— reload 换入
775
+ // 新的服务器集合(只重启规格真变了的),applyPluginAgentTools 把新工具推给已有会话。
776
+ const mcpHotReload = createMcpHotReload({
777
+ dataDir: DATA_DIR,
778
+ reload: () => mcpBridge.reload(),
779
+ onToolsChanged: () => service.applyPluginAgentTools(),
780
+ onNotice: (level, text, textEn) => pushNoticeToAll(level, text, textEn),
781
+ log: (...a) => console.log(...a),
782
+ });
739
783
  void mcpBridge.load().then(() => {
740
784
  if (mcpBridge.getTools().length)
741
785
  service.applyPluginAgentTools();
786
+ // 播种在 load 之后:否则指纹可能记在 load 读到的版本之前,白重载一次。
787
+ mcpHotReload.start();
742
788
  });
743
789
  // 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
744
790
  service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
@@ -761,6 +807,107 @@ service.pluginCommandsProvider = () => pluginMgr.listCommands();
761
807
  pluginMgr.onBgTasksChanged = () => service.refreshBackgroundServers();
762
808
  service.pluginBgTasksProvider = () => pluginMgr.bgTasks();
763
809
  service.pluginStopBgTask = (taskId) => pluginMgr.stopPluginBgTask(taskId);
810
+ // ---------------------------------------------------------------------------
811
+ // 插件扩展点 v2(并行任务在 server/plugins.ts 加 host.conversations/prompt/
812
+ // steer/abortRun/chatWait/fs.watch/scm/bash/schedule/models/onStats/onStreaming/
813
+ // net.fetch/events + conversationLister/conversationSearcher/conversationWriter/
814
+ // runSteerer/runAborter/modelLister + emitStats/emitStreaming 注入点,web/ 侧加
815
+ // plugin-host v8 与新 slot/kind/messageWidget)。本文件只做接线,不实现宿主方法
816
+ // 本身:下面全是注入函数(读 service 现有逻辑组装数据),PluginManager 那边
817
+ // 存在即用、不存在即跳过。约束:全部用 (pm as any).xxx 赋值 + typeof 防御,绝不
818
+ // 假设 PluginManager 已有这些字段(并行任务可能还没合入);每个注入内部再
819
+ // try/catch,DSH 引擎(无 pluginClient/各 ForPlugins 方法)回退空列表或
820
+ // {ok:false},绝不抛错炸进程。
821
+ // ---------------------------------------------------------------------------
822
+ {
823
+ const pm = pluginMgr;
824
+ /** 挑一个客户端会话:标准 pi 引擎走 service.pluginClient(),DSH/未知引擎无此方法即 undefined。 */
825
+ const pickClient = () => {
826
+ try {
827
+ const svc = service;
828
+ return typeof svc.pluginClient === "function" ? svc.pluginClient() : undefined;
829
+ }
830
+ catch {
831
+ return undefined;
832
+ }
833
+ };
834
+ // conversationLister:本客户端运行中对话 + 当前项目历史会话摘要,只读组装
835
+ // {id,title,cwd,kind,isStreaming}。无客户端/方法缺失回空数组(插件显示空态)。
836
+ pm.conversationLister = async () => {
837
+ try {
838
+ const cs = pickClient();
839
+ if (!cs)
840
+ return [];
841
+ const running = typeof cs.listRunningForPlugins === "function" ? cs.listRunningForPlugins() : [];
842
+ const history = typeof cs.listHistoryForPlugins === "function" ? await cs.listHistoryForPlugins(50) : [];
843
+ return [...running, ...history];
844
+ }
845
+ catch {
846
+ return [];
847
+ }
848
+ };
849
+ // conversationSearcher:复用 search_sessions 逻辑,返回前 N 个 {id,title}。
850
+ pm.conversationSearcher = async (query, limit) => {
851
+ try {
852
+ const cs = pickClient();
853
+ if (!cs || typeof cs.searchForPlugins !== "function")
854
+ return [];
855
+ return await cs.searchForPlugins(query, limit ?? 20);
856
+ }
857
+ catch {
858
+ return [];
859
+ }
860
+ };
861
+ // conversationWriter:向指定对话投递 prompt(复用 prompt 路径);找不到对话回 {ok:false,error}。
862
+ pm.conversationWriter = async (id, text) => {
863
+ try {
864
+ const cs = pickClient();
865
+ if (!cs || typeof cs.writeForPlugins !== "function")
866
+ return { ok: false, error: "当前引擎不支持对话投递(仅标准 pi 引擎)" };
867
+ return await cs.writeForPlugins(id, text);
868
+ }
869
+ catch (err) {
870
+ return { ok: false, error: err.message };
871
+ }
872
+ };
873
+ // modelLister:复用现有模型列表,映射 {id,provider,vision}。
874
+ pm.modelLister = async () => {
875
+ try {
876
+ const cs = pickClient();
877
+ if (!cs || typeof cs.listModelsForPlugins !== "function")
878
+ return [];
879
+ return await cs.listModelsForPlugins();
880
+ }
881
+ catch {
882
+ return [];
883
+ }
884
+ };
885
+ // runSteerer:复用 ClientSession.steerForPlugins(sendUserMessage + deliverAs:'steer',
886
+ // 跨客户端查找由方法内部兜底);DSH/未知引擎无此方法即 not supported。
887
+ pm.runSteerer = async (id, text) => {
888
+ try {
889
+ const cs = pickClient();
890
+ if (!cs || typeof cs.steerForPlugins !== "function")
891
+ return { ok: false, error: "not supported" };
892
+ return await cs.steerForPlugins(id, text);
893
+ }
894
+ catch (err) {
895
+ return { ok: false, error: err.message };
896
+ }
897
+ };
898
+ // runAborter:有现成 abort 路径(interruptRun,卡住/空转强制重置语义继承)。
899
+ pm.runAborter = async (id) => {
900
+ try {
901
+ const cs = pickClient();
902
+ if (!cs || typeof cs.abortForPlugins !== "function")
903
+ return { ok: false, error: "not supported" };
904
+ return await cs.abortForPlugins(id);
905
+ }
906
+ catch (err) {
907
+ return { ok: false, error: err.message };
908
+ }
909
+ };
910
+ }
764
911
  // 插件宿主工作区实时跟随当前项目:任意客户端 set_cwd 成功后同步给
765
912
  // PluginManager,编辑器等工作区跟随型插件随即切根(详见 plugins.ts notifyCwd)。
766
913
  service.onClientCwdChanged = (cwd, roots) => {
@@ -912,6 +1059,9 @@ wss.on("connection", (ws) => {
912
1059
  case "queue_remove":
913
1060
  cs.removeQueued(msg.kind, msg.text);
914
1061
  break;
1062
+ case "draft_update":
1063
+ cs.saveDraft?.(msg.sessionId, msg.text, msg.ts);
1064
+ break;
915
1065
  case "abort":
916
1066
  void cs.abort();
917
1067
  break;
@@ -931,7 +1081,7 @@ wss.on("connection", (ws) => {
931
1081
  void cs.listBgServers();
932
1082
  break;
933
1083
  case "new_chat":
934
- void cs.newChat();
1084
+ void cs.newChat(msg.preset);
935
1085
  break;
936
1086
  case "edit_message":
937
1087
  void cs.editMessage(msg.messageId, msg.text, msg.attachments);
@@ -1010,6 +1160,18 @@ wss.on("connection", (ws) => {
1010
1160
  case "upload_file":
1011
1161
  void cs.uploadFile(msg.dirPath, msg.name, msg.data);
1012
1162
  break;
1163
+ case "file_create":
1164
+ void cs.createEntry(msg.dir, msg.name, msg.kind);
1165
+ break;
1166
+ case "file_rename":
1167
+ void cs.renameEntry(msg.path, msg.newName);
1168
+ break;
1169
+ case "file_delete":
1170
+ void cs.deleteEntry(msg.path);
1171
+ break;
1172
+ case "file_copy":
1173
+ void cs.copyEntry(msg.src, msg.destDir, msg.move);
1174
+ break;
1013
1175
  case "list_models":
1014
1176
  void cs.listModels();
1015
1177
  break;
@@ -1061,9 +1223,18 @@ wss.on("connection", (ws) => {
1061
1223
  send({
1062
1224
  type: "notice",
1063
1225
  level: "info",
1064
- text: "正在重启服务…页面会在服务恢复后自动重连。",
1065
- textEn: "Restarting the service… this page reconnects once it is back.",
1226
+ text: "正在重启服务…页面会在服务恢复后自动重连,进行中的对话会自动恢复并继续。",
1227
+ textEn: "Restarting the service… this page reconnects once it is back; running conversations resume automatically.",
1066
1228
  });
1229
+ // Record streaming runs BEFORE exiting: under systemd this path
1230
+ // exits via process.exit without shutdown(), so without this the
1231
+ // post-restart resume would find nothing to continue.
1232
+ try {
1233
+ service.recordInterruptedRuns();
1234
+ }
1235
+ catch {
1236
+ /* best effort — never block the restart on bookkeeping */
1237
+ }
1067
1238
  // Let the notice (and this socket's backlog) flush before we go down.
1068
1239
  setTimeout(() => void scheduleQuit(), 400);
1069
1240
  break;
@@ -1080,6 +1251,21 @@ wss.on("connection", (ws) => {
1080
1251
  case "clear_provider_api_key":
1081
1252
  void cs.clearProviderApiKey(msg.provider);
1082
1253
  break;
1254
+ case "provider_oauth_start":
1255
+ cs.startProviderOAuth(msg.provider);
1256
+ break;
1257
+ case "provider_oauth_reply":
1258
+ cs.replyProviderOAuth(msg.flowId, msg.promptId, msg.value);
1259
+ break;
1260
+ case "provider_oauth_cancel":
1261
+ cs.cancelProviderOAuth(msg.flowId);
1262
+ break;
1263
+ case "list_provider_oauth_flows":
1264
+ cs.listProviderOAuthFlows();
1265
+ break;
1266
+ case "provider_oauth_logout":
1267
+ void cs.logoutProviderOAuth(msg.provider);
1268
+ break;
1083
1269
  case "list_models_config":
1084
1270
  void cs.listModelsConfig();
1085
1271
  break;
@@ -1265,6 +1451,7 @@ wss.on("connection", (ws) => {
1265
1451
  id: pluginId,
1266
1452
  source: msg.source,
1267
1453
  build: msg.build === true,
1454
+ noBuild: msg.noBuild === true,
1268
1455
  }, {
1269
1456
  lang: jobLang,
1270
1457
  emit: (m) => send(m),
@@ -1305,6 +1492,22 @@ wss.on("connection", (ws) => {
1305
1492
  }
1306
1493
  break;
1307
1494
  }
1495
+ case "plugin_dom_consent": {
1496
+ void pluginMgr
1497
+ .setDomConsent(msg.pluginId, msg.granted === true)
1498
+ .then((r) => {
1499
+ if (r.error)
1500
+ cs?.emitNotice("warning", `DOM 授权失败:${r.error}`, `DOM consent failed: ${r.error}`);
1501
+ else if (r.changed)
1502
+ cs?.emitNotice("info", msg.granted === true
1503
+ ? `已授权插件「${msg.pluginId}」完全 DOM 访问`
1504
+ : `已撤销插件「${msg.pluginId}」完全 DOM 访问`, msg.granted === true
1505
+ ? `Granted full DOM access to plugin "${msg.pluginId}"`
1506
+ : `Revoked full DOM access from plugin "${msg.pluginId}"`);
1507
+ })
1508
+ .catch(() => { });
1509
+ break;
1510
+ }
1308
1511
  case "plugin_path_revoke": {
1309
1512
  const removed = pluginMgr.grants.revoke(typeof msg.pluginId === "string" ? msg.pluginId : undefined, typeof msg.path === "string" ? msg.path : undefined);
1310
1513
  cs?.emitNotice("info", `已撤销 ${removed} 条插件目录授权`, `Revoked ${removed} plugin path grant(s)`);
@@ -1336,6 +1539,21 @@ wss.on("connection", (ws) => {
1336
1539
  case "dsh_patches_list":
1337
1540
  void cs.listDshPatches?.();
1338
1541
  break;
1542
+ case "dsh_preset_list":
1543
+ void cs.refreshAgentPresets?.();
1544
+ break;
1545
+ case "dsh_preset_select":
1546
+ void cs.selectAgentPreset?.(msg.preset);
1547
+ break;
1548
+ case "dsh_preset_default":
1549
+ void cs.setDefaultAgentPreset?.(msg.preset);
1550
+ break;
1551
+ case "dsh_permission_set":
1552
+ void cs.setPermissionPreset?.(msg.preset);
1553
+ break;
1554
+ case "dsh_permission_default":
1555
+ void cs.setDefaultPermissionPreset?.(msg.preset);
1556
+ break;
1339
1557
  case "dsh_patches_rescan":
1340
1558
  void cs.rescanDshPatches?.();
1341
1559
  break;
@@ -1514,6 +1732,26 @@ httpServer.listen(PORT, HOST, () => {
1514
1732
  });
1515
1733
  // 上传文件保留期清理:启动扫一次 + 每 6 小时一次(best-effort,见 uploads.ts)
1516
1734
  scheduleUploadCleanup();
1735
+ // 开机目录预同步(issue #165):PI_WEB_PLUGIN_CATALOG_URL 指向一份插件市场目录文档
1736
+ // (headless/预置场景,不开浏览器也能装插件)。只跑一次、失败只告警不阻断启动;
1737
+ // 条目逐条安装/更新(托管实例上安装会被安装器拒绝,但列表本身仍会更新)。
1738
+ const BOOT_CATALOG_URL = (process.env.PI_WEB_PLUGIN_CATALOG_URL ?? "").trim();
1739
+ if (BOOT_CATALOG_URL) {
1740
+ void syncPluginCatalog(BOOT_CATALOG_URL, { install: true }, {
1741
+ customCatalogPath: pluginMgr.customCatalogPath,
1742
+ pluginsDir: join(DATA_DIR, "plugins"),
1743
+ installer: pluginInstaller,
1744
+ afterWrite: () => reloadPluginsAndPush(),
1745
+ }).then((r) => {
1746
+ if (!r.ok) {
1747
+ console.warn(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步失败(不阻断启动): ${r.error}`);
1748
+ return;
1749
+ }
1750
+ const bad = (r.installed ?? []).filter((i) => !i.ok);
1751
+ console.log(`[catalog] PI_WEB_PLUGIN_CATALOG_URL 同步完成:安装 ${(r.installed ?? []).length - bad.length} 成功 / ${bad.length} 失败` +
1752
+ (bad.length ? `:${bad.map((i) => `${i.id}(${i.error ?? "?"})`).join(";")}` : ""));
1753
+ });
1754
+ }
1517
1755
  // Local control socket (status / quiesce / unquiesce) — same data dir the
1518
1756
  // CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
1519
1757
  const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
@@ -1527,6 +1765,7 @@ async function shutdown() {
1527
1765
  stopControl();
1528
1766
  pluginMgr.dispose();
1529
1767
  pluginInstaller.dispose();
1768
+ mcpHotReload.dispose();
1530
1769
  mcpBridge.dispose();
1531
1770
  await service.disposeAll();
1532
1771
  wss.close();