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.
Files changed (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/server/agent-service.js +763 -61
  3. package/dist/server/client-state.js +70 -5
  4. package/dist/server/compaction-markers.js +287 -0
  5. package/dist/server/delegate-task.js +12 -1
  6. package/dist/server/dsh/dsh-agent-service.js +21 -1
  7. package/dist/server/file-transfer-routes.js +4 -1
  8. package/dist/server/icon-svg.js +35 -0
  9. package/dist/server/index.js +120 -30
  10. package/dist/server/plugin-catalog.js +5 -0
  11. package/dist/server/plugin-facilities.js +42 -11
  12. package/dist/server/plugins.js +47 -8
  13. package/dist/server/prompt-composer.js +1 -1
  14. package/dist/server/protocol-version.js +1 -1
  15. package/dist/server/schedule-agent-tool.js +23 -5
  16. package/dist/server/scheduler-tasks.js +60 -1
  17. package/dist/server/scm-commitmsg.js +140 -0
  18. package/dist/server/scm.js +42 -0
  19. package/dist/server/settings-service.js +40 -0
  20. package/dist/server/skill-tool.js +120 -0
  21. package/dist/server/soft-cap.js +68 -0
  22. package/dist/server/subagent-templates.js +6 -0
  23. package/dist/server/subagents.js +157 -16
  24. package/dist/server/tool-manager.js +7 -1
  25. package/package.json +2 -1
  26. package/plugins/catalog.json +10 -0
  27. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
  28. package/web/dist/assets/es-DIDZppIF.js +2 -0
  29. package/web/dist/assets/index-CdsKltOS.css +1 -0
  30. package/web/dist/assets/index-OoULNF70.js +363 -0
  31. package/web/dist/index.html +2 -2
  32. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  33. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -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
- res.download(abs, name);
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
- // SAFETY: Express 5 命名通配 *splat 落在 req.params.splat(Express 4 是 params[0])。
364
- const captured = String(req.params.splat ?? "");
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
- res.sendFile(file);
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
- // SAFETY: Express 5 命名通配 *splat 落在 req.params.splat(Express 4 是 params[0])。
514
- const rest = String(req.params.splat ?? "");
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
- // SAFETY: Express 5 命名通配 *splat 落在 req.params.splat(Express 4 是 params[0])。
588
- const rest = String(req.params.splat ?? "");
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
- res.sendFile(abs, (err) => {
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
- res.sendFile(join(webDist, "index.html"), (err) => {
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
- // 发起对话还在 → steer 唤醒它(报告直接落原对话);不在了 → 无头执行。
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
- if (target && typeof svc.wakeConversation === "function") {
985
- const w = await svc.wakeConversation(target, `[定时任务 ${task.name}] ${task.prompt}`);
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 svc.chatFromScheduler({
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 svc.chatFromScheduler({
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
- load() {
42
- if (this.cache)
43
- return this.cache;
50
+ mtime() {
44
51
  try {
45
- const parsed = JSON.parse(readFileSync(this.file, "utf8"));
46
- this.cache = parsed && typeof parsed === "object" ? parsed : {};
52
+ return statSync(this.file).mtimeMs;
47
53
  }
48
54
  catch {
49
- this.cache = {}; // 不存在/损坏 = 空表(损坏不致命,重新积累)
55
+ return undefined; // 不存在 / 不可 stat
50
56
  }
51
- return this.cache;
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
- const store = this.load();
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.load();
104
+ const store = this.readFromDisk(); // 同 set:写前重读,不拿旧快照回写
74
105
  if (!(key in store))
75
106
  return;
76
107
  delete store[key];
@@ -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 { ...base, agentTools };
1613
- const { agentTools: _drop, ...rest } = base;
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
- continue; // 无 manifest / JSON 坏 —— 不是插件
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
- return self.chatProvider(info.id, { text, accountId });
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, "当任务与某技能的描述相符时,用 read 工具加载该技能文件。", "Use the read tool to load a skill's file when the task matches its description.", "prompt.skills.intro.use.read"),
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>",
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 17;
11
+ export const PROTOCOL_VERSION = 18;
@@ -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
- "If the conversation is gone when it fires (closed/restarted), the task runs headless in its project and the report stays in the scheduler panel history. " +
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; runs headless (report in panel history) if it is gone.`
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).";