pi-web-ui 0.90.1 → 0.91.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 +48 -1
- package/dist/server/agent-service.js +763 -61
- package/dist/server/client-state.js +70 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +21 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +120 -30
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugins.js +47 -8
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/settings-service.js +40 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/tool-manager.js +7 -1
- package/package.json +2 -1
- package/plugins/catalog.json +10 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-CdsKltOS.css +1 -0
- package/web/dist/assets/index-OoULNF70.js +363 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -212,6 +212,16 @@ function cookieToken(req) {
|
|
|
212
212
|
}
|
|
213
213
|
return "";
|
|
214
214
|
}
|
|
215
|
+
/** Express 5 命名通配 `*splat` 的取值:单段是字符串,多段是字符串数组
|
|
216
|
+
* (issue #225:直接 String() 会把多段用逗号拼成 "a,b.mjs",插件 vendor 子
|
|
217
|
+
* 目录、嵌套文件预览、插件子路径 API 全 404)。统一拼回 "/" 即得 Express 4
|
|
218
|
+
* 语义;下游既有的越界/包含校验(workspacePath / resolvePluginClientFile)不变。 */
|
|
219
|
+
function splatParam(req) {
|
|
220
|
+
const v = req.params.splat;
|
|
221
|
+
if (Array.isArray(v))
|
|
222
|
+
return v.join("/");
|
|
223
|
+
return String(v ?? "");
|
|
224
|
+
}
|
|
215
225
|
if (AUTH_TOKEN) {
|
|
216
226
|
// /api/health 保持开放:无敏感信息,容器/监控探针需要它。
|
|
217
227
|
// 但绝不能因命中 /api/health 就反射下发真实 token cookie(安全漏洞:issue #45)。
|
|
@@ -318,7 +328,10 @@ app.get("/api/file", async (req, res) => {
|
|
|
318
328
|
if (isDownload) {
|
|
319
329
|
// res.download sets Content-Disposition: attachment and RFC 5987
|
|
320
330
|
// filename* encoding for non-ASCII names.
|
|
321
|
-
|
|
331
|
+
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
|
|
332
|
+
// 工作区/数据目录常位于隐藏目录下(如 ~/.pi-web),绝对路径含点号段会被判 404。
|
|
333
|
+
// 路径已由上方的 workspacePath/isAbsoluteWirePath 做工作区 containment 校验,放行安全。
|
|
334
|
+
res.download(abs, name, { dotfiles: "allow" });
|
|
322
335
|
}
|
|
323
336
|
else {
|
|
324
337
|
if (isHtmlPreview) {
|
|
@@ -335,7 +348,7 @@ app.get("/api/file", async (req, res) => {
|
|
|
335
348
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
336
349
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
337
350
|
}
|
|
338
|
-
res.sendFile(abs);
|
|
351
|
+
res.sendFile(abs, { dotfiles: "allow" });
|
|
339
352
|
}
|
|
340
353
|
}
|
|
341
354
|
catch {
|
|
@@ -360,8 +373,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
360
373
|
*/
|
|
361
374
|
app.get("/api/preview/*splat", async (req, res) => {
|
|
362
375
|
try {
|
|
363
|
-
//
|
|
364
|
-
const captured =
|
|
376
|
+
// 多段路径的 splat 是数组(见 splatParam),拼回 "/" 后才是线形路径。
|
|
377
|
+
const captured = splatParam(req);
|
|
365
378
|
const ABS_MARKER = "__abs__/";
|
|
366
379
|
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
367
380
|
const cs = cid ? service.get(cid) : undefined;
|
|
@@ -399,7 +412,7 @@ app.get("/api/preview/*splat", async (req, res) => {
|
|
|
399
412
|
const allowJs = req.query.allowJs === "1";
|
|
400
413
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
401
414
|
}
|
|
402
|
-
res.sendFile(abs);
|
|
415
|
+
res.sendFile(abs, { dotfiles: "allow" });
|
|
403
416
|
}
|
|
404
417
|
catch {
|
|
405
418
|
res.status(404).end("not found");
|
|
@@ -498,7 +511,10 @@ app.get("/themes/:id.css", (req, res) => {
|
|
|
498
511
|
}
|
|
499
512
|
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
500
513
|
res.setHeader("Cache-Control", "no-cache");
|
|
501
|
-
|
|
514
|
+
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,主题文件位于
|
|
515
|
+
// 隐藏目录下(如 ~/.pi-web/themes、npm 全局目录 ~/.local/…)时会被判 404。路径已由
|
|
516
|
+
// resolveThemeFile 校验(id 白名单 + 仅已知目录 + isFile),放行安全。
|
|
517
|
+
res.sendFile(file, { dotfiles: "allow" });
|
|
502
518
|
});
|
|
503
519
|
// Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
|
|
504
520
|
// /plugins/<id>/client/* so the frontend can import() plugin views. Only the
|
|
@@ -510,8 +526,8 @@ const PLUGINS_DIR = join(DATA_DIR, "plugins");
|
|
|
510
526
|
// /plugins-api/<id>/inbox。PI_WEB_TOKEN 鉴权(上方 app.use)自动覆盖;
|
|
511
527
|
// 响应已在前面过了 express.json。注意不要在此 catch-all 里消费 body。
|
|
512
528
|
app.all(["/plugins-api/:id/*splat", "/plugins-api/:id"], (req, res) => {
|
|
513
|
-
//
|
|
514
|
-
const rest =
|
|
529
|
+
// 多段子路径的 splat 是数组(见 splatParam),拼回 "/" 后再交插件路由。
|
|
530
|
+
const rest = splatParam(req);
|
|
515
531
|
pluginMgr.handleHttp(String(req.params.id ?? ""), req.method, rest, req, res);
|
|
516
532
|
});
|
|
517
533
|
function proxyForwardPath(hit, url) {
|
|
@@ -584,8 +600,8 @@ app.use((req, res, next) => {
|
|
|
584
600
|
proxyHttp(hit, req, res);
|
|
585
601
|
});
|
|
586
602
|
app.get("/plugins/:id/client/*splat", (req, res) => {
|
|
587
|
-
//
|
|
588
|
-
const rest =
|
|
603
|
+
// 多段子路径(插件 vendor/分包)的 splat 是数组(见 splatParam),拼回 "/"。
|
|
604
|
+
const rest = splatParam(req);
|
|
589
605
|
// 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
|
|
590
606
|
// (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
|
|
591
607
|
if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
|
|
@@ -602,7 +618,8 @@ app.get("/plugins/:id/client/*splat", (req, res) => {
|
|
|
602
618
|
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
603
619
|
}
|
|
604
620
|
res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
|
|
605
|
-
|
|
621
|
+
// dotfiles: allow — issue #223:插件目录默认在 ~/.pi-web/plugins(隐藏目录段),同上需放行。
|
|
622
|
+
res.sendFile(abs, { dotfiles: "allow" }, (err) => {
|
|
606
623
|
if (err && !res.headersSent)
|
|
607
624
|
res
|
|
608
625
|
.status(err.statusCode === 404 ? 404 : 500)
|
|
@@ -659,7 +676,8 @@ if (existsSync(webDist)) {
|
|
|
659
676
|
const stored = readDevNoCacheSetting();
|
|
660
677
|
const noStore = stored ?? envDefault;
|
|
661
678
|
res.setHeader("Cache-Control", noStore ? "no-store" : "public, max-age=0");
|
|
662
|
-
|
|
679
|
+
// dotfiles: allow — issue #223:nvm 等安装路径本身在隐藏目录下(如 ~/.nvm/…),同上需放行。
|
|
680
|
+
res.sendFile(join(webDist, "index.html"), { dotfiles: "allow" }, (err) => {
|
|
663
681
|
if (err && !res.headersSent) {
|
|
664
682
|
res.status(503).send("正在更新 pi-web-ui,请稍后刷新…");
|
|
665
683
|
}
|
|
@@ -967,7 +985,8 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
|
|
|
967
985
|
pluginMgr.onPermGrantsChanged = () => pushPluginPermissions();
|
|
968
986
|
// 内置定时任务(issue #184):全局 <dataDir>/scheduler-tasks.json,TTL 与
|
|
969
987
|
// client-state 同级;Agent 工具建的任务优先唤醒发起对话(issue #193:
|
|
970
|
-
// wakeConversation steer
|
|
988
|
+
// wakeConversation steer 投递,不切用户当前对话),原对话不在先回落同项目
|
|
989
|
+
// 活跃对话(issue #231:视口兜底+自动重绑定+明确降级提示),都没有才无头伪
|
|
971
990
|
// 客户端(chatFromScheduler);单次任务触发后自动删除。DSH 引擎无这俩方法时
|
|
972
991
|
// executor 回 not-supported(历史里记失败,不炸进程)。
|
|
973
992
|
const scheduler = new SchedulerStore(DATA_DIR, {
|
|
@@ -979,31 +998,83 @@ const scheduler = new SchedulerStore(DATA_DIR, {
|
|
|
979
998
|
result = { ok: false, error: "当前引擎不支持定时任务(仅标准 pi 引擎)" };
|
|
980
999
|
}
|
|
981
1000
|
else {
|
|
982
|
-
|
|
1001
|
+
const runHeadless = () => svc.chatFromScheduler({
|
|
1002
|
+
id: task.id,
|
|
1003
|
+
cwd: task.cwd,
|
|
1004
|
+
prompt: task.prompt,
|
|
1005
|
+
model: task.model,
|
|
1006
|
+
thinkingLevel: task.thinkingLevel,
|
|
1007
|
+
});
|
|
983
1008
|
const target = String(task.conversationId ?? "").trim();
|
|
984
|
-
|
|
985
|
-
|
|
1009
|
+
const taskFile = String(task.sessionFile ?? "").trim();
|
|
1010
|
+
const text = `[定时任务 ${task.name}] ${task.prompt}`;
|
|
1011
|
+
if (!target && !taskFile) {
|
|
1012
|
+
// 面板建的任务:创建时就没绑对话,保持无头语义(不抢占用户视口)。
|
|
1013
|
+
result = await runHeadless();
|
|
1014
|
+
}
|
|
1015
|
+
else if (typeof svc.wakeConversation === "function") {
|
|
1016
|
+
// 原绑定对话还在(含压缩/重启后按会话文件重认)→ steer 唤醒,报告落原对话。
|
|
1017
|
+
let w;
|
|
1018
|
+
try {
|
|
1019
|
+
w = await svc.wakeConversation(target, text, { sessionFile: taskFile, cwd: task.cwd });
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
w = { ok: false, error: err.message };
|
|
1023
|
+
}
|
|
986
1024
|
if (w.ok) {
|
|
1025
|
+
// 会话继承(issue #231):压缩/重启后同文件对话换了新 id → 任务跟过去,
|
|
1026
|
+
// 下次触发直达,不再误判 closed/gone。单次任务随后自删,免一次写盘。
|
|
1027
|
+
if (!task.oneShot) {
|
|
1028
|
+
try {
|
|
1029
|
+
if (w.conversationId && w.conversationId !== target)
|
|
1030
|
+
scheduler.rebind(task.id, { conversationId: w.conversationId });
|
|
1031
|
+
if (w.sessionFile && w.sessionFile !== taskFile)
|
|
1032
|
+
scheduler.rebind(task.id, { sessionFile: w.sessionFile });
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
// 重绑失败不影响本次已投递的唤醒
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
987
1038
|
result = { ok: true, conversationId: w.conversationId };
|
|
988
1039
|
}
|
|
1040
|
+
else if (typeof svc.wakeViewportInCwd === "function") {
|
|
1041
|
+
// 活跃视口兜底(issue #231):原句柄断开(切走释放/过户改名/重启),
|
|
1042
|
+
// 只要同项目还有用户正看着的对话,报告落那里 —— 不静默吞结果。
|
|
1043
|
+
const fallbackText = `[定时任务 ${task.name}|原对话不在,已转到本窗口继续] ${task.prompt}`;
|
|
1044
|
+
let f;
|
|
1045
|
+
try {
|
|
1046
|
+
f = await svc.wakeViewportInCwd(task.cwd, fallbackText);
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
f = { ok: false, error: err.message };
|
|
1050
|
+
}
|
|
1051
|
+
if (f.ok) {
|
|
1052
|
+
if (!task.oneShot) {
|
|
1053
|
+
try {
|
|
1054
|
+
scheduler.rebind(task.id, {
|
|
1055
|
+
conversationId: f.conversationId ?? "",
|
|
1056
|
+
sessionFile: f.sessionFile ?? "",
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
// 重绑失败不影响本次已投递的唤醒
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
pushNoticeToAll("info", `定时任务「${task.name}」原对话不在,已转到同项目的活跃对话继续(原绑定 ${target || "(未知)"})。报告直接落在当前对话。`, `Scheduled task "${task.name}" moved to the project's active conversation (was ${target || "unknown"}). The report lands in the current chat.`);
|
|
1064
|
+
result = { ok: true, conversationId: f.conversationId };
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
// 降级可见性(issue #231):必须无头时明确广播去向,不静默。
|
|
1068
|
+
pushNoticeToAll("warning", `定时任务「${task.name}」原对话不在、同项目也无存活对话,已转后台执行(无头)。报告在后台任务面板的调度历史中查看。`, `Scheduled task "${task.name}" found no live conversation and runs headless; see its report in the background-tasks panel history.`);
|
|
1069
|
+
result = await runHeadless();
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
989
1072
|
else {
|
|
990
|
-
result = await
|
|
991
|
-
id: task.id,
|
|
992
|
-
cwd: task.cwd,
|
|
993
|
-
prompt: task.prompt,
|
|
994
|
-
model: task.model,
|
|
995
|
-
thinkingLevel: task.thinkingLevel,
|
|
996
|
-
});
|
|
1073
|
+
result = await runHeadless();
|
|
997
1074
|
}
|
|
998
1075
|
}
|
|
999
1076
|
else {
|
|
1000
|
-
result = await
|
|
1001
|
-
id: task.id,
|
|
1002
|
-
cwd: task.cwd,
|
|
1003
|
-
prompt: task.prompt,
|
|
1004
|
-
model: task.model,
|
|
1005
|
-
thinkingLevel: task.thinkingLevel,
|
|
1006
|
-
});
|
|
1077
|
+
result = await runHeadless();
|
|
1007
1078
|
}
|
|
1008
1079
|
}
|
|
1009
1080
|
}
|
|
@@ -1489,6 +1560,22 @@ wss.on("connection", (ws) => {
|
|
|
1489
1560
|
case "scm_commit":
|
|
1490
1561
|
void cs.scmQuery("commit", msg.reqId, { hash: msg.hash });
|
|
1491
1562
|
break;
|
|
1563
|
+
case "scm_commitmsg":
|
|
1564
|
+
if (typeof cs.scmGenCommitMessage === "function") {
|
|
1565
|
+
void cs.scmGenCommitMessage(msg.reqId);
|
|
1566
|
+
}
|
|
1567
|
+
else {
|
|
1568
|
+
// DSH 等引擎没有 pi 的 ModelRuntime——照样应答一次(ok:false),
|
|
1569
|
+
// 前端按钮不能因引擎差异卡在转圈。
|
|
1570
|
+
send({
|
|
1571
|
+
type: "scm_data",
|
|
1572
|
+
reqId: msg.reqId,
|
|
1573
|
+
kind: "commitmsg",
|
|
1574
|
+
ok: false,
|
|
1575
|
+
error: "当前引擎不支持 AI 生成提交信息(请用 pi 引擎)/ AI commit messages need the pi engine",
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
break;
|
|
1492
1579
|
case "read_file":
|
|
1493
1580
|
void cs.readFile(msg.path);
|
|
1494
1581
|
break;
|
|
@@ -1729,6 +1816,7 @@ wss.on("connection", (ws) => {
|
|
|
1729
1816
|
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
1730
1817
|
editSoftEnabled: msg.editSoftEnabled,
|
|
1731
1818
|
questionnaireEnabled: msg.questionnaireEnabled,
|
|
1819
|
+
parallelReminderEnabled: msg.parallelReminderEnabled,
|
|
1732
1820
|
goalModeEnabled: msg.goalModeEnabled,
|
|
1733
1821
|
thinkingWrap: msg.thinkingWrap,
|
|
1734
1822
|
toolsWrap: msg.toolsWrap,
|
|
@@ -1741,6 +1829,8 @@ wss.on("connection", (ws) => {
|
|
|
1741
1829
|
visionBridgePrompt: msg.visionBridgePrompt,
|
|
1742
1830
|
subagentDefaultModel: msg.subagentDefaultModel,
|
|
1743
1831
|
retryMaxAttempts: msg.retryMaxAttempts,
|
|
1832
|
+
softCapTokens: msg.softCapTokens,
|
|
1833
|
+
softCapByModel: msg.softCapByModel,
|
|
1744
1834
|
reviewPrompt: msg.reviewPrompt,
|
|
1745
1835
|
reviewDisabledSkills: msg.reviewDisabledSkills,
|
|
1746
1836
|
markersEnabled: msg.markersEnabled,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
|
+
import { normalizeIconSvg } from "./icon-svg.js";
|
|
19
20
|
import { pick } from "./i18n.js";
|
|
20
21
|
/** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
|
|
21
22
|
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
@@ -77,6 +78,7 @@ function toEntry(raw, builtin) {
|
|
|
77
78
|
const description = typeof raw.description === "string" && raw.description.trim() ? raw.description.trim() : undefined;
|
|
78
79
|
const descriptionEn = typeof raw.descriptionEn === "string" && raw.descriptionEn.trim() ? raw.descriptionEn.trim() : undefined;
|
|
79
80
|
const icon = typeof raw.icon === "string" && raw.icon.trim() ? raw.icon.trim() : undefined;
|
|
81
|
+
const iconSvg = normalizeIconSvg(raw.iconSvg);
|
|
80
82
|
const homepage = typeof raw.homepage === "string" && raw.homepage.trim() ? raw.homepage.trim() : undefined;
|
|
81
83
|
return {
|
|
82
84
|
id,
|
|
@@ -86,6 +88,7 @@ function toEntry(raw, builtin) {
|
|
|
86
88
|
...(description ? { description } : {}),
|
|
87
89
|
...(descriptionEn ? { descriptionEn } : {}),
|
|
88
90
|
...(icon ? { icon } : {}),
|
|
91
|
+
...(iconSvg ? { iconSvg } : {}),
|
|
89
92
|
...(homepage ? { homepage } : {}),
|
|
90
93
|
};
|
|
91
94
|
}
|
|
@@ -146,6 +149,7 @@ lang) {
|
|
|
146
149
|
? { description: input.description.trim() }
|
|
147
150
|
: {}),
|
|
148
151
|
...(typeof input?.icon === "string" && input.icon.trim() ? { icon: input.icon.trim() } : {}),
|
|
152
|
+
...(normalizeIconSvg(input?.iconSvg) ? { iconSvg: normalizeIconSvg(input?.iconSvg) } : {}),
|
|
149
153
|
});
|
|
150
154
|
atomicWrite(customPath, { entries: next });
|
|
151
155
|
return toEntry(next[next.length - 1], false);
|
|
@@ -223,6 +227,7 @@ export function writeCustomCatalog(customPath, incoming, replace) {
|
|
|
223
227
|
...(e.description ? { description: e.description } : {}),
|
|
224
228
|
...(e.descriptionEn ? { descriptionEn: e.descriptionEn } : {}),
|
|
225
229
|
...(e.icon ? { icon: e.icon } : {}),
|
|
230
|
+
...(e.iconSvg ? { iconSvg: e.iconSvg } : {}),
|
|
226
231
|
...(e.homepage ? { homepage: e.homepage } : {}),
|
|
227
232
|
}));
|
|
228
233
|
atomicWrite(customPath, { entries });
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
|
|
17
17
|
*/
|
|
18
18
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } 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
22
|
import { createRequire } from "node:module";
|
|
@@ -31,24 +31,52 @@ function atomicWrite(file, data) {
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
// storage
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
|
-
/** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。
|
|
34
|
+
/** 每插件的 JSON 文件 KV。所有方法同步(数据量小,避免并发写乱序)。
|
|
35
|
+
*
|
|
36
|
+
* 缓存按文件 mtimeMs 失效(而非「首次读入后永不失效」):storage.json 有两个
|
|
37
|
+
* 写入者 —— 本类,以及宿主 settings 面板的 saveSettingsValues(直写磁盘的
|
|
38
|
+
* settings 键,不经过本缓存)。插件长轮询里每隔几秒就 store.set("cursor", …)
|
|
39
|
+
* 一次,若缓存永不失效,set() 会把整份旧快照回写,把面板刚保存的 settings
|
|
40
|
+
* 抹掉 —— 表现为「设置重启即丢」。mtime 失效让下一次 load() 重读,两个写入者
|
|
41
|
+
* 各自保留自己的键。 */
|
|
35
42
|
export class PluginStorage {
|
|
36
43
|
file;
|
|
37
44
|
cache;
|
|
45
|
+
/** 缓存对应的文件 mtimeMs(undefined = 文件尚不存在或不可 stat)。 */
|
|
46
|
+
cacheMtimeMs;
|
|
38
47
|
constructor(file) {
|
|
39
48
|
this.file = file;
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
if (this.cache)
|
|
43
|
-
return this.cache;
|
|
50
|
+
mtime() {
|
|
44
51
|
try {
|
|
45
|
-
|
|
46
|
-
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
52
|
+
return statSync(this.file).mtimeMs;
|
|
47
53
|
}
|
|
48
54
|
catch {
|
|
49
|
-
|
|
55
|
+
return undefined; // 不存在 / 不可 stat
|
|
50
56
|
}
|
|
51
|
-
|
|
57
|
+
}
|
|
58
|
+
/** 从磁盘重读(不存在 / 损坏 = 空表)并同步缓存与 mtime。 */
|
|
59
|
+
readFromDisk(mtimeMs = this.mtime()) {
|
|
60
|
+
let parsed = {};
|
|
61
|
+
if (mtimeMs !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
64
|
+
if (raw && typeof raw === "object")
|
|
65
|
+
parsed = raw;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
parsed = {}; // 损坏 = 空表(不致命,重新积累)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.cache = parsed;
|
|
72
|
+
this.cacheMtimeMs = mtimeMs;
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
load() {
|
|
76
|
+
const mtimeMs = this.mtime();
|
|
77
|
+
if (this.cache && this.cacheMtimeMs === mtimeMs)
|
|
78
|
+
return this.cache;
|
|
79
|
+
return this.readFromDisk(mtimeMs);
|
|
52
80
|
}
|
|
53
81
|
get(key, fallback) {
|
|
54
82
|
const v = this.load()[key];
|
|
@@ -60,7 +88,10 @@ export class PluginStorage {
|
|
|
60
88
|
set(key, value) {
|
|
61
89
|
if (!key)
|
|
62
90
|
throw new Error("storage.set: key 不能为空");
|
|
63
|
-
|
|
91
|
+
// 写前必重读磁盘:宿主 settings 面板的 saveSettingsValues 直写同一文件的
|
|
92
|
+
// settings 键(不经过本缓存),拿旧快照整份回写会把它抹掉。写是低频路径,
|
|
93
|
+
// 重读的代价可忽略;mtime 粒度即使同毫秒也抹不掉(这里是无条件重读)。
|
|
94
|
+
const store = this.readFromDisk();
|
|
64
95
|
store[key] = value;
|
|
65
96
|
try {
|
|
66
97
|
atomicWrite(this.file, JSON.stringify(store));
|
|
@@ -70,7 +101,7 @@ export class PluginStorage {
|
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
103
|
delete(key) {
|
|
73
|
-
const store = this.
|
|
104
|
+
const store = this.readFromDisk(); // 同 set:写前重读,不拿旧快照回写
|
|
74
105
|
if (!(key in store))
|
|
75
106
|
return;
|
|
76
107
|
delete store[key];
|
package/dist/server/plugins.js
CHANGED
|
@@ -26,6 +26,7 @@ import { parseCronSpec, nextCronFire, loadScheduleRecords, saveScheduleRecords,
|
|
|
26
26
|
import { PluginPermissionStore } from "./plugin-permissions.js";
|
|
27
27
|
import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
|
|
28
28
|
import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
|
|
29
|
+
import { normalizeIconSvg } from "./icon-svg.js";
|
|
29
30
|
import { PluginDomConsent, declarationWantsDom } from "./plugin-dom.js";
|
|
30
31
|
// 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
|
|
31
32
|
import { normalizeWorkspaceRoots } from "./client-state.js";
|
|
@@ -371,6 +372,9 @@ export function parseUiItem(raw, slot, diagnostics) {
|
|
|
371
372
|
...(align ? { align } : {}),
|
|
372
373
|
...(trimStr(o.labelEn, 60) ? { labelEn: trimStr(o.labelEn, 60) } : {}),
|
|
373
374
|
...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
|
|
375
|
+
...(normalizeIconSvg(o.iconSvg)
|
|
376
|
+
? { iconSvg: normalizeIconSvg(o.iconSvg) }
|
|
377
|
+
: {}),
|
|
374
378
|
...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
|
|
375
379
|
...(trimStr(o.hintEn, 200) ? { hintEn: trimStr(o.hintEn, 200) } : {}),
|
|
376
380
|
kind,
|
|
@@ -538,6 +542,9 @@ export function parseUiArrange(raw, diagnostics) {
|
|
|
538
542
|
...(trimStr(o.label, 60) ? { label: trimStr(o.label, 60) } : {}),
|
|
539
543
|
...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
|
|
540
544
|
...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
|
|
545
|
+
...(normalizeIconSvg(o.iconSvg)
|
|
546
|
+
? { iconSvg: normalizeIconSvg(o.iconSvg) }
|
|
547
|
+
: {}),
|
|
541
548
|
});
|
|
542
549
|
}
|
|
543
550
|
return out;
|
|
@@ -1582,15 +1589,15 @@ export class PluginManager {
|
|
|
1582
1589
|
}
|
|
1583
1590
|
}
|
|
1584
1591
|
/** 当前目录清单(重扫 manifest,不重新 import)。 */
|
|
1585
|
-
async list() {
|
|
1586
|
-
return this.scan();
|
|
1592
|
+
async list(lang) {
|
|
1593
|
+
return this.scan(lang);
|
|
1587
1594
|
}
|
|
1588
1595
|
/**
|
|
1589
1596
|
* attach 时调用:重扫目录 + 激活尚未加载的新插件。
|
|
1590
1597
|
* 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
|
|
1591
1598
|
*/
|
|
1592
1599
|
async ensureLoaded(lang) {
|
|
1593
|
-
const found = await this.scan();
|
|
1600
|
+
const found = await this.scan(lang);
|
|
1594
1601
|
for (const info of found) {
|
|
1595
1602
|
if (this.loaded.has(info.id) || this.attempted.has(info.id))
|
|
1596
1603
|
continue;
|
|
@@ -1607,10 +1614,13 @@ export class PluginManager {
|
|
|
1607
1614
|
}
|
|
1608
1615
|
return found.map((f) => {
|
|
1609
1616
|
const base = this.loaded.get(f.id)?.info ?? f;
|
|
1617
|
+
// 运行相位(设置面板清单用):宿主持有实例即 active(含激活失败的占位行;
|
|
1618
|
+
// 纯前端插件无 index.mjs、从未进 loaded 表,保持非 active)。
|
|
1619
|
+
const withPhase = { ...base, active: this.loaded.has(f.id) };
|
|
1610
1620
|
const agentTools = this.agentToolsSnapshot(f.id);
|
|
1611
1621
|
if (agentTools.length)
|
|
1612
|
-
return { ...
|
|
1613
|
-
const { agentTools: _drop, ...rest } =
|
|
1622
|
+
return { ...withPhase, agentTools };
|
|
1623
|
+
const { agentTools: _drop, ...rest } = withPhase;
|
|
1614
1624
|
return rest;
|
|
1615
1625
|
});
|
|
1616
1626
|
}
|
|
@@ -1702,7 +1712,8 @@ export class PluginManager {
|
|
|
1702
1712
|
this.pluginLogs.clear();
|
|
1703
1713
|
}
|
|
1704
1714
|
/** 读 manifest 清单;坏目录(无 manifest/id 非法)直接跳过。 */
|
|
1705
|
-
async scan() {
|
|
1715
|
+
async scan(lang) {
|
|
1716
|
+
const l = lang?.() ?? "en";
|
|
1706
1717
|
let names;
|
|
1707
1718
|
try {
|
|
1708
1719
|
names = await readdir(this.pluginsDir);
|
|
@@ -1731,6 +1742,7 @@ export class PluginManager {
|
|
|
1731
1742
|
version: typeof m.version === "string" ? m.version : undefined,
|
|
1732
1743
|
description: typeof m.description === "string" ? m.description : undefined,
|
|
1733
1744
|
icon: typeof m.icon === "string" && m.icon.trim() ? m.icon.trim() : undefined,
|
|
1745
|
+
iconSvg: normalizeIconSvg(m.iconSvg),
|
|
1734
1746
|
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
1735
1747
|
error: this.loaded.get(name)?.info.error,
|
|
1736
1748
|
// manifest 声明的能力清单(fs/net/tools…)——设置面板展示用
|
|
@@ -1840,7 +1852,19 @@ export class PluginManager {
|
|
|
1840
1852
|
}
|
|
1841
1853
|
}
|
|
1842
1854
|
catch {
|
|
1843
|
-
|
|
1855
|
+
// 无 manifest.json / JSON 解析失败 —— 占位行展示(坏插件不跳过,
|
|
1856
|
+
// 设置面板清单标红 + 给原因;ensureLoaded 照例跳过激活)。
|
|
1857
|
+
const msg = pick(l, `manifest.json 缺失或解析失败(${name}/),不是有效插件 —— 修复或移走该目录后点“重新扫描”`, `manifest.json missing or unparsable (${name}/), not a valid plugin — fix or remove the directory, then hit Rescan`, "plugins.manifest.broken", { name });
|
|
1858
|
+
console.error(`[plugin:${name}] ${msg}`);
|
|
1859
|
+
const brokenDiags = this.diagnosticsOf(name);
|
|
1860
|
+
out.push({
|
|
1861
|
+
id: name,
|
|
1862
|
+
name,
|
|
1863
|
+
hasClient: existsSync(join(dir, "client", "entry.mjs")),
|
|
1864
|
+
error: msg,
|
|
1865
|
+
view: false,
|
|
1866
|
+
...(brokenDiags ? { diagnostics: brokenDiags } : {}),
|
|
1867
|
+
});
|
|
1844
1868
|
}
|
|
1845
1869
|
}
|
|
1846
1870
|
// 删掉的目录不同时清快照:门禁会把不存在的 id 当 403,正确的应该是 404。
|
|
@@ -2073,7 +2097,22 @@ export class PluginManager {
|
|
|
2073
2097
|
if (text.length > 8000)
|
|
2074
2098
|
return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
|
|
2075
2099
|
const accountId = String(req?.accountId ?? "default").slice(0, 64);
|
|
2076
|
-
|
|
2100
|
+
// issue #226:透传定时任务对齐的四件套(各按长度封顶,语义校验归宿主 chatFromPlugin)。
|
|
2101
|
+
const r = (req ?? {});
|
|
2102
|
+
const passthrough = { text, accountId };
|
|
2103
|
+
const cwd = String(r.cwd ?? "").trim();
|
|
2104
|
+
if (cwd)
|
|
2105
|
+
passthrough.cwd = cwd.slice(0, 1024);
|
|
2106
|
+
const conversationId = String(r.conversationId ?? "").trim();
|
|
2107
|
+
if (conversationId)
|
|
2108
|
+
passthrough.conversationId = conversationId.slice(0, 128);
|
|
2109
|
+
const model = String(r.model ?? "").trim();
|
|
2110
|
+
if (model)
|
|
2111
|
+
passthrough.model = model.slice(0, 256);
|
|
2112
|
+
const thinkingLevel = String(r.thinkingLevel ?? "").trim();
|
|
2113
|
+
if (thinkingLevel)
|
|
2114
|
+
passthrough.thinkingLevel = thinkingLevel.slice(0, 64);
|
|
2115
|
+
return self.chatProvider(info.id, passthrough);
|
|
2077
2116
|
};
|
|
2078
2117
|
const host = {
|
|
2079
2118
|
broadcast: (payload) => this.broadcast(info.id, payload),
|
|
@@ -149,7 +149,7 @@ export function buildSkillsText(skills, lang = "en", fullText = false) {
|
|
|
149
149
|
return "";
|
|
150
150
|
const lines = [
|
|
151
151
|
pick(lang, "以下技能为特定任务提供专门的指令。", "The following skills provide specialized instructions for specific tasks.", "prompt.skills.intro.specialized"),
|
|
152
|
-
pick(lang, "当任务与某技能的描述相符时,用
|
|
152
|
+
pick(lang, "当任务与某技能的描述相符时,用 skill 工具按名称加载该技能全文。", "Use the skill tool to load a skill's full text by name when the task matches its description.", "prompt.skills.intro.use.skill"),
|
|
153
153
|
pick(lang, "当技能文件引用相对路径时,以技能目录(SKILL.md 的父目录 / 该路径的 dirname)为基准解析,并在工具命令中使用解析后的绝对路径。", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "prompt.skills.intro.resolve.path"),
|
|
154
154
|
"",
|
|
155
155
|
"<available_skills>",
|
|
@@ -111,12 +111,12 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
111
111
|
"Use it when the user asks for periodic checks, delayed reminders, or scheduled summaries — do NOT fake it with sleep loops (a sleeping script can never push a message back). " +
|
|
112
112
|
'Schedule accepts a 5-field cron ("0 * * * *" = hourly) or a relative delay ("in 30m", "in 1h", "30m"); minimum interval is 60s. ' +
|
|
113
113
|
"One-shot by default (recurring=false auto-deletes the task after it fires); pass recurring=true for repeats. " +
|
|
114
|
-
"
|
|
114
|
+
"The wake-up binds the originating conversation AND its persisted session file: after context compaction or a restart the scheduler re-binds to the same session automatically; if the original conversation is gone it first falls back to the project's active conversation (and visibly reports the move), and only runs headless (report in panel history) when no live conversation exists for the project. " +
|
|
115
115
|
"Manage with schedule_list / schedule_cancel; the user can also cancel from the background-tasks panel.", "在**当前对话**里创建一个定时唤醒:到指定 cron 时间或延迟后,调度器自动把你的 prompt 投回本对话,你继续执行并向用户汇报。" +
|
|
116
116
|
"用户要求定时巡检、延时提醒、定时汇总时用它 —— 不要用 sleep 死循环假装答应(休眠的脚本推不回任何消息)。" +
|
|
117
117
|
"时间写法收 5 字段 cron(“0 * * * *”=每小时)或相对延迟(“in 30m”“in 1h”“30m”);最短间隔 60s。" +
|
|
118
118
|
"默认单次(recurring=false 触发后自动删除);传 recurring=true 做周期任务。" +
|
|
119
|
-
"
|
|
119
|
+
"唤醒绑定发起对话与其落盘会话文件:上下文压缩或重启后自动重绑到同一会话;原对话不在时先回落到同项目的活跃对话(并明确提示迁移),同项目无存活对话才无头执行,报告留在调度面板历史里。" +
|
|
120
120
|
"用 schedule_list / schedule_cancel 管理;用户也可从后台任务面板取消。"),
|
|
121
121
|
promptSnippet: "schedule a wake-up in this conversation (cron or delay), auto-report on fire",
|
|
122
122
|
parameters: Type.Object({
|
|
@@ -154,16 +154,34 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
154
154
|
const labelRaw = typeof p.label === "string" ? p.label.trim().slice(0, 80) : "";
|
|
155
155
|
const name = labelRaw || prompt.split("\n")[0].slice(0, 40) || "定时任务";
|
|
156
156
|
const convId = (ownerConversationId ?? "").trim() || String(host.activeConversationId() ?? "").trim();
|
|
157
|
+
// 稳定绑定(issue #231):owner 对话的落盘会话文件 —— 压缩/重启后靠它重认同一会话,
|
|
158
|
+
// 内存对话 id(c1/c2…)只做首选唤醒键。宿主没实现 conversationInfo 时按原来只绑 id。
|
|
159
|
+
let bindCwd = cwd;
|
|
160
|
+
let bindSessionFile = "";
|
|
161
|
+
try {
|
|
162
|
+
const info = host.conversationInfo?.((ownerConversationId ?? "").trim() || convId || undefined);
|
|
163
|
+
if (info) {
|
|
164
|
+
if (String(info.cwd ?? "").trim())
|
|
165
|
+
bindCwd = String(info.cwd).trim();
|
|
166
|
+
bindSessionFile = String(info.sessionFile ?? "")
|
|
167
|
+
.trim()
|
|
168
|
+
.slice(0, 1024);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* 绑定快照尽力而为:拿不到稳定键就按原来的 id 绑定触发 */
|
|
173
|
+
}
|
|
157
174
|
const recurring = p.recurring === true;
|
|
158
175
|
let task;
|
|
159
176
|
try {
|
|
160
177
|
task = store.upsert({
|
|
161
178
|
name,
|
|
162
|
-
cwd,
|
|
179
|
+
cwd: bindCwd,
|
|
163
180
|
kind: parsed.kind,
|
|
164
181
|
spec: parsed.spec,
|
|
165
182
|
prompt,
|
|
166
183
|
conversationId: convId,
|
|
184
|
+
sessionFile: bindSessionFile,
|
|
167
185
|
oneShot: !recurring,
|
|
168
186
|
});
|
|
169
187
|
}
|
|
@@ -178,8 +196,8 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
178
196
|
: `Fires: ${parsed.kind === "cron" ? `cron ${parsed.spec}` : `every ${Math.round(Number(parsed.spec) / 1000)}s`}, next ${fmtTime(next, L)}${recurring ? " (recurring)" : " (one-shot, auto-deleted after firing)"}`;
|
|
179
197
|
const targetLine = convId
|
|
180
198
|
? L === "zh"
|
|
181
|
-
? `汇报:触发时自动唤醒本对话(${convId}
|
|
182
|
-
: `Reports: wakes this conversation (${convId}) on fire;
|
|
199
|
+
? `汇报:触发时自动唤醒本对话(${convId});压缩/重启后按会话文件自动重绑,原对话不在时先回落同项目活跃对话(明确提示),无存活对话才无头执行。`
|
|
200
|
+
: `Reports: wakes this conversation (${convId}) on fire; re-binds by session file after compaction/restart, falls back to the project's active conversation (with a visible note), headless only with no live conversation.`
|
|
183
201
|
: L === "zh"
|
|
184
202
|
? "汇报:无头执行,报告进调度面板历史(创建时没拿到对话 id)。"
|
|
185
203
|
: "Reports: headless run, report in panel history (no conversation id captured at creation).";
|