pi-web-ui 0.96.1 → 0.98.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.
- package/CHANGELOG.md +144 -2
- package/README.md +29 -3
- package/README.zh-CN.md +28 -3
- package/bin/pi-web-ui.mjs +75 -25
- package/dist/server/agent-service.js +1829 -262
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +296 -11
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +148 -0
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +143 -0
- package/dist/server/delegate-task.js +10 -10
- package/dist/server/dsh/dsh-agent-service.js +66 -31
- package/dist/server/dsh/dsh-client.js +48 -10
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +11 -5
- package/dist/server/goal-review-gate.js +250 -0
- package/dist/server/goal-service.js +1272 -448
- package/dist/server/index.js +118 -16
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +123 -24
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -28
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-bridge.js +13 -0
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +0 -3
- package/dist/server/patch-tool.js +22 -6
- package/dist/server/patch-turn-end-boundary.js +77 -0
- package/dist/server/permission-preset.js +69 -0
- package/dist/server/plan-manager.js +150 -5
- package/dist/server/plan-mode.js +414 -0
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +39 -8
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +22 -5
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +33 -9
- package/dist/server/settings-service.js +18 -6
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +379 -416
- package/dist/server/terminals.js +11 -6
- package/dist/server/text-quote.js +59 -0
- package/dist/server/text-sniff.js +15 -9
- package/dist/server/tool-manager.js +60 -7
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +80 -28
- package/dist/server/wait-subscription-scan.js +5 -1
- package/extensions/webui.ts +15 -1
- package/package.json +33 -21
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/themes/zhupi-dark.css +5 -30
- package/themes/zhupi.css +5 -30
- package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
- package/web/dist/assets/index-2jDtjHvW.css +1 -0
- package/web/dist/assets/index-BjdK7s1M.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-B7Zp23Vc.css +0 -1
- package/web/dist/assets/index-ByfoaDwp.js +0 -366
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - 只有真正发生变更的写才落盘(临时文件 + rename 原子写);写失败 best-effort
|
|
14
14
|
* (内存态仍生效,本次会话可用)。
|
|
15
15
|
*/
|
|
16
|
-
import { readFileSync
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
18
19
|
const FILE = "plugin-dom.json";
|
|
19
20
|
/** manifest.permissions 条目 → 是否请求特权 DOM(只看 `:` 前的族名,与宿主 can() 同口径)。
|
|
20
21
|
* 例外:`dom:anchor` 只是客户端锚点挂载点的范围 DOM(免用户授权),不触发 bundle 403;
|
|
@@ -71,9 +72,7 @@ export class PluginDomConsent {
|
|
|
71
72
|
else
|
|
72
73
|
this.granted.delete(id);
|
|
73
74
|
try {
|
|
74
|
-
|
|
75
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, granted: this.list() }), "utf8");
|
|
76
|
-
renameSync(tmp, join(this.dataDir, FILE));
|
|
75
|
+
writeJsonAtomicSync(join(this.dataDir, FILE), { v: 1, granted: this.list() });
|
|
77
76
|
}
|
|
78
77
|
catch {
|
|
79
78
|
/* 写失败 best-effort:内存态仍生效,本次会话可用 */
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
|
|
17
17
|
*/
|
|
18
18
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
|
-
import { chmodSync, existsSync,
|
|
19
|
+
import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
20
20
|
import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile, appendFile as fspAppendFile, stat as fspStat, } from "node:fs/promises";
|
|
21
21
|
import { dirname, join, relative, resolve } from "node:path";
|
|
22
|
+
import { writeAtomicSync } from "./atomic-file.js";
|
|
22
23
|
import { createRequire } from "node:module";
|
|
23
24
|
import { spawnSync } from "node:child_process";
|
|
24
25
|
// 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
|
|
@@ -26,10 +27,7 @@ import { spawnSync } from "node:child_process";
|
|
|
26
27
|
import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
|
|
27
28
|
/** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
|
|
28
29
|
function atomicWrite(file, data) {
|
|
29
|
-
|
|
30
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
31
|
-
writeFileSync(tmp, data);
|
|
32
|
-
renameSync(tmp, file);
|
|
30
|
+
writeAtomicSync(file, data);
|
|
33
31
|
}
|
|
34
32
|
// ---------------------------------------------------------------------------
|
|
35
33
|
// storage.json 的进程内「读-改-写」互斥
|
|
@@ -303,14 +301,47 @@ export function depName(spec) {
|
|
|
303
301
|
function str(v) {
|
|
304
302
|
return typeof v === "string" ? v.trim() : "";
|
|
305
303
|
}
|
|
306
|
-
/**
|
|
304
|
+
/** 纯包名(`foo` / `@scope/foo`):能按 `node_modules/<name>/package.json` 判盘。
|
|
305
|
+
* `node:path`、URL、相对/绝对路径、git spec 一律 false(交给 resolve 判)。 */
|
|
306
|
+
function isPlainPackageName(name) {
|
|
307
|
+
return /^(?:@[a-z\d](?:[a-z\d._-]*[a-z\d])?\/)?[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i.test(name);
|
|
308
|
+
}
|
|
309
|
+
/** 从 fromDir 沿目录树向上找 `node_modules/<name>/package.json` 是否已落盘。 */
|
|
310
|
+
function depOnDisk(fromDir, name) {
|
|
311
|
+
let dir = fromDir;
|
|
312
|
+
for (;;) {
|
|
313
|
+
try {
|
|
314
|
+
if (existsSync(join(dir, "node_modules", ...name.split("/"), "package.json")))
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
/* 权限/坏路径:当作这一层没有,继续往上找 */
|
|
319
|
+
}
|
|
320
|
+
const up = dirname(dir);
|
|
321
|
+
if (!up || up === dir)
|
|
322
|
+
return false;
|
|
323
|
+
dir = up;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。
|
|
327
|
+
*
|
|
328
|
+
* resolve 失败后再看一眼「文件是否已落盘」,这不是冗余判据而是**修 bug**(issue #383):
|
|
329
|
+
* Node 的 CJS 解析会把 `node_modules/<pkg>/package.json` 不存在的**负结果**缓存在
|
|
330
|
+
* 进程内存里(packageJsonReader 的路径缓存)。于是「探测缺失 → npm install 成功 →
|
|
331
|
+
* 复查」这组三连里,最后一次复查永远命中那条负缓存,恒抛
|
|
332
|
+
* `Cannot find module`,用户看到的是「装成功了却报 npm install 没跑通」,
|
|
333
|
+
* 且非重启整个进程不能恢复。落盘判据不碰 CJS 缓存,天然免疫。
|
|
334
|
+
*
|
|
335
|
+
* 顺序保持「先 resolve 后判盘」:解析成功仍然是最强的信号(含 exports 映射等
|
|
336
|
+
* 盘上判据覆盖不到的形状),判盘只是给解析的假阴性兜底,不会放宽真缺失。 */
|
|
307
337
|
export function isDepAvailable(pluginDir, spec) {
|
|
338
|
+
const name = depName(spec);
|
|
308
339
|
try {
|
|
309
|
-
createRequire(join(pluginDir, "index.mjs")).resolve(
|
|
340
|
+
createRequire(join(pluginDir, "index.mjs")).resolve(name);
|
|
310
341
|
return true;
|
|
311
342
|
}
|
|
312
343
|
catch {
|
|
313
|
-
return
|
|
344
|
+
return isPlainPackageName(name) && depOnDisk(pluginDir, name);
|
|
314
345
|
}
|
|
315
346
|
}
|
|
316
347
|
const depInstallLocks = new Map();
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
* 与尾部分隔符(Windows 文件系统不区分大小写),但**存储保持用户写入时的形式**
|
|
37
37
|
* (不 lowerCase),否则设置面板里 `C:\Users\Foo` 会显示成 `c:\users\foo`。
|
|
38
38
|
*/
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
40
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
41
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
41
42
|
/** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
|
|
42
43
|
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
43
44
|
/** 去掉尾部分隔符(保留根:「C:\」/「/」不能退化)。 */
|
|
@@ -93,13 +94,9 @@ function isSameOrInside(targetKey, ancestorKey) {
|
|
|
93
94
|
const prefix = ancestorKey.endsWith(sep) ? ancestorKey : ancestorKey + sep;
|
|
94
95
|
return targetKey.startsWith(prefix);
|
|
95
96
|
}
|
|
96
|
-
/** 原子写:临时文件 + rename
|
|
97
|
-
* 它,避免为一个 3 行函数制造模块耦合)。 */
|
|
97
|
+
/** 原子写:临时文件 + rename。 */
|
|
98
98
|
function atomicWriteJson(filePath, data) {
|
|
99
|
-
|
|
100
|
-
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
101
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
102
|
-
renameSync(tmp, filePath);
|
|
99
|
+
writeJsonAtomicSync(filePath, data);
|
|
103
100
|
}
|
|
104
101
|
/** 读文件并净化为内存表:任何异常(不存在 / 权限 / JSON 坏 / 形状不对)→ 空表。
|
|
105
102
|
* 条目逐个过 normalizeGrantPath(顺手兼容手工写入的相对路径、重复项、坏 id)。 */
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - 内存 session 授权(仅本次运行,进程结束即失)。
|
|
14
14
|
* 读坏/形状不对当空表,且不在读路径回写;只有真正变更的写才落盘(tmp+rename 原子写)。
|
|
15
15
|
*/
|
|
16
|
-
import { readFileSync
|
|
17
|
-
import {
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
18
19
|
/** 主机匹配(与 manifest netAllowlist 同口径):全等或点号后缀。纯函数,单测覆盖。 */
|
|
19
20
|
export function permissionHostMatches(hostname, entry) {
|
|
20
21
|
const h = String(hostname ?? "").toLowerCase();
|
|
@@ -63,10 +64,7 @@ export class PluginPermissionStore {
|
|
|
63
64
|
}
|
|
64
65
|
write(grants) {
|
|
65
66
|
try {
|
|
66
|
-
|
|
67
|
-
const tmp = `${this.file()}.tmp-${process.pid}`;
|
|
68
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, grants }));
|
|
69
|
-
renameSync(tmp, this.file());
|
|
67
|
+
writeJsonAtomicSync(this.file(), { v: 1, grants });
|
|
70
68
|
}
|
|
71
69
|
catch (err) {
|
|
72
70
|
console.error(`[plugin-permissions] persist failed (${this.file()}):`, err);
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* 上次触发 lastRun;fn 回调永远由插件代码在 activate 里重给(重启后重建)。
|
|
11
11
|
* 坏文件当空表(不回写,避免一次磁盘抖动清空全部定时)。
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync
|
|
14
|
-
import {
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
15
16
|
const MONTH_NAMES = {
|
|
16
17
|
jan: 1,
|
|
17
18
|
feb: 2,
|
|
@@ -224,11 +225,7 @@ export function loadScheduleRecords(pluginDir) {
|
|
|
224
225
|
/** 写持久记录(tmp+rename 原子写;失败只记日志,内存态本次会话仍生效)。 */
|
|
225
226
|
export function saveScheduleRecords(pluginDir, records) {
|
|
226
227
|
try {
|
|
227
|
-
|
|
228
|
-
const file = scheduleFile(pluginDir);
|
|
229
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
230
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, schedules: records }));
|
|
231
|
-
renameSync(tmp, file);
|
|
228
|
+
writeJsonAtomicSync(scheduleFile(pluginDir), { v: 1, schedules: records });
|
|
232
229
|
}
|
|
233
230
|
catch (err) {
|
|
234
231
|
console.error(`[plugin-schedule] persist failed (${pluginDir}):`, err);
|
|
@@ -8,13 +8,29 @@
|
|
|
8
8
|
* <dataDir>/plugin-backups/<id>-<ts>/ 覆盖安装前的旧版本快照(保留最近 N 份)
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
11
|
+
import { join, resolve, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { execFile } from "node:child_process";
|
|
13
14
|
import { pick } from "./i18n.js";
|
|
14
15
|
import { parseInstallSpec, manifestCandidateUrls } from "./plugin-install-spec.js";
|
|
15
16
|
const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
16
17
|
/** 保留的备份份数(超出删除最旧的)。 */
|
|
17
18
|
export const BACKUP_KEEP = 3;
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
/**
|
|
21
|
+
* 解析宿主随包根目录(优先环境变量 PI_WEB_PKG_ROOT,次之向上查找包含 package.json 的祖先目录)。
|
|
22
|
+
*/
|
|
23
|
+
export function resolveDefaultPkgRoot() {
|
|
24
|
+
if (process.env.PI_WEB_PKG_ROOT && existsSync(process.env.PI_WEB_PKG_ROOT)) {
|
|
25
|
+
return process.env.PI_WEB_PKG_ROOT;
|
|
26
|
+
}
|
|
27
|
+
const candidates = [resolve(here, ".."), resolve(here, "..", ".."), resolve(here, "..", "..", "..")];
|
|
28
|
+
for (const c of candidates) {
|
|
29
|
+
if (existsSync(join(c, "package.json")))
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
18
34
|
/** 默认执行器:execFile 直跑 git(不经 shell),15s 超时。 */
|
|
19
35
|
export const execGit = (cmd, args) => new Promise((resolve) => {
|
|
20
36
|
execFile(cmd, args, { timeout: 15_000, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
@@ -151,25 +167,98 @@ export async function resolveRemoteSha(spec, exec = execGit) {
|
|
|
151
167
|
const sha = res.stdout.match(/^([0-9a-f]{40,64})\s+HEAD/m)?.[1] ?? null;
|
|
152
168
|
return sha ? sha.slice(0, 12) : null;
|
|
153
169
|
}
|
|
154
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* 语义化版本(SemVer)比较器:
|
|
172
|
+
* - 剥离前缀 `v` / `V` 及首尾空白;
|
|
173
|
+
* - 剥离 `+` 后的构建元数据(build metadata 在优先级比较中被忽略);
|
|
174
|
+
* - 分离主要数字版本与预发布标识(prerelease,如 -beta.1);
|
|
175
|
+
* - 支持任意段数的数字比对;
|
|
176
|
+
* - 符合 SemVer 规范:正式版 > 预发布版(例如 1.0.0 > 1.0.0-beta.1);
|
|
177
|
+
* - 返回值:>0 代表 a 比 b 新;<0 代表 a 比 b 旧;0 代表版本一致。
|
|
178
|
+
*/
|
|
155
179
|
export function compareVersions(a, b) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
180
|
+
const clean = (s) => String(s ?? "")
|
|
181
|
+
.trim()
|
|
182
|
+
.replace(/^[vV]/, "")
|
|
183
|
+
.split("+")[0]
|
|
184
|
+
.trim();
|
|
185
|
+
const cleanA = clean(a);
|
|
186
|
+
const cleanB = clean(b);
|
|
187
|
+
if (cleanA === cleanB)
|
|
188
|
+
return 0;
|
|
189
|
+
if (!cleanA && !cleanB)
|
|
190
|
+
return 0;
|
|
191
|
+
if (!cleanA)
|
|
192
|
+
return -1;
|
|
193
|
+
if (!cleanB)
|
|
194
|
+
return 1;
|
|
195
|
+
// 拆分主版本段与 prerelease 段
|
|
196
|
+
const dashA = cleanA.indexOf("-");
|
|
197
|
+
const mainAStr = dashA >= 0 ? cleanA.slice(0, dashA) : cleanA;
|
|
198
|
+
const preA = dashA >= 0 ? cleanA.slice(dashA + 1) : null;
|
|
199
|
+
const dashB = cleanB.indexOf("-");
|
|
200
|
+
const mainBStr = dashB >= 0 ? cleanB.slice(0, dashB) : cleanB;
|
|
201
|
+
const preB = dashB >= 0 ? cleanB.slice(dashB + 1) : null;
|
|
202
|
+
// 比较主版本数字段
|
|
203
|
+
const segsA = mainAStr.split(".").map((n) => parseInt(n, 10) || 0);
|
|
204
|
+
const segsB = mainBStr.split(".").map((n) => parseInt(n, 10) || 0);
|
|
205
|
+
const maxLen = Math.max(segsA.length, segsB.length);
|
|
206
|
+
for (let i = 0; i < maxLen; i++) {
|
|
207
|
+
const x = segsA[i] ?? 0;
|
|
208
|
+
const y = segsB[i] ?? 0;
|
|
161
209
|
if (x !== y)
|
|
162
210
|
return x - y;
|
|
163
211
|
}
|
|
212
|
+
// 主版本数字段全相同时,比较 prerelease(正式版高于任何 prerelease 版)
|
|
213
|
+
if (preA === null && preB !== null)
|
|
214
|
+
return 1;
|
|
215
|
+
if (preA !== null && preB === null)
|
|
216
|
+
return -1;
|
|
217
|
+
if (preA !== null && preB !== null) {
|
|
218
|
+
const partsA = preA.split(".");
|
|
219
|
+
const partsB = preB.split(".");
|
|
220
|
+
const maxPreLen = Math.max(partsA.length, partsB.length);
|
|
221
|
+
for (let i = 0; i < maxPreLen; i++) {
|
|
222
|
+
const pa = partsA[i];
|
|
223
|
+
const pb = partsB[i];
|
|
224
|
+
if (pa === undefined)
|
|
225
|
+
return -1;
|
|
226
|
+
if (pb === undefined)
|
|
227
|
+
return 1;
|
|
228
|
+
if (pa === pb)
|
|
229
|
+
continue;
|
|
230
|
+
const isNumA = /^\d+$/.test(pa);
|
|
231
|
+
const isNumB = /^\d+$/.test(pb);
|
|
232
|
+
if (isNumA && isNumB) {
|
|
233
|
+
const diff = parseInt(pa, 10) - parseInt(pb, 10);
|
|
234
|
+
if (diff !== 0)
|
|
235
|
+
return diff;
|
|
236
|
+
}
|
|
237
|
+
else if (isNumA && !isNumB) {
|
|
238
|
+
return -1; // 数字标识优先级低于非数字标识
|
|
239
|
+
}
|
|
240
|
+
else if (!isNumA && isNumB) {
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
return pa.localeCompare(pb);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
164
248
|
return 0;
|
|
165
249
|
}
|
|
166
250
|
/** 判断某个插件是否属于随包维护的内置插件(官方插件)。 */
|
|
167
251
|
export function isBuiltinPlugin(id, source, builtinCatalogPath) {
|
|
168
252
|
if (source && /xing-shuyin\/pi-web-ui\/plugins\//i.test(source))
|
|
169
253
|
return true;
|
|
170
|
-
|
|
254
|
+
const catPath = builtinCatalogPath ??
|
|
255
|
+
(() => {
|
|
256
|
+
const pkgRoot = resolveDefaultPkgRoot();
|
|
257
|
+
return pkgRoot ? join(pkgRoot, "plugins", "catalog.json") : undefined;
|
|
258
|
+
})();
|
|
259
|
+
if (catPath && existsSync(catPath)) {
|
|
171
260
|
try {
|
|
172
|
-
const raw = JSON.parse(readFileSync(
|
|
261
|
+
const raw = JSON.parse(readFileSync(catPath, "utf8"));
|
|
173
262
|
if (Array.isArray(raw)) {
|
|
174
263
|
return raw.some((e) => e?.id === id);
|
|
175
264
|
}
|
|
@@ -180,9 +269,23 @@ export function isBuiltinPlugin(id, source, builtinCatalogPath) {
|
|
|
180
269
|
}
|
|
181
270
|
return false;
|
|
182
271
|
}
|
|
183
|
-
/**
|
|
272
|
+
/** 探测远端或本地源 manifest 中的版本号(本地路径 / raw.githubusercontent.com / 注入的 fetcher)。 */
|
|
184
273
|
async function fetchRemoteVersion(source, fetcher) {
|
|
185
274
|
const spec = parseInstallSpec(source);
|
|
275
|
+
if (spec.kind === "path" && spec.normalized) {
|
|
276
|
+
const manifestFile = join(spec.normalized, "manifest.json");
|
|
277
|
+
if (existsSync(manifestFile)) {
|
|
278
|
+
try {
|
|
279
|
+
const data = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
280
|
+
if (typeof data?.version === "string" && data.version.trim()) {
|
|
281
|
+
return data.version.trim();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
186
289
|
const urls = manifestCandidateUrls(spec);
|
|
187
290
|
if (urls.length === 0)
|
|
188
291
|
return null;
|
|
@@ -212,6 +315,8 @@ export async function checkPluginUpdates(dataDir, exec = execGit,
|
|
|
212
315
|
/** error 字段文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
|
|
213
316
|
lang, opts) {
|
|
214
317
|
const l = lang?.() ?? "en";
|
|
318
|
+
const effectivePkgRoot = opts?.pkgRoot ?? resolveDefaultPkgRoot();
|
|
319
|
+
const effectiveCatalogPath = opts?.builtinCatalogPath ?? (effectivePkgRoot ? join(effectivePkgRoot, "plugins", "catalog.json") : undefined);
|
|
215
320
|
const pluginsDir = join(dataDir, "plugins");
|
|
216
321
|
let names = [];
|
|
217
322
|
try {
|
|
@@ -235,23 +340,27 @@ lang, opts) {
|
|
|
235
340
|
localSha = readFileSync(join(dir, ".pi-git-sha"), "utf8").trim() || null;
|
|
236
341
|
}
|
|
237
342
|
catch {
|
|
238
|
-
localSha = null; // 无 sha 记录
|
|
343
|
+
localSha = null; // 无 sha 记录
|
|
239
344
|
}
|
|
240
345
|
let name;
|
|
241
346
|
let version;
|
|
242
347
|
try {
|
|
243
348
|
const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
244
349
|
name = m.name;
|
|
245
|
-
version
|
|
350
|
+
if (typeof m.version === "string" && m.version.trim()) {
|
|
351
|
+
version = m.version.trim();
|
|
352
|
+
}
|
|
246
353
|
}
|
|
247
354
|
catch {
|
|
248
355
|
/* 坏 manifest:仍报告 */
|
|
249
356
|
}
|
|
250
|
-
const
|
|
357
|
+
const parsedSpec = parseInstallSpec(source);
|
|
358
|
+
const isSubpath = Boolean(parsedSpec.subpath);
|
|
359
|
+
const builtin = isBuiltinPlugin(n, source, effectiveCatalogPath);
|
|
251
360
|
let latestVersion = null;
|
|
252
|
-
//
|
|
253
|
-
if (
|
|
254
|
-
const localPkgManifest = join(
|
|
361
|
+
// 随包或本地开发模式:若宿主包自带 plugins/<id>/manifest.json,可直接读随包最新版本号
|
|
362
|
+
if (effectivePkgRoot) {
|
|
363
|
+
const localPkgManifest = join(effectivePkgRoot, "plugins", n, "manifest.json");
|
|
255
364
|
if (existsSync(localPkgManifest)) {
|
|
256
365
|
try {
|
|
257
366
|
const rawPkg = JSON.parse(readFileSync(localPkgManifest, "utf8"));
|
|
@@ -264,15 +373,18 @@ lang, opts) {
|
|
|
264
373
|
}
|
|
265
374
|
}
|
|
266
375
|
}
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
376
|
+
// 尝试通过 fetcher 探测远端 manifest.json,若拿到了更高版本则采用更高者
|
|
377
|
+
try {
|
|
378
|
+
const remoteVer = await fetchRemoteVersion(source, opts?.fetcher);
|
|
379
|
+
if (remoteVer) {
|
|
380
|
+
if (!latestVersion || compareVersions(remoteVer, latestVersion) > 0) {
|
|
381
|
+
latestVersion = remoteVer;
|
|
382
|
+
}
|
|
274
383
|
}
|
|
275
384
|
}
|
|
385
|
+
catch {
|
|
386
|
+
/* ignore */
|
|
387
|
+
}
|
|
276
388
|
let remoteSha = null;
|
|
277
389
|
let error;
|
|
278
390
|
try {
|
|
@@ -282,27 +394,28 @@ lang, opts) {
|
|
|
282
394
|
error = err instanceof Error ? err.message : String(err);
|
|
283
395
|
remoteSha = null;
|
|
284
396
|
}
|
|
285
|
-
if (!remoteSha && !latestVersion && !error)
|
|
286
|
-
error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
|
|
287
397
|
let updatable = false;
|
|
288
398
|
if (latestVersion && version) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
else if (cmp === 0 && remoteSha && localSha) {
|
|
294
|
-
updatable = localSha !== remoteSha;
|
|
295
|
-
}
|
|
296
|
-
else if (cmp === 0 && remoteSha && !localSha) {
|
|
297
|
-
updatable = true;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
else if (remoteSha) {
|
|
301
|
-
updatable = !localSha || localSha !== remoteSha;
|
|
399
|
+
// 两端都有版本号:版本号是唯一权威事实源。
|
|
400
|
+
// 严格大于才判定为有更新(cmp > 0)。
|
|
401
|
+
// 相同或更低(cmp <= 0)坚决判定为无更新,彻底避免主仓库无关 commit 触发的假阳性误报。
|
|
402
|
+
updatable = compareVersions(latestVersion, version) > 0;
|
|
302
403
|
}
|
|
303
404
|
else if (latestVersion && !version) {
|
|
405
|
+
// 远端有版本号但本地无版本号:视为可更新
|
|
304
406
|
updatable = true;
|
|
305
407
|
}
|
|
408
|
+
else if (!isSubpath && remoteSha) {
|
|
409
|
+
// 仅在未能获取最新版本号且为独立仓库源(非子目录)时,才回退到基于 git commit SHA 比对
|
|
410
|
+
updatable = !localSha || localSha !== remoteSha;
|
|
411
|
+
}
|
|
412
|
+
if (!updatable && !latestVersion && !remoteSha && !error) {
|
|
413
|
+
error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
|
|
414
|
+
}
|
|
415
|
+
else if (!updatable && isSubpath && !latestVersion && !error) {
|
|
416
|
+
// 子目录源插件未能获取远端清单时,不凭仓库根 SHA 误报,而是提示未能获取清单
|
|
417
|
+
error = pick(l, "无法检查(未能获取远端插件清单)", "Cannot check (failed to fetch remote plugin manifest)", "pluginupdate.subpath.manifest.failed");
|
|
418
|
+
}
|
|
306
419
|
out.push({
|
|
307
420
|
id: n,
|
|
308
421
|
name,
|
package/dist/server/plugins.js
CHANGED
|
@@ -275,6 +275,8 @@ export const UI_SLOTS = new Set([
|
|
|
275
275
|
"goalbar.actions",
|
|
276
276
|
"notice.actions",
|
|
277
277
|
"modal.dialog",
|
|
278
|
+
"sidebar.left",
|
|
279
|
+
"sidebar.right",
|
|
278
280
|
]);
|
|
279
281
|
/** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
|
|
280
282
|
export const UI_SLOT_ALIASES = {
|
|
@@ -285,6 +287,11 @@ export const UI_SLOT_ALIASES = {
|
|
|
285
287
|
rightpanel: "rightpanel.tabs",
|
|
286
288
|
settings: "settings.pages",
|
|
287
289
|
modal: "modal.dialog",
|
|
290
|
+
sidebar: "sidebar.left",
|
|
291
|
+
"sidebar.left": "sidebar.left",
|
|
292
|
+
"sidebar.right": "sidebar.right",
|
|
293
|
+
leftbar: "sidebar.left",
|
|
294
|
+
rightbar: "sidebar.right",
|
|
288
295
|
};
|
|
289
296
|
/** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
|
|
290
297
|
export const UI_KINDS = new Set([
|
|
@@ -1473,7 +1480,11 @@ export class PluginManager {
|
|
|
1473
1480
|
params: freezeParams(req.params),
|
|
1474
1481
|
conversationId: req.conversationId,
|
|
1475
1482
|
};
|
|
1476
|
-
|
|
1483
|
+
// #489:对 this.loaded 取快照再迭代——下面的 withGuardTimeout 是 await 点,
|
|
1484
|
+
// 期间插件 reload/uninstall 会原地 clear 同一个 Map(实例不变),Map 迭代器
|
|
1485
|
+
// 立即 done → 剩余插件的 pre 守卫一次都不被求值 → fail-open 直接放行。
|
|
1486
|
+
const loadedSnapshot = [...this.loaded.values()];
|
|
1487
|
+
for (const p of loadedSnapshot) {
|
|
1477
1488
|
for (const h of p.preGuards ?? []) {
|
|
1478
1489
|
let raw;
|
|
1479
1490
|
try {
|
|
@@ -1510,7 +1521,10 @@ export class PluginManager {
|
|
|
1510
1521
|
result: req.result,
|
|
1511
1522
|
conversationId: req.conversationId,
|
|
1512
1523
|
};
|
|
1513
|
-
|
|
1524
|
+
// #489:同 evaluateToolPre——await 点期间 reload 原地 clear Map 会让迭代器
|
|
1525
|
+
// 提前终止,取快照迭代保证本轮请求遍历的是发起时的插件集合。
|
|
1526
|
+
const postLoadedSnapshot = [...this.loaded.values()];
|
|
1527
|
+
for (const p of postLoadedSnapshot) {
|
|
1514
1528
|
for (const h of p.postGuards ?? []) {
|
|
1515
1529
|
let raw;
|
|
1516
1530
|
try {
|
|
@@ -1763,11 +1777,14 @@ export class PluginManager {
|
|
|
1763
1777
|
}
|
|
1764
1778
|
return out;
|
|
1765
1779
|
}
|
|
1766
|
-
/** 当前全部插件注册的 AI 工具(扁平化,按插件 id
|
|
1780
|
+
/** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序,附带所属 pluginId)。 */
|
|
1767
1781
|
getAgentTools() {
|
|
1768
1782
|
const out = [];
|
|
1769
|
-
for (const table of [...this.agentTools.
|
|
1770
|
-
|
|
1783
|
+
for (const [pid, table] of [...this.agentTools.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1784
|
+
for (const tool of table.values()) {
|
|
1785
|
+
out.push({ ...tool, pluginId: pid });
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1771
1788
|
return out;
|
|
1772
1789
|
}
|
|
1773
1790
|
/** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
|
|
@@ -55,9 +55,6 @@ export function isReadonlyPromptSource(token) {
|
|
|
55
55
|
* SDK 默认拼装的完整提示词。 */
|
|
56
56
|
export const DEFAULT_PROMPT_TEMPLATE = PROMPT_TOKENS.map((t) => `{{${t}}}`).join("\n\n");
|
|
57
57
|
const TOKEN_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
58
|
-
export function isKnownToken(name) {
|
|
59
|
-
return PROMPT_TOKENS.includes(name);
|
|
60
|
-
}
|
|
61
58
|
/** 模板里出现的全部 token(含未知名,供 UI 提示)。 */
|
|
62
59
|
export function collectTemplateTokens(template) {
|
|
63
60
|
const out = [];
|
|
@@ -258,3 +255,15 @@ export function estimatePromptTokens(text) {
|
|
|
258
255
|
const cjk = text.match(CJK_RE)?.length ?? 0;
|
|
259
256
|
return Math.ceil(cjk + (text.length - cjk) / 4);
|
|
260
257
|
}
|
|
258
|
+
/** 把「早前扩展改过的提示词」拆成 SDK 原始提示词 + 扩展的首尾增补。
|
|
259
|
+
* before_agent_start 是链式的:我们是最后一个 handler,返回 systemPrompt 等于
|
|
260
|
+
* forceSystemPrompt 整体替换,会把别的扩展(如注入 <invoked_skill> 的)写进去的
|
|
261
|
+
* 内容一起丢掉。只要原始提示词还原样嵌在里面(纯前置/后置增补),就能把增补摘出来
|
|
262
|
+
* 重新套回我们的渲染结果上。摘不出来(中间插入 / 被整体替换)时返回 pre/post 为空、
|
|
263
|
+
* core 为当前文本 —— 调用方退回今天的行为,绝不把整段提示词翻倍。 */
|
|
264
|
+
export function splitExtensionWrap(baseline, current) {
|
|
265
|
+
const at = baseline ? current.indexOf(baseline) : -1;
|
|
266
|
+
if (at === -1)
|
|
267
|
+
return { pre: "", core: current, post: "" };
|
|
268
|
+
return { pre: current.slice(0, at), core: baseline, post: current.slice(at + baseline.length) };
|
|
269
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* 或前台启动时在 import 服务入口之前先 import 它)。
|
|
27
27
|
*/
|
|
28
28
|
import { registerHooks } from "node:module";
|
|
29
|
+
import { dirname } from "node:path";
|
|
29
30
|
import { pathToFileURL } from "node:url";
|
|
30
31
|
import { compareVersions, sdkCopies } from "./sdk-origin.js";
|
|
31
32
|
const PKG = "@earendil-works/pi-coding-agent";
|
|
@@ -40,6 +41,13 @@ export function pickGlobalSdk(copies, mode) {
|
|
|
40
41
|
if (BUNDLED_MODES.has((mode ?? "global").trim().toLowerCase()))
|
|
41
42
|
return null;
|
|
42
43
|
const bundled = copies[0];
|
|
44
|
+
// 显式透传的宿主 SDK 路径(如 extensions/webui.ts 宿主传递,issue #482):若不降级优先选用
|
|
45
|
+
if (process.env.PI_WEB_SDK_DIR) {
|
|
46
|
+
const hostCopy = copies.find((c) => dirname(c.path) === process.env.PI_WEB_SDK_DIR);
|
|
47
|
+
if (hostCopy && (!bundled || compareVersions(hostCopy.version, bundled.version) >= 0)) {
|
|
48
|
+
return hostCopy;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
43
51
|
// 只考虑祖先链上**严格更新**的副本,多份取版本最高的(旧的/同版本的都不折腾)。
|
|
44
52
|
let best = null;
|
|
45
53
|
for (const copy of copies.slice(1)) {
|
|
@@ -20,7 +20,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { pick } from "./i18n.js";
|
|
22
22
|
import { SCHEDULE_CANCEL_TOOL_NAME, SCHEDULE_LIST_TOOL_NAME, SCHEDULE_TASK_TOOL_NAME } from "./tool-manager.js";
|
|
23
|
-
import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, } from "./scheduler-tasks.js";
|
|
23
|
+
import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, SchedulerValidationError, } from "./scheduler-tasks.js";
|
|
24
24
|
import { parseCronSpec } from "./plugin-schedule.js";
|
|
25
25
|
/** 用户写法 → 调度规格。纯函数,可单测。
|
|
26
26
|
* 收:5 字段 cron("0 * * * *")、相对时间("in 30m" / "in 1h" / "in 2d")、
|
|
@@ -180,6 +180,10 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
catch (err) {
|
|
183
|
+
const L = getLang();
|
|
184
|
+
if (err instanceof SchedulerValidationError) {
|
|
185
|
+
return text(L === "zh" ? err.messageZh : err.messageEn);
|
|
186
|
+
}
|
|
183
187
|
return text(String(err?.message ?? err));
|
|
184
188
|
}
|
|
185
189
|
const L = getLang();
|