pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,29 @@
1
+ /**
2
+ * WS hello 的 clientId 校验 —— 独立成模块是为了可单测
3
+ * (`server/index.ts` 起 WS + SDK,不适合在单测里 import)。
4
+ *
5
+ * 背景:hello.clientId 原样进入 service.attach → ClientSession → saveUpload
6
+ * (`<dataDir>/uploads/<clientId>/`,见 attachments.ts / dsh-agent-service.ts),
7
+ * 无校验时外部可传对象/数字/路径穿越串。合法客户端用 uuid(含连字符,在字符集
8
+ * 内);服务端伪客户端是 `scheduler:`/`plugin:` 前缀(冒号在字符集内),但那些
9
+ * 不走 WS hello——收进字符集只是不挡未来的复用,不构成放行面。
10
+ */
11
+ /** clientId 长度上限:uuid 36 字符的数倍余量,防超长串写盘/进日志。 */
12
+ export const MAX_CLIENT_ID_LEN = 128;
13
+ /** clientId 字符集:uuid(含 -)、伪客户端前缀(含 :)、下划线。 */
14
+ const CLIENT_ID_RE = /^[A-Za-z0-9:_-]+$/;
15
+ /**
16
+ * 校验 hello.clientId:必须是 string、1-128 字符、限定字符集。
17
+ * 合法原样返回;不合法(类型不对/空串/超长/含非法字符)返回 null,
18
+ * 调用方回退 randomUUID()——校验必须在 hello 入口统一做(服务端所有把
19
+ * clientId 落盘/当目录名的路径都从 attach 这一条链来)。
20
+ */
21
+ export function validateClientId(input) {
22
+ if (typeof input !== "string")
23
+ return null;
24
+ if (input.length < 1 || input.length > MAX_CLIENT_ID_LEN)
25
+ return null;
26
+ if (!CLIENT_ID_RE.test(input))
27
+ return null;
28
+ return input;
29
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * attach 完成前暂存客户端命令的队列 —— 独立成模块是为了可单测
3
+ * (`server/index.ts` 起 WS + SDK,不适合在单测里 import)。
4
+ *
5
+ * 背景:hello 先回 ready(issue #295)后,attach/插件链就绪前收到的命令一律进
6
+ * pending 队列等重放;attach 挂死(坏挂载/家目录扫描数十秒)或失败保活时队列
7
+ * 原先无上限——失控/恶意连接可在此期间灌消息把内存顶爆。加上限后超限丢最旧并
8
+ * 计数(做法对齐 ws-unknown-types 的「上限 + 溢出计数 + 节流 warn」先例)。
9
+ */
10
+ /** 队列上限:正常前端在 attach 窗口内只发 get_state 等零星几条,256 绰绰有余。 */
11
+ export const MAX_PENDING_WS_MESSAGES = 256;
12
+ /** 同一进程两次超限告警的最小间隔(防刷屏,对齐 ws-unknown-types 的节流思路)。 */
13
+ export const PENDING_DROP_WARN_INTERVAL_MS = 60_000;
14
+ let lastWarnAt = 0;
15
+ /** 超限丢弃的节流告警:`PENDING_DROP_WARN_INTERVAL_MS` 内最多一条。log 注入便于单测。 */
16
+ export function warnPendingDrop(totalDropped, log) {
17
+ const now = Date.now();
18
+ if (now - lastWarnAt < PENDING_DROP_WARN_INTERVAL_MS)
19
+ return;
20
+ lastWarnAt = now;
21
+ log(`[ws] attach 未完成,命令队列超限丢最旧(累计 ${totalDropped} 条)`);
22
+ }
23
+ /** 单测隔离用。 */
24
+ export function resetPendingDropWarn() {
25
+ lastWarnAt = 0;
26
+ }
27
+ /** attach 前的命令暂存队列:超限丢最旧(shift),丢弃只计数,不阻断新消息入队。 */
28
+ export class PendingCommandQueue {
29
+ cap;
30
+ items = [];
31
+ droppedTotal = 0;
32
+ constructor(cap = MAX_PENDING_WS_MESSAGES) {
33
+ this.cap = cap;
34
+ }
35
+ push(msg) {
36
+ if (this.items.length >= this.cap) {
37
+ this.items.shift();
38
+ this.droppedTotal++;
39
+ warnPendingDrop(this.droppedTotal, (line) => console.warn(line));
40
+ }
41
+ this.items.push(msg);
42
+ }
43
+ /** 取走全部并清空(attach 完成后的重放)。 */
44
+ drain() {
45
+ const out = this.items;
46
+ this.items = [];
47
+ return out;
48
+ }
49
+ /** attach 失败保活 / 连接关闭时清空(用户修好后重发的 get_state 会重新入队)。 */
50
+ clear() {
51
+ this.items = [];
52
+ }
53
+ /** 累计丢弃条数(排障用)。 */
54
+ get dropped() {
55
+ return this.droppedTotal;
56
+ }
57
+ get size() {
58
+ return this.items.length;
59
+ }
60
+ }
@@ -17,8 +17,9 @@
17
17
  * - 每个 pi 会话管理一个子进程;session_shutdown 时清理,避免孤儿进程。
18
18
  */
19
19
 
20
- import { spawn } from "node:child_process";
20
+ import { spawn, execSync } from "node:child_process";
21
21
  import { existsSync } from "node:fs";
22
+ import { homedir } from "node:os";
22
23
  import { dirname, join, resolve } from "node:path";
23
24
  import { fileURLToPath } from "node:url";
24
25
  import net from "node:net";
@@ -27,7 +28,63 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
27
28
  // 本文件位于 <pkg>/extensions/webui.ts → 包根在上一级
28
29
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
30
  const SERVER_ENTRY = join(PKG_ROOT, "dist", "server", "index.js");
30
- const NODE = process.execPath;
31
+
32
+ /**
33
+ * 解析真实的 node 可执行路径。
34
+ *
35
+ * pi 0.87+ 的 TUI 版本是 Bun 打包的二进制(pi.exe),此时 process.execPath 指向
36
+ * pi.exe 而非 node。用 pi.exe 去 spawn(index.js) 会静默失败(pi.exe 不能当
37
+ * node 执行脚本)。此函数在 Bun 环境或 execPath 非 node 时,从 PATH 和常见安装
38
+ * 位置回退查找真实 node。
39
+ */
40
+ function resolveNode(): string {
41
+ const base = process.execPath.toLowerCase();
42
+ // 普通 node 直接返回(未被 Bun 接管)
43
+ if (!(process as any).isBun && /(^|[/\\])(node|nodejs)(\.exe)?$/.test(base)) {
44
+ return process.execPath;
45
+ }
46
+
47
+ const candidates: string[] = [];
48
+ // 1. 从 PATH 里找(跨平台:Windows 用 where,其余用 which)
49
+ try {
50
+ const cmd = process.platform === "win32" ? "where node" : "which node";
51
+ const out = execSync(cmd, { windowsHide: true }).toString().trim();
52
+ for (const line of out.split(/\r?\n/)) {
53
+ const p = line.trim();
54
+ // 过滤掉 pi 自身
55
+ if (p && !/pi[/\\]pi(\.exe)?$/i.test(p)) candidates.push(p);
56
+ }
57
+ } catch {
58
+ /* node 不在 PATH */
59
+ }
60
+
61
+ // 2. 常见安装位置兜底(Windows 为主,也兼顾 Unix)
62
+ const home = homedir();
63
+ const extra: string[] = [];
64
+ if (process.env.PNPM_HOME) extra.push(join(process.env.PNPM_HOME, "node.exe"));
65
+ for (const rel of [
66
+ "scoop/apps/nodejs/current/node.exe",
67
+ "AppData/Roaming/nvm/current/node.exe",
68
+ ".nvm/versions/node/current/bin/node",
69
+ ".local/share/pnpm/node.exe",
70
+ ".local/bin/node",
71
+ ]) {
72
+ extra.push(join(home, ...rel.split("/")));
73
+ }
74
+ for (const c of extra) {
75
+ if (existsSync(c) && !/pi[/\\]pi(\.exe)?$/i.test(c)) candidates.push(c);
76
+ }
77
+
78
+ if (candidates.length > 0) return candidates[0];
79
+
80
+ console.warn(
81
+ "[webui] 未找到真实 node(当前 process.execPath 指向非 node 进程,疑似 Bun 环境)。" +
82
+ "将退回 process.execPath,子进程可能无法启动。请确保 node 在 PATH 中。",
83
+ );
84
+ return process.execPath;
85
+ }
86
+
87
+ const NODE = resolveNode();
31
88
 
32
89
  /** 每个会话的服务器子进程 + 元数据 */
33
90
  interface RunningServer {
@@ -161,6 +218,7 @@ export default function (pi: ExtensionAPI): void {
161
218
  const env = {
162
219
  ...process.env,
163
220
  PORT: String(port),
221
+ PI_WEB_PORT: String(port), // server/index.js 读取 PI_WEB_PORT
164
222
  PI_WEB_CWD: cwd,
165
223
  ...(process.env.PI_WEB_DATA_DIR ? {} : { PI_WEB_DATA_DIR: join(cwd, ".pi-web") }),
166
224
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.94.1",
3
+ "version": "0.96.0",
4
4
  "description": "Just open your browser to get all your work done — browser cockpit for AI coding agents (chat, code, files, terminal, Git in one tab)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -67,12 +67,13 @@
67
67
  "test": "vitest run",
68
68
  "test:unit": "vitest run",
69
69
  "test:smoke": "node tests/run-smoke.mjs",
70
+ "test:smoke:core": "node tests/run-smoke.mjs --core",
70
71
  "check:protocol": "node scripts/check-protocol-sync.mjs",
71
72
  "i18n:diff": "node scripts/i18n-diff.mjs",
72
73
  "release:notes": "node scripts/release-notes.mjs",
73
74
  "changelog:i18n": "node scripts/release-notes.mjs --unreleased",
74
75
  "test:freeze": "node tests/freeze-test.mjs",
75
- "start": "node dist/server/index.js",
76
+ "start": "node --import ./dist/server/resolve-global-sdk.js dist/server/index.js",
76
77
  "build:desktop": "tsc -p desktop/tsconfig.json",
77
78
  "desktop:dev": "npm run build:desktop && electron dist/desktop/main.js",
78
79
  "desktop:dist": "npm run build && npm run build:desktop && electron-builder -c desktop/electron-builder.yml",
@@ -84,7 +85,7 @@
84
85
  "dependencies": {
85
86
  "@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
86
87
  "@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
87
- "@earendil-works/pi-coding-agent": ">=0.85.1 <0.87.0",
88
+ "@earendil-works/pi-coding-agent": ">=0.85.1",
88
89
  "@xterm/addon-fit": "^0.11.0",
89
90
  "@xterm/xterm": "^6.0.0",
90
91
  "compression": "^1.8.2",
@@ -101,6 +102,7 @@
101
102
  "rehype-highlight": "^7.0.2",
102
103
  "rehype-katex": "^7.0.1",
103
104
  "rehype-raw": "^7.0.0",
105
+ "rehype-sanitize": "^6.0.0",
104
106
  "remark-breaks": "^4.0.0",
105
107
  "remark-gfm": "^4.0.1",
106
108
  "remark-math": "^6.0.0",
@@ -125,4 +125,29 @@ describe("my-plugin", () => {
125
125
  | `schedule` 持久版(`{id, persistent, catchUp, label}`,落盘+补跑+进后台面板) | ✅ | — |
126
126
  | `host.requestPermission`(动态授权:net 补主机 / llm 限模型,用户逐条确认可记住) | ✅(基础族须已声明) | 确认框 + 设置面板「已授权能力」 |
127
127
 
128
+ ## 反激活与清理:`host.effect(label, dispose)`
129
+
130
+ 宿主的每个注册面(`ui.register` / `registerAgentTool` / `registerCommand` / `route` /
131
+ `registerProxy` / `fs.watch` / `schedule` / `registerBackgroundTask` / `events.on` /
132
+ `onStats` / `onStreaming` / `onSettingsChanged` …)都返回注销函数,**并且**已经在宿主内部
133
+ 登记进该插件的 effect 栈:反激活(禁用、卸载、`plugins_reload`)时**逆序回卷**,插件忘了
134
+ 调用返回的注销函数也不会留下孤儿订阅/定时器/路由。
135
+
136
+ 你自己建的东西(`setInterval` / `addEventListener` / WebSocket / 自建缓存)挂在
137
+ `host.effect` 上同样享受这条保障:
138
+
139
+ ```js
140
+ export default definePlugin({
141
+ async activate(host) {
142
+ const timer = setInterval(() => host.log("info", "tick"), 60_000);
143
+ host.effect("my-timer", () => clearInterval(timer));
144
+ // 也可以两者都写(幂等:dispose 写成本身幂等即可)
145
+ const off = host.effect("my-ws", () => ws.close());
146
+ },
147
+ });
148
+ ```
149
+
150
+ 清理抛错只记一条诊断、不阻断其它清理;`host.effect` 返回的注销函数与反激活都会调
151
+ `dispose`,所以 `dispose` 请写成**幂等**的。
152
+
128
153
  完整契约见 `docs/architecture-plugins.md`。
@@ -5,16 +5,7 @@
5
5
  */
6
6
 
7
7
  export type PluginPermissionFamily =
8
- | "fs"
9
- | "fs:read"
10
- | "fs:write"
11
- | "ui"
12
- | "tools"
13
- | "http"
14
- | "chat"
15
- | "net"
16
- | "dom"
17
- | "dom:anchor";
8
+ "fs" | "fs:read" | "fs:write" | "ui" | "tools" | "http" | "chat" | "net" | "dom" | "dom:anchor";
18
9
 
19
10
  export interface WsEntry {
20
11
  name: string;
@@ -35,17 +26,7 @@ export interface UiSelectOption {
35
26
  }
36
27
 
37
28
  export type UiItemKind =
38
- | "view"
39
- | "action"
40
- | "badge"
41
- | "menu"
42
- | "page"
43
- | "organizer"
44
- | "divider"
45
- | "toggle"
46
- | "input"
47
- | "progress"
48
- | "select";
29
+ "view" | "action" | "badge" | "menu" | "page" | "organizer" | "divider" | "toggle" | "input" | "progress" | "select";
49
30
 
50
31
  export interface UiContribution {
51
32
  id: string;
@@ -152,13 +133,7 @@ export interface PluginHostUi {
152
133
  /** host.llm.complete:孤立无工具的一次性补全(不建对话、不进历史)。
153
134
  * 需要 manifest.permissions 含 "llm"(花用户自己的模型额度)。 */
154
135
  export interface PluginHostLlm {
155
- complete(req: {
156
- prompt: string;
157
- system?: string;
158
- model?: string;
159
- maxChars?: number;
160
- timeoutMs?: number;
161
- }): Promise<{
136
+ complete(req: { prompt: string; system?: string; model?: string; maxChars?: number; timeoutMs?: number }): Promise<{
162
137
  ok: boolean;
163
138
  text?: string;
164
139
  model?: string;
@@ -167,6 +142,19 @@ export interface PluginHostLlm {
167
142
  }>;
168
143
  }
169
144
 
145
+ /** 工具拦截守卫看到的 pre 请求(只对 bash/read 生效,全量见 server/plugin-tool-guard.ts)。 */
146
+ export interface ToolGuardPreRequest {
147
+ toolName: "bash" | "read";
148
+ params: unknown;
149
+ conversationId?: string;
150
+ }
151
+
152
+ /** 工具 pre 决策:allow 放行 / deny 拒绝 / ask 待确认(暂按拒绝执行)。 */
153
+ export type ToolGuardPreDecision =
154
+ | { decision: "allow" }
155
+ | { decision: "deny"; reason?: string; reasonEn?: string }
156
+ | { decision: "ask"; reason?: string; reasonEn?: string };
157
+
170
158
  /** 插件服务端入口拿到的宿主接口(精简:全量见 server/plugins.ts PluginHost)。 */
171
159
  export interface PluginHost {
172
160
  broadcast(payload: unknown): void;
@@ -175,6 +163,12 @@ export interface PluginHost {
175
163
  sendTo(clientId: string, payload: unknown): void;
176
164
  onAttach(handler: (clientId: string) => void): () => void;
177
165
  onToolEvent(handler: (ev: unknown) => void): () => void;
166
+ /** 注册工具 pre 拦截守卫(只对 bash/read 生效;要 "tools" 能力)。 */
167
+ onToolPre(
168
+ handler: (req: ToolGuardPreRequest) => ToolGuardPreDecision | void | Promise<ToolGuardPreDecision | void>,
169
+ ): () => void;
170
+ /** 注册工具 post 编辑守卫(只对 bash/read 生效;要 "tools" 能力)。 */
171
+ onToolPost(handler: (req: unknown) => unknown): () => void;
178
172
  onRunEvent(handler: (ev: unknown) => void): () => void;
179
173
  getActiveConversation(): unknown;
180
174
  onConversationChanged(handler: () => void): () => void;
@@ -219,6 +213,11 @@ export interface PluginHost {
219
213
  * 200 条,单条截断 500 字符,不落盘);error 级同时走 console.error。
220
214
  * 用户在设置面板“界面插件”页点某插件的“日志”按需查看(级别过滤 + 清空)。 */
221
215
  log(level?: "debug" | "info" | "warn" | "error", ...args: unknown[]): void;
216
+ /** 登记一条**自建**的可逆副作用(自建 setInterval / event 监听 / WebSocket…):
217
+ * 返回的注销函数与插件反激活**都会**调 dispose。宿主自己的每个注册面已在内部
218
+ * 走同一个 effect 栈,这里只用于「宿主管不到的那些」—— 挂进来就不怕漏注销
219
+ * (热重载后定时器叠加、监听器堆积都是这个漏法的症状)。dispose 请写成幂等的。 */
220
+ effect(label: string, dispose: () => void): () => void;
222
221
  /** 无头调用:把外部通道文本投给 agent(要 "chat" 能力;宿主未接 chatProvider
223
222
  * 时 reject(由 chatWait 包成 {ok:false},插件侧用 chatWait 更省心)。 */
224
223
  chat(req: { text: string; accountId?: string }): Promise<{
@@ -259,12 +258,7 @@ export interface PluginHost {
259
258
  }>;
260
259
  };
261
260
  /** 常驻后台任务(顶栏「后台任务」面板;返回 update/unregister)。 */
262
- registerBackgroundTask(task: {
263
- id: string;
264
- label: string;
265
- stop?: () => void;
266
- status?: string;
267
- }): {
261
+ registerBackgroundTask(task: { id: string; label: string; stop?: () => void; status?: string }): {
268
262
  update(next: Partial<{ label: string; status: string; stop: () => void }>): void;
269
263
  unregister(): void;
270
264
  };
@@ -340,10 +334,9 @@ export declare function defineView(view: PluginViewModule): PluginViewModule;
340
334
  export declare function defineRenderer(
341
335
  renderers: Record<string, (code: string, ctx: PluginViewContext) => HTMLElement | null>,
342
336
  ): Pick<PluginViewModule, "renderers">;
343
- export declare function actionHandler(map: Record<string, (value?: string) => void>): (
344
- itemId: string,
345
- value?: string,
346
- ) => void;
337
+ export declare function actionHandler(
338
+ map: Record<string, (value?: string) => void>,
339
+ ): (itemId: string, value?: string) => void;
347
340
  export declare function onUiAction(action: string, handler: (itemId: string, value?: string) => void): () => void;
348
341
  export declare function getSetting<T>(host: PluginHost, key: string, fallback: T): T;
349
342
  export declare function selectOptions(list: Array<string | UiSelectOption>): UiSelectOption[];
@@ -211,18 +211,20 @@ export function createMockHost(overrides) {
211
211
  // 订阅类:存 handler + 返回注销函数(调用本身由外层统一记进 calls)。
212
212
  // handler 缺省取首参;fs.watch(path, handler) / events.on(topic, handler) /
213
213
  // shortcuts.register(key, handler) 这类回调在第二参,用 sub(method, 1)。
214
- const sub = (method, argIndex = 0) => (...args) => {
215
- const handler = args[argIndex];
216
- if (typeof handler === "function") handlersOf(method).push(handler);
217
- let done = false;
218
- return () => {
219
- if (done) return;
220
- done = true;
221
- const arr = handlersOf(method);
222
- const i = arr.indexOf(handler);
223
- if (i >= 0) arr.splice(i, 1);
214
+ const sub =
215
+ (method, argIndex = 0) =>
216
+ (...args) => {
217
+ const handler = args[argIndex];
218
+ if (typeof handler === "function") handlersOf(method).push(handler);
219
+ let done = false;
220
+ return () => {
221
+ if (done) return;
222
+ done = true;
223
+ const arr = handlersOf(method);
224
+ const i = arr.indexOf(handler);
225
+ if (i >= 0) arr.splice(i, 1);
226
+ };
224
227
  };
225
- };
226
228
  const logImpl = (levelOrArg, ...args) => {
227
229
  const levels = ["debug", "info", "warn", "error"];
228
230
  const level = levels.includes(levelOrArg) ? levelOrArg : "info";
@@ -243,12 +245,16 @@ export function createMockHost(overrides) {
243
245
  try {
244
246
  console.error("[mock-host]", text);
245
247
  } catch {
246
- /* 打印失败不影响单测 */
248
+ /* 打印失败不影响单测 */
247
249
  }
248
250
  }
249
251
  };
250
252
  const statImpl = (p) => ({
251
- name: String(p ?? "").split(/[\\/]/).filter(Boolean).pop() ?? "",
253
+ name:
254
+ String(p ?? "")
255
+ .split(/[\\/]/)
256
+ .filter(Boolean)
257
+ .pop() ?? "",
252
258
  type: "file",
253
259
  size: 0,
254
260
  mtime: 0,
@@ -264,6 +270,7 @@ export function createMockHost(overrides) {
264
270
  const routeStore = [];
265
271
  const proxyStore = [];
266
272
  const scheduleStore = [];
273
+ const effectStore = [];
267
274
  // 注册类:存定义体 + 返回只摘除本次注册的注销函数(与宿主同语义)。
268
275
  const remember = (store, entry) => {
269
276
  if (entry !== undefined) store.push(entry);
@@ -283,6 +290,8 @@ export function createMockHost(overrides) {
283
290
  sendTo: noop,
284
291
  onAttach: sub("onAttach"),
285
292
  onToolEvent: sub("onToolEvent"),
293
+ onToolPre: sub("onToolPre"),
294
+ onToolPost: sub("onToolPost"),
286
295
  onRunEvent: sub("onRunEvent"),
287
296
  getActiveConversation: () => null,
288
297
  chat: async () => okFalse("chatProvider(无头调用未接入)"),
@@ -313,8 +322,7 @@ export function createMockHost(overrides) {
313
322
  const list = Array.isArray(items) ? items : [items];
314
323
  const added = [];
315
324
  for (const raw of list.slice(0, 32)) {
316
- const id =
317
- raw && typeof raw.id === "string" && raw.id ? raw.id : `mock-${++uiAutoId}`;
325
+ const id = raw && typeof raw.id === "string" && raw.id ? raw.id : `mock-${++uiAutoId}`;
318
326
  uiItems.set(id, isPlainObject(raw) ? { ...raw, id } : { id });
319
327
  added.push(id);
320
328
  }
@@ -421,6 +429,12 @@ export function createMockHost(overrides) {
421
429
  on: sub("events.on", 1),
422
430
  },
423
431
  log: logImpl,
432
+ // effect 栈:mock 里只记调用 + 返回可撤函数(真实宿主在反激活时逆序回卷)。
433
+ // 单测里测试 dispose 真跑的写法:拿到 off() 后调它,或断言 host.calls 里有 effect。
434
+ effect: (label, dispose) => {
435
+ const entry = { label, dispose };
436
+ return remember(effectStore, entry);
437
+ },
424
438
  // 浏览器桥兼容(client 侧逻辑单测也能用同一个 mock):无注入回退语义。
425
439
  dialogs: {
426
440
  select: async () => ({ ok: false }),
@@ -527,10 +541,12 @@ export function createMockHost(overrides) {
527
541
  }
528
542
  }
529
543
  // 记录层:所有 host 方法(mock/reset 除外)先记 calls 再委托。
530
- const record = (method, fn) => (...args) => {
531
- calls.push({ method, args, seq: calls.length });
532
- return fn(...args);
533
- };
544
+ const record =
545
+ (method, fn) =>
546
+ (...args) => {
547
+ calls.push({ method, args, seq: calls.length });
548
+ return fn(...args);
549
+ };
534
550
  for (const [key, value] of Object.entries(host)) {
535
551
  if (key === "calls" || key === "logs" || key === "mock" || key === "reset") continue;
536
552
  if (typeof value === "function") {
@@ -109,6 +109,16 @@
109
109
  "source": "xing-shuyin/pi-web-ui/plugins/notes",
110
110
  "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/notes"
111
111
  },
112
+ {
113
+ "id": "desktop-use",
114
+ "name": "桌面控制",
115
+ "icon": "🖱️",
116
+ "iconSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"5\" y=\"2\" width=\"14\" height=\"20\" rx=\"7\"/><line x1=\"12\" y1=\"6\" x2=\"12\" y2=\"10\"/></svg>",
117
+ "description": "让 AI 操作 Windows 桌面:按元素名查控件坐标(UI Automation,无需截图)→ 点击 / 输入(含中文粘贴)/ 按键 / 聚焦窗口。输入库缺失时自动安装,卸载时连带删除。",
118
+ "descriptionEn": "Let the AI drive the Windows desktop: find controls by name via UI Automation (no screenshots) → click / type (CJK via paste) / hotkeys / focus windows. The input library auto-installs on activate and is removed with the plugin.",
119
+ "source": "xing-shuyin/pi-web-ui/plugins/desktop-use",
120
+ "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/desktop-use"
121
+ },
112
122
  {
113
123
  "id": "pi-web-multigit",
114
124
  "name": "multi-git",
@@ -118,5 +128,35 @@
118
128
  "descriptionEn": "Multi-repo Git overview: changes, diffs, history, branches, stashes, cross-repo search and a timeline for every repository in the workspace, plus a workspace-wide branch view, Fetch/Pull and an optional terminal.",
119
129
  "source": "EinErste/pi-web-multigit",
120
130
  "homepage": "https://github.com/EinErste/pi-web-multigit"
131
+ },
132
+ {
133
+ "id": "reconnect",
134
+ "name": "重连",
135
+ "icon": "🔄",
136
+ "iconSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/><path d=\"M3 21v-5h5\"/></svg>",
137
+ "description": "断连恢复:查看网页服务与重启守护状态,一键重新连接或重启网页服务(支持单服务/全服务重启,配套可选独立守护进程)。",
138
+ "descriptionEn": "Reconnection & service recovery: inspect web service and companion watchdog health, reconnect or restart services in one click (supports multi-service restarts with companion watchdog).",
139
+ "source": "xieweimo/pi-web-ui-reconnect-watchdog/reconnect-plugin",
140
+ "homepage": "https://github.com/xieweimo/pi-web-ui-reconnect-watchdog"
141
+ },
142
+ {
143
+ "id": "sol-savings",
144
+ "name": "SoL-Pi 状态",
145
+ "icon": "⚡",
146
+ "iconSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/></svg>",
147
+ "description": "SoL-Pi 会话节省统计与规划底栏:实时追踪大工具输出打包(Observation Pack)节省的上下文 Token 与在线计划(Plan)进度,借鉴 pi-sol-plan-footer 在底栏显示。",
148
+ "descriptionEn": "SoL-Pi session savings and plan status in the footer bar: real-time tracking of context tokens saved by Observation Pack and active Plan progress, inspired by pi-sol-plan-footer.",
149
+ "source": "xing-shuyin/pi-web-ui/plugins/sol-savings",
150
+ "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/sol-savings"
151
+ },
152
+ {
153
+ "id": "session-migrate",
154
+ "name": "会话迁移",
155
+ "icon": "🔄",
156
+ "iconSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/><path d=\"M8 16H3v5\"/></svg>",
157
+ "description": "扫描 ~/.omp/agent/sessions 与 ~/.pi/agent/sessions 的历史会话,只读预览后一键导入 pi 会话库(issue 286 Phase 1)。",
158
+ "descriptionEn": "Scan ~/.omp/agent/sessions and ~/.pi/agent/sessions history, preview read-only, then import into the pi session store (issue 286 Phase 1).",
159
+ "source": "xing-shuyin/pi-web-ui/plugins/session-migrate",
160
+ "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/session-migrate"
121
161
  }
122
162
  ]