pi-web-ui 0.97.0 → 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 +97 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-web-ui.mjs +64 -23
- package/dist/server/agent-service.js +637 -145
- 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 +220 -23
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +7 -5
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +6 -12
- package/dist/server/dsh/dsh-agent-service.js +32 -30
- package/dist/server/dsh/dsh-client.js +45 -9
- 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 +8 -4
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +49 -5
- package/dist/server/index.js +83 -15
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +110 -14
- 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 -26
- 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-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 +21 -6
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +150 -5
- package/dist/server/plan-mode.js +58 -20
- 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 +3 -5
- 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 +16 -2
- 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 +22 -9
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +22 -11
- package/dist/server/terminals.js +11 -6
- package/dist/server/text-quote.js +59 -0
- package/dist/server/tool-manager.js +12 -3
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +69 -25
- package/extensions/webui.ts +15 -1
- package/package.json +31 -19
- 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/web/dist/assets/{TerminalPanel-CITBqQrb.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-DwToli0J.css +0 -1
- package/web/dist/assets/index-oI7UKOqJ.js +0 -368
|
@@ -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 {
|
|
@@ -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();
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* 定时策略:单个 10s ticker 检查到期任务(cron 下次触发 / 间隔毫秒),
|
|
11
11
|
* running 集合防重叠;catchUp "once" 在启动时补跑一次漏掉的触发。
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { nextCronFire, parseCronSpec } from "./plugin-schedule.js";
|
|
16
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
16
17
|
export const SCHEDULER_FILE = "scheduler-tasks.json";
|
|
17
18
|
/** 持久间隔底线 60s(与 host.schedule 持久任务同口径,防 token 烧穿)。 */
|
|
18
19
|
export const SCHEDULER_MIN_INTERVAL_MS = 60_000;
|
|
@@ -30,50 +31,72 @@ function randomTaskId() {
|
|
|
30
31
|
s += abc[Math.floor(Math.random() * abc.length)];
|
|
31
32
|
return `task-${s}`;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
export class SchedulerValidationError extends Error {
|
|
35
|
+
code;
|
|
36
|
+
messageZh;
|
|
37
|
+
messageEn;
|
|
38
|
+
constructor(code, messageZh, messageEn) {
|
|
39
|
+
super(messageZh);
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.messageZh = messageZh;
|
|
42
|
+
this.messageEn = messageEn;
|
|
43
|
+
this.name = "SchedulerValidationError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** 归一化 + 校验用户输入;非法抛 SchedulerValidationError。纯函数,可单测。 */
|
|
34
47
|
export function normalizeSchedulerInput(input, now = Date.now()) {
|
|
35
48
|
const idRaw = String(input.id ?? "").trim();
|
|
36
49
|
const id = idRaw || randomTaskId();
|
|
37
|
-
if (!SCHEDULER_ID_RE.test(id))
|
|
38
|
-
throw new
|
|
50
|
+
if (!SCHEDULER_ID_RE.test(id)) {
|
|
51
|
+
throw new SchedulerValidationError("invalid_id", "任务 id 非法(只收字母/数字/下划线/连字符,≤64 字)", "Invalid task id (letters/numbers/underscores/hyphens only, <= 64 chars)");
|
|
52
|
+
}
|
|
39
53
|
const name = String(input.name ?? "")
|
|
40
54
|
.trim()
|
|
41
55
|
.slice(0, 80);
|
|
42
|
-
if (!name)
|
|
43
|
-
throw new
|
|
56
|
+
if (!name) {
|
|
57
|
+
throw new SchedulerValidationError("empty_name", "任务名称不能为空", "Task name cannot be empty");
|
|
58
|
+
}
|
|
44
59
|
const description = String(input.description ?? "")
|
|
45
60
|
.trim()
|
|
46
61
|
.slice(0, 500);
|
|
47
62
|
const cwd = String(input.cwd ?? "").trim();
|
|
48
|
-
if (!cwd)
|
|
49
|
-
throw new
|
|
63
|
+
if (!cwd) {
|
|
64
|
+
throw new SchedulerValidationError("empty_cwd", "执行目标项目(cwd)不能为空", "Target project directory (cwd) cannot be empty");
|
|
65
|
+
}
|
|
50
66
|
const kind = input.kind === "interval" ? "interval" : "cron";
|
|
51
67
|
let spec;
|
|
52
68
|
if (kind === "cron") {
|
|
53
69
|
spec = String(input.spec ?? "")
|
|
54
70
|
.trim()
|
|
55
71
|
.replace(/\s+/g, " ");
|
|
56
|
-
if (!parseCronSpec(spec))
|
|
57
|
-
throw new
|
|
72
|
+
if (!parseCronSpec(spec)) {
|
|
73
|
+
throw new SchedulerValidationError("invalid_cron", "cron 表达式非法(要 5 字段:分 时 日 月 周,如 0 9 * * *)", "Invalid cron expression (requires 5 fields: min hour day month weekday, e.g. 0 9 * * *)");
|
|
74
|
+
}
|
|
58
75
|
}
|
|
59
76
|
else {
|
|
60
77
|
const ms = Math.floor(Number(input.spec));
|
|
61
|
-
if (!Number.isFinite(ms) || ms <= 0)
|
|
62
|
-
throw new
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
79
|
+
throw new SchedulerValidationError("invalid_interval", "间隔毫秒数非法", "Invalid interval milliseconds");
|
|
80
|
+
}
|
|
81
|
+
if (ms < SCHEDULER_MIN_INTERVAL_MS) {
|
|
82
|
+
throw new SchedulerValidationError("interval_too_short", `间隔太短(最短 ${SCHEDULER_MIN_INTERVAL_MS / 1000}s,防 token 烧穿)`, `Interval too short (minimum ${SCHEDULER_MIN_INTERVAL_MS / 1000}s)`);
|
|
83
|
+
}
|
|
84
|
+
if (ms > SCHEDULER_MAX_INTERVAL_MS) {
|
|
85
|
+
throw new SchedulerValidationError("interval_too_long", "间隔太长(最长 30 天)", "Interval too long (maximum 30 days)");
|
|
86
|
+
}
|
|
67
87
|
spec = String(ms);
|
|
68
88
|
}
|
|
69
89
|
const prompt = String(input.prompt ?? "").trim();
|
|
70
|
-
if (!prompt)
|
|
71
|
-
throw new
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
if (!prompt) {
|
|
91
|
+
throw new SchedulerValidationError("empty_prompt", "触发指令(prompt)不能为空", "Trigger instruction (prompt) cannot be empty");
|
|
92
|
+
}
|
|
93
|
+
if (prompt.length > 8000) {
|
|
94
|
+
throw new SchedulerValidationError("prompt_too_long", "触发指令超长(>8000 字),请裁剪后重试", "Trigger instruction too long (>8000 characters), please shorten and retry");
|
|
95
|
+
}
|
|
74
96
|
const model = String(input.model ?? "").trim();
|
|
75
|
-
if (model && !model.includes("/"))
|
|
76
|
-
throw new
|
|
97
|
+
if (model && !model.includes("/")) {
|
|
98
|
+
throw new SchedulerValidationError("invalid_model", "模型格式非法(应为 provider/id)", "Invalid model format (expected provider/id)");
|
|
99
|
+
}
|
|
77
100
|
const thinkingLevel = String(input.thinkingLevel ?? "").trim();
|
|
78
101
|
const catchUp = input.catchUp === "once" ? "once" : "skip";
|
|
79
102
|
const conversationId = String(input.conversationId ?? "")
|
|
@@ -267,14 +290,10 @@ export class SchedulerStore {
|
|
|
267
290
|
}
|
|
268
291
|
save() {
|
|
269
292
|
try {
|
|
270
|
-
mkdirSync(this.dataDir, { recursive: true });
|
|
271
|
-
const file = schedulerFile(this.dataDir);
|
|
272
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
273
293
|
const tasks = {};
|
|
274
294
|
for (const [id, t] of this.tasks)
|
|
275
295
|
tasks[id] = t;
|
|
276
|
-
|
|
277
|
-
renameSync(tmp, file);
|
|
296
|
+
writeJsonAtomicSync(schedulerFile(this.dataDir), { v: 1, tasks });
|
|
278
297
|
}
|
|
279
298
|
catch (err) {
|
|
280
299
|
console.error("[scheduler] persist failed:", err);
|
|
@@ -37,6 +37,33 @@ export function sdkCopies(fromFile = import.meta.url) {
|
|
|
37
37
|
break;
|
|
38
38
|
dir = parent;
|
|
39
39
|
}
|
|
40
|
+
// 显式透传的宿主 SDK 路径(如 extensions/webui.ts 宿主传递,issue #482)
|
|
41
|
+
if (process.env.PI_WEB_SDK_DIR) {
|
|
42
|
+
const pj = join(process.env.PI_WEB_SDK_DIR, "package.json");
|
|
43
|
+
if (existsSync(pj) && !out.some((c) => c.path === pj)) {
|
|
44
|
+
try {
|
|
45
|
+
const version = JSON.parse(readFileSync(pj, "utf8")).version;
|
|
46
|
+
if (typeof version === "string" && version) {
|
|
47
|
+
out.push({ path: pj, version });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 宿主 Pi Node 路径探测(~/.local/share/pi-node/current/...,issue #482)
|
|
54
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
55
|
+
if (home) {
|
|
56
|
+
const hostPj = join(home, ".local", "share", "pi-node", "current", "lib", "node_modules", PKG, "package.json");
|
|
57
|
+
if (existsSync(hostPj) && !out.some((c) => c.path === hostPj)) {
|
|
58
|
+
try {
|
|
59
|
+
const version = JSON.parse(readFileSync(hostPj, "utf8")).version;
|
|
60
|
+
if (typeof version === "string" && version) {
|
|
61
|
+
out.push({ path: hostPj, version });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
40
67
|
return out;
|
|
41
68
|
}
|
|
42
69
|
/** 点分版本号比较(够用即可:只比数字段,缺位当 0)。 */
|