pi-web-ui 0.95.0 → 0.96.1

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 (75) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +331 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-installer.js +30 -0
  38. package/dist/server/plugin-llm.js +71 -65
  39. package/dist/server/plugin-project.js +70 -2
  40. package/dist/server/plugin-updater.js +115 -12
  41. package/dist/server/plugins.js +205 -44
  42. package/dist/server/present-files-tool.js +9 -12
  43. package/dist/server/process-utils.js +4 -0
  44. package/dist/server/prompt-composer.js +9 -0
  45. package/dist/server/protocol-version.js +1 -1
  46. package/dist/server/read-tool.js +69 -30
  47. package/dist/server/resolve-global-sdk.js +30 -16
  48. package/dist/server/schedule-agent-tool.js +12 -15
  49. package/dist/server/sdk-origin.js +18 -2
  50. package/dist/server/serialize.js +108 -21
  51. package/dist/server/settings-service.js +4 -2
  52. package/dist/server/skill-tool.js +5 -7
  53. package/dist/server/subagent-templates.js +51 -0
  54. package/dist/server/subagents.js +48 -72
  55. package/dist/server/terminals.js +181 -69
  56. package/dist/server/tool-manager.js +24 -7
  57. package/dist/server/tool-overrides.js +59 -0
  58. package/dist/server/update-check.js +28 -1
  59. package/dist/server/uploads.js +17 -2
  60. package/dist/server/wait-subscription-scan.js +18 -21
  61. package/dist/server/ws-client-id.js +29 -0
  62. package/dist/server/ws-pending-queue.js +60 -0
  63. package/extensions/webui.ts +60 -2
  64. package/package.json +4 -2
  65. package/plugins/catalog.json +20 -0
  66. package/themes/aetheris.css +457 -0
  67. package/themes/zhupi-dark.css +718 -0
  68. package/themes/zhupi.css +771 -0
  69. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
  70. package/web/dist/assets/index-B7Zp23Vc.css +1 -0
  71. package/web/dist/assets/index-ByfoaDwp.js +366 -0
  72. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  73. package/web/dist/index.html +3 -3
  74. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  75. package/web/dist/assets/index-RpIGFyi_.css +0 -1
@@ -30,7 +30,21 @@
30
30
  * - 内存缓存 + 文件 mtime 感知,支持直接编辑 JSON 文件热生效。
31
31
  */
32
32
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
33
- import { dirname, isAbsolute, resolve, sep } from "node:path";
33
+ import { dirname, resolve, sep } from "node:path";
34
+ /**
35
+ * 跨平台判定 target 路径是否严格位于 root 目录内部或就是 root 本身。
36
+ * 针对 Windows 盘符大小写不敏感及正反斜杠统一进行规范化,防止 .. 路径穿越逃逸。
37
+ */
38
+ export function isPathInsideRoot(target, root) {
39
+ const normTarget = resolve(target);
40
+ const normRoot = resolve(root);
41
+ if (process.platform === "win32") {
42
+ const lowerTarget = normTarget.toLowerCase();
43
+ const lowerRoot = normRoot.toLowerCase();
44
+ return (lowerTarget === lowerRoot || lowerTarget.startsWith(lowerRoot + sep) || lowerTarget.startsWith(lowerRoot + "/"));
45
+ }
46
+ return normTarget === normRoot || normTarget.startsWith(normRoot + sep);
47
+ }
34
48
  /**
35
49
  * 极简 glob 转 RegExp(只支持 * 单段、? 单字符、** 跨段)。
36
50
  * 纯函数,支持正反斜杠统一归一为 /。
@@ -66,6 +80,22 @@ export function globToRegex(pattern) {
66
80
  }
67
81
  return new RegExp(`^${re}$`, "i");
68
82
  }
83
+ /**
84
+ * 从工具参数里取「这次调用动的是哪个文件」。SDK 内置 read/write/edit 用 `path`,read 还有
85
+ * `file_path` 别名,部分扩展(如 pi-better-edit 的 edit)用 `file` —— 三种都认,否则叠在
86
+ * 扩展实现之上的权限沙箱与审批规则会静默放行(取不到 → 空串 → 被判成工作区内)。
87
+ */
88
+ export function extractTargetPath(params) {
89
+ if (!params || typeof params !== "object")
90
+ return "";
91
+ const obj = params;
92
+ for (const key of ["path", "file_path", "file"]) {
93
+ const value = obj[key];
94
+ if (typeof value === "string" && value.trim())
95
+ return value;
96
+ }
97
+ return "";
98
+ }
69
99
  /**
70
100
  * 从工具参数对象中提取待检查文本。
71
101
  */
@@ -78,7 +108,7 @@ export function extractRuleFieldValue(field, toolName, params) {
78
108
  return String(obj.command ?? "");
79
109
  }
80
110
  if (field === "path") {
81
- return String(obj.path ?? "");
111
+ return extractTargetPath(params);
82
112
  }
83
113
  if (field === "params") {
84
114
  try {
@@ -106,9 +136,10 @@ export function matchApprovalRule(rule, toolName, params, cwd, workspaceRoots =
106
136
  const targetPath = extractRuleFieldValue("path", toolName, params);
107
137
  if (!targetPath)
108
138
  return false;
109
- const abs = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
139
+ // 无论相对或绝对路径,统一经 resolve(cwd, targetPath) 规范化并消除 ".."
140
+ const abs = resolve(cwd, targetPath);
110
141
  const allRoots = [resolve(cwd), ...workspaceRoots.map((r) => resolve(r))];
111
- const inside = allRoots.some((r) => abs === r || abs.startsWith(r + sep));
142
+ const inside = allRoots.some((r) => isPathInsideRoot(abs, r));
112
143
  return !inside;
113
144
  }
114
145
  // 3. 提取字段文本
@@ -176,7 +207,7 @@ export const DEFAULT_APPROVAL_RULES = [
176
207
  tools: ["bash"],
177
208
  field: "command",
178
209
  match: "regex",
179
- value: "\\brm\\s+-[a-zA-Z0-9]*[rf][a-zA-Z0-9]*\\s+((\\/)|(~)|(\\.\\.)|(\\*)|(\\.\\/))",
210
+ value: "\\brm\\s+((-[a-zA-Z0-9]*[rf][a-zA-Z0-9]*|--recursive|--force)\\s+)+(((\\/)|(~)|(\\.\\.)|(\\*)|(\\.\\/))|[a-zA-Z]:[\\\\/])",
180
211
  action: "ask",
181
212
  label: "递归/强制删除 (rm -rf)",
182
213
  labelEn: "Recursive/force delete (rm -rf)",
@@ -191,12 +222,12 @@ export const DEFAULT_APPROVAL_RULES = [
191
222
  tools: ["bash"],
192
223
  field: "command",
193
224
  match: "regex",
194
- value: "\\b(del|rmdir)\\s+\\/[fsq]",
225
+ value: "\\b(del|rmdir|rd)\\s+[/\\-][fsq]",
195
226
  action: "ask",
196
- label: "Windows 强制删除 (del/rmdir)",
197
- labelEn: "Windows force delete (del/rmdir)",
198
- reason: "检测到高风险的 Windows 强制/递归删除目录命令 (del/rmdir /s /q)",
199
- reasonEn: "Detected high-risk Windows force/recursive deletion command (del/rmdir /s /q)",
227
+ label: "Windows 强制删除 (del/rmdir/rd)",
228
+ labelEn: "Windows force delete (del/rmdir/rd)",
229
+ reason: "检测到高风险的 Windows 强制/递归删除目录命令 (del/rmdir/rd /s /q)",
230
+ reasonEn: "Detected high-risk Windows force/recursive deletion command (del/rmdir/rd /s /q)",
200
231
  categoryId: "bash.win-del",
201
232
  builtin: true,
202
233
  },
@@ -515,6 +546,21 @@ export class ApprovalRulesStore {
515
546
  ids.add(r.id);
516
547
  normalized.push(r);
517
548
  }
549
+ // 内置规则是安全底线:整表替换绝不能把它们裁掉(旧客户端 / 并发竞态都
550
+ // 可能送来缺内置规则的清单)。缺失的按默认定义补种、追加到队尾——与
551
+ // load/resetBuiltin 同口径;插队首会改变用户 allow 规则的 first-match
552
+ // 语义。id 命中内置定义的一律强制 builtin 标记(同 upsert 的保护),
553
+ // 防止 remove() 的内置不可删保护被绕过。
554
+ for (const def of DEFAULT_APPROVAL_RULES) {
555
+ if (!ids.has(def.id)) {
556
+ normalized.push({ ...def, tools: [...def.tools] });
557
+ ids.add(def.id);
558
+ }
559
+ else {
560
+ const i = normalized.findIndex((r) => r.id === def.id);
561
+ normalized[i].builtin = true;
562
+ }
563
+ }
518
564
  this.rules = normalized;
519
565
  this.persist();
520
566
  return null;
@@ -7,9 +7,9 @@
7
7
  * - 浏览器通过 `/api/attachment/:hash` 经 HTTP 访问,带永久强缓存(immutable);
8
8
  * - 避免快照和消息列表随会话进行出现数兆字节 base64 的膨胀。
9
9
  */
10
- import { createHash } from "node:crypto";
10
+ import { createHash, randomUUID } from "node:crypto";
11
11
  import { mkdirSync, existsSync, readdirSync } from "node:fs";
12
- import { readFile, writeFile, stat } from "node:fs/promises";
12
+ import { readFile, rename, rm, writeFile, stat } from "node:fs/promises";
13
13
  import { join, extname } from "node:path";
14
14
  let storeDir = "";
15
15
  /** 初始化附件存储目录(服务启动时调用)。 */
@@ -53,7 +53,22 @@ export async function saveAttachment(buffer, mimeType = "application/octet-strea
53
53
  const fileName = `${hash}${ext}`;
54
54
  const filePath = join(storeDir, fileName);
55
55
  if (!existsSync(filePath)) {
56
- await writeFile(filePath, buffer);
56
+ // 原地 writeFile 有两个问题:a) 同 hash 并发写时两个 writeFileSync
57
+ // 交错会留下半个文件;b) 读方(/api/attachment/:hash)可能在写完前就命中。
58
+ // 因此先写私有 tmp 再 rename(同目录内原子生效)。POSIX rename 原子覆盖;
59
+ // Windows 目标已存在时报 EEXIST/EPERM —— 内容寻址同 hash 即同内容,
60
+ // 并发对手落盘视为成功。tmp 命名带 uuid,绝不与真实附件名冲突。
61
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
62
+ try {
63
+ await writeFile(tmpPath, buffer);
64
+ await rename(tmpPath, filePath);
65
+ }
66
+ catch (err) {
67
+ await rm(tmpPath, { force: true }).catch(() => { });
68
+ const code = err.code;
69
+ if (code !== "EEXIST" && code !== "EPERM")
70
+ throw err;
71
+ }
57
72
  }
58
73
  return {
59
74
  hash,
@@ -75,7 +90,9 @@ export async function findAttachment(hash) {
75
90
  return null;
76
91
  try {
77
92
  const files = readdirSync(storeDir);
78
- const match = files.find((f) => f.startsWith(hash));
93
+ // 跳过原子写(tmp + rename)崩溃时可能残留的 .tmp —— 前缀同为 hash,
94
+ // 若被命中会拿到错误的后缀与 MIME。
95
+ const match = files.find((f) => f.startsWith(hash) && !f.endsWith(".tmp"));
79
96
  if (!match)
80
97
  return null;
81
98
  const filePath = join(storeDir, match);
@@ -4,8 +4,20 @@ import { isAbsoluteWirePath, wireToAbs } from "./files-service.js";
4
4
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
5
5
  import { saveAttachment } from "./attachment-store.js";
6
6
  /** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
7
- * 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。 */
7
+ * 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。
8
+ * 长驻进程下无界 Map 会随图片种类缓慢吃内存(单条转写可达数十 KB),
9
+ * 超过 VISION_BRIDGE_CACHE_MAX 按插入序 FIFO 淘汰最旧。 */
10
+ const VISION_BRIDGE_CACHE_MAX = 256;
8
11
  const visionBridgeCache = new Map();
12
+ function cacheVisionTranscript(key, value) {
13
+ visionBridgeCache.set(key, value);
14
+ if (visionBridgeCache.size > VISION_BRIDGE_CACHE_MAX) {
15
+ // Map 迭代序 = 插入序,第一个 key 即最旧。
16
+ const oldest = visionBridgeCache.keys().next().value;
17
+ if (oldest !== undefined)
18
+ visionBridgeCache.delete(oldest);
19
+ }
20
+ }
9
21
  /** "provider/id" 解析;非法格式返回 null。 */
10
22
  export function parseModelSpec(spec) {
11
23
  if (!spec)
@@ -194,7 +206,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
194
206
  systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt, vLang),
195
207
  lang: vLang,
196
208
  });
197
- visionBridgeCache.set(batchHash, transcript);
209
+ cacheVisionTranscript(batchHash, transcript);
198
210
  ctx.emit({
199
211
  type: "notice",
200
212
  level: "info",
@@ -410,7 +422,22 @@ export async function buildAttachmentMessages(ctx, attachments) {
410
422
  // Uploaded files live in a GLOBAL per-user dir (not inside the project
411
423
  // or the per-client session store) so browsing a repo never picks up
412
424
  // uploaded junk: <dataDir>/uploads/<clientId>/(保留期自动清理,见 uploads.ts)。
413
- const { abs, displayName: safeName } = saveUpload(ctx.clientId, att.name ?? "file", buf);
425
+ // saveUpload 对非法 clientId/displayName 抛错(消毒失败整条拒绝,绝不
426
+ // 落到别的目录)—— 这里报错跳过该附件,不让一条坏附件中断整条消息。
427
+ let saved;
428
+ try {
429
+ saved = saveUpload(ctx.clientId, att.name ?? "file", buf);
430
+ }
431
+ catch (err) {
432
+ ctx.emit({
433
+ type: "notice",
434
+ level: "error",
435
+ text: `上传文件保存失败,已跳过:${err.message}`,
436
+ textEn: `Failed to save uploaded file, skipped: ${err.message}`,
437
+ });
438
+ continue;
439
+ }
440
+ const { abs, displayName: safeName } = saved;
414
441
  // Wire format: forward-slash absolute path (the read tool accepts
415
442
  // absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
416
443
  const wirePath = abs.split(sep).join("/");
@@ -66,7 +66,13 @@ export function shouldTrackBackgroundServer(pid, parents, newPids, serverPid, na
66
66
  export class BgServerTracker {
67
67
  opts;
68
68
  servers = new Map();
69
- /** bash 工具开始执行前拍的监听端口快照(tool_execution_start 时设置)。 */
69
+ /**
70
+ * bash 工具开始执行前拍的监听端口快照(tool_execution_start 时设置)。
71
+ * 槽位存「in-flight Promise」而非已解析的 Map:并发 bash 同时开跑时不互相
72
+ * 覆盖(??= 保住最早的一份——更早的基线只会多算新增,落库有去重兜底),
73
+ * 也修掉 fire-and-forget 时序竞态(bash 先于快照返回结束 → trackAfterBash
74
+ * 读到 null 直接漏记,延迟解析的旧快照还会污染下一轮)。
75
+ */
70
76
  listenBefore = null;
71
77
  refreshTimer = null;
72
78
  constructor(opts) {
@@ -83,20 +89,21 @@ export class BgServerTracker {
83
89
  this.refreshTimer = null;
84
90
  }
85
91
  }
86
- /** tool_execution_start(bash):先记下「前」快照。 */
92
+ /** tool_execution_start(bash):先记下「前」快照(快照在途时保留最早的一份)。 */
87
93
  snapshotBefore() {
88
- void snapshotListeningPorts().then((m) => {
89
- this.listenBefore = m;
90
- });
94
+ this.listenBefore ??= snapshotListeningPorts();
91
95
  }
92
96
  /** After a bash tool run, wait briefly for background servers to bind,
93
97
  * then diff the listening-port snapshot against the pre-run one and
94
98
  * remember anything new — those are servers the agent left running. */
95
99
  async trackAfterBash() {
96
- const before = this.listenBefore;
100
+ // 读后清槽:await in-flight 快照(消除 fire-and-forget 的时序竞态),
101
+ // 并发 bash 共用这份最早的基线,各自结束都能对得上。
102
+ const beforePromise = this.listenBefore;
97
103
  this.listenBefore = null;
98
- if (!before)
104
+ if (!beforePromise)
99
105
  return;
106
+ const before = await beforePromise;
100
107
  await new Promise((r) => setTimeout(r, BG_BIND_WAIT_MS));
101
108
  const after = await snapshotListeningPorts();
102
109
  const fresh = [];
@@ -198,6 +205,22 @@ export class BgServerTracker {
198
205
  await this.refresh();
199
206
  this.push();
200
207
  }
208
+ /** 杀前复核端口→pid 归属(与 refresh() 同一判定:端口与 pid 都对得上才算我们的)。
209
+ * 缓存的 pid 可能已过期——原进程退出后端口被无关进程复用时,按缓存 pid 直接
210
+ * killPidTree 会误伤无辜进程树。不符的条目剔除并推送,绝不对其开杀。 */
211
+ async verifyBeforeKill() {
212
+ const now = await snapshotListeningPorts();
213
+ let changed = false;
214
+ // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
215
+ for (const [port, v] of [...this.servers]) {
216
+ if (now.get(port) !== v.pid) {
217
+ this.servers.delete(port);
218
+ changed = true;
219
+ }
220
+ }
221
+ if (changed)
222
+ this.push();
223
+ }
201
224
  /** Kill ONE background server (by port); returns whether anything was killed. */
202
225
  async killOne(port) {
203
226
  const entry = this.servers.get(port);
@@ -211,6 +234,18 @@ export class BgServerTracker {
211
234
  this.opts.flushSnapshot();
212
235
  return false;
213
236
  }
237
+ await this.verifyBeforeKill();
238
+ const verified = this.servers.get(port);
239
+ if (!verified || verified.pid !== entry.pid) {
240
+ this.opts.emit({
241
+ type: "notice",
242
+ level: "warning",
243
+ text: `端口 ${port} 已不在原进程(原 pid ${entry.pid})监听,已跳过并刷新列表`,
244
+ textEn: `Port ${port} is no longer held by the original process (pid ${entry.pid}); skipped and list refreshed`,
245
+ });
246
+ this.opts.flushSnapshot();
247
+ return false;
248
+ }
214
249
  killPidTree(entry.pid);
215
250
  this.servers.delete(port);
216
251
  this.push();
@@ -225,6 +260,9 @@ export class BgServerTracker {
225
260
  }
226
261
  /** Kill every background server the agent started; returns the freed ports. */
227
262
  async killAll() {
263
+ if (this.servers.size === 0)
264
+ return [];
265
+ await this.verifyBeforeKill();
228
266
  if (this.servers.size === 0)
229
267
  return [];
230
268
  const killed = [];
@@ -12,7 +12,7 @@
12
12
  // ---------------------------------------------------------------------------
13
13
  import { defineTool } from "@earendil-works/pi-coding-agent";
14
14
  import { Type } from "typebox";
15
- import { bilingual, pick } from "./i18n.js";
15
+ import { pick } from "./i18n.js";
16
16
  import { CLAIM_FILES_TOOL_NAME } from "./tool-manager.js";
17
17
  import { CLAIM_TTL_MS, resolveClaimPath } from "./claim-store.js";
18
18
  /** 工具名(唯一定义在 tool-manager.ts,本模块 re-export 供单测沿用,模式同 present-files-tool.ts)。 */
@@ -30,13 +30,9 @@ export function makeClaimFilesTool(host, lang) {
30
30
  return defineTool({
31
31
  name: CLAIM_FILES_TOOL_NAME,
32
32
  label: "Claim files to avoid parallel conflicts",
33
- description: bilingual("Declare which files you are about to edit so parallel runs in the same project can steer clear (advisory only — it never blocks edits). " +
34
- "action=claim reserves paths for THIS conversation (first wins; already claimed by others is reported, not overridden); action=release frees yours (no paths = all yours); action=list shows this project's claim table. " +
35
- "Claims expire after ~30 min idle (each of your prompts refreshes them) and are released when the conversation closes. " +
36
- "Paths must stay inside the project directory. Only pi-engine conversations can claim (no customTool surface on DSH).", "声明你要改哪些文件,让同项目的并行对话绕行(纯建议,不拦任何编辑)。" +
37
- "action=claim 把路径记到本对话名下(先到先得;别人已认领的会告诉你,不抢占);action=release 放掉你自己的(不给 paths = 全放);action=list 看本项目的认领表。" +
38
- "认领闲置约 30 分钟过期(你每次发 prompt 自动续期),对话关闭时释放。" +
39
- "路径必须在项目目录内。只有 pi 引擎对话能认领(DSH 无 customTool 注册面)。"),
33
+ description: "Declare files you are about to edit so parallel runs in the same project steer clear (advisory only — never blocks edits). " +
34
+ "claim reserves paths for THIS conversation (first wins; conflicts reported, not overridden); release frees yours (no paths = all yours); list shows the project's claim table. " +
35
+ "Claims expire after ~30 min idle (your prompts refresh them) and release when the conversation closes. Paths must stay inside the project.",
40
36
  promptSnippet: "claim files you are about to edit (advisory, first-wins) so parallel runs steer clear",
41
37
  parameters: Type.Object({
42
38
  action: Type.Optional(Type.String({ description: 'claim = reserve paths; release = free yours; list = show table. Default "list".' })),
@@ -212,7 +212,8 @@ export class ClaimStore {
212
212
  save() {
213
213
  try {
214
214
  mkdirSync(dirname(this.file), { recursive: true });
215
- const tmp = `${this.file}.tmp`;
215
+ // tmp 带进程唯一后缀:同机多实例(多 worktree/多开)写同一文件不互踩
216
+ const tmp = `${this.file}.${process.pid}.tmp`;
216
217
  writeFileSync(tmp, JSON.stringify({ version: CLAIMS_FILE_VERSION, tables: Object.fromEntries(this.tables) }));
217
218
  renameSync(tmp, this.file);
218
219
  }
@@ -280,7 +281,8 @@ export async function mergeTouchSidecar(sessionFile, fresh) {
280
281
  return;
281
282
  try {
282
283
  const merged = unionTouchLists(readTouchSidecar(sessionFile) ?? [], fresh).slice(0, SIDECAR_MAX_ENTRIES);
283
- const tmp = `${sidecarPath(sessionFile)}.tmp`;
284
+ // tmp 带进程唯一后缀(同 client-state.ts 惯例):避免并发进程互踩
285
+ const tmp = `${sidecarPath(sessionFile)}.${process.pid}.tmp`;
284
286
  writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: Date.now(), files: merged }));
285
287
  renameSync(tmp, sidecarPath(sessionFile));
286
288
  }
@@ -259,6 +259,16 @@ export function normalizeWorkspaceRoots(v) {
259
259
  }
260
260
  return out;
261
261
  }
262
+ /** 跨平台(尤其是 Windows)路径归一化键:统一转绝对路径,并在 Windows 下转小写以消除大小写与正反斜杠差异。 */
263
+ export function normalizePathKey(p) {
264
+ try {
265
+ const resolved = resolve(p);
266
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
267
+ }
268
+ catch {
269
+ return process.platform === "win32" ? p.toLowerCase() : p;
270
+ }
271
+ }
262
272
  /**
263
273
  * Persists which workspace each browser client last used + which workspaces it
264
274
  * has opened, so a server restart / page reload restores the same project and
@@ -293,6 +303,26 @@ export class ClientStateStore {
293
303
  catch {
294
304
  this.cache = {};
295
305
  }
306
+ // 历史数据迁移:老版本将 removedProjects 仅记在各自临时 clientId 下,升级后新标签页无法继承。
307
+ // 启动/加载时自动将所有老 client 的墓碑合并到全局 __settings__,避免重启或新标签页后已删项目复活。
308
+ let migrated = false;
309
+ const globalState = (this.cache[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
310
+ const globalSet = new Set((globalState.removedProjects ?? []).map(normalizePathKey));
311
+ for (const [id, cState] of Object.entries(this.cache)) {
312
+ if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.removedProjects?.length) {
313
+ for (const p of cState.removedProjects) {
314
+ const key = normalizePathKey(p);
315
+ if (!globalSet.has(key)) {
316
+ globalSet.add(key);
317
+ (globalState.removedProjects ??= []).push(p);
318
+ migrated = true;
319
+ }
320
+ }
321
+ }
322
+ }
323
+ if (migrated) {
324
+ this.save();
325
+ }
296
326
  return this.cache;
297
327
  }
298
328
  save() {
@@ -318,11 +348,15 @@ export class ClientStateStore {
318
348
  const state = (all[clientId] ??= { projects: [] });
319
349
  state.lastCwd = cwd;
320
350
  const now = Date.now();
321
- state.projects = [{ path: cwd, lastUsed: now }, ...state.projects.filter((p) => p.path !== cwd)].slice(0, 30);
351
+ const targetKey = normalizePathKey(cwd);
352
+ state.projects = [
353
+ { path: cwd, lastUsed: now },
354
+ ...state.projects.filter((p) => normalizePathKey(p.path) !== targetKey),
355
+ ].slice(0, 30);
322
356
  // Opening the workspace again clears its removal tombstone across all clients and global settings.
323
357
  for (const cState of Object.values(all)) {
324
358
  if (cState.removedProjects?.length) {
325
- cState.removedProjects = cState.removedProjects.filter((p) => p !== cwd);
359
+ cState.removedProjects = cState.removedProjects.filter((p) => normalizePathKey(p) !== targetKey);
326
360
  }
327
361
  }
328
362
  this.save();
@@ -335,20 +369,21 @@ export class ClientStateStore {
335
369
  * explicitly opens that project again. */
336
370
  removeProject(clientId, cwd) {
337
371
  const all = this.load();
372
+ const targetKey = normalizePathKey(cwd);
338
373
  const state = (all[clientId] ??= { projects: [] });
339
- state.projects = state.projects.filter((p) => p.path !== cwd);
340
- if (state.lastCwd === cwd)
374
+ state.projects = state.projects.filter((p) => normalizePathKey(p.path) !== targetKey);
375
+ if (state.lastCwd && normalizePathKey(state.lastCwd) === targetKey)
341
376
  delete state.lastCwd;
342
- const removed = new Set(state.removedProjects ?? []);
343
- removed.add(cwd);
344
- state.removedProjects = [...removed];
377
+ const removed = (state.removedProjects ?? []).filter((p) => normalizePathKey(p) !== targetKey);
378
+ removed.push(cwd);
379
+ state.removedProjects = removed;
345
380
  const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
346
- const globalRemoved = new Set(globalState.removedProjects ?? []);
347
- globalRemoved.add(cwd);
348
- globalState.removedProjects = [...globalRemoved];
381
+ const globalRemoved = (globalState.removedProjects ?? []).filter((p) => normalizePathKey(p) !== targetKey);
382
+ globalRemoved.push(cwd);
383
+ globalState.removedProjects = globalRemoved;
349
384
  for (const [id, cState] of Object.entries(all)) {
350
385
  if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.projects) {
351
- cState.projects = cState.projects.filter((p) => p.path !== cwd);
386
+ cState.projects = cState.projects.filter((p) => normalizePathKey(p.path) !== targetKey);
352
387
  }
353
388
  }
354
389
  this.save();
@@ -363,7 +398,16 @@ export class ClientStateStore {
363
398
  return clientRemoved;
364
399
  if (clientRemoved.length === 0)
365
400
  return globalRemoved;
366
- return [...new Set([...clientRemoved, ...globalRemoved])];
401
+ const seen = new Set();
402
+ const result = [];
403
+ for (const p of [...clientRemoved, ...globalRemoved]) {
404
+ const key = normalizePathKey(p);
405
+ if (!seen.has(key)) {
406
+ seen.add(key);
407
+ result.push(p);
408
+ }
409
+ }
410
+ return result;
367
411
  }
368
412
  /** Last-used goal/review prefs for a client, or undefined if never set. */
369
413
  getGoalPrefs(clientId) {
@@ -548,8 +592,13 @@ export class ClientStateStore {
548
592
  toolImagesEnabled: settings.toolImagesEnabled ?? cur.toolImagesEnabled ?? true,
549
593
  skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
550
594
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
551
- visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
552
- subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
595
+ // 按键存在性合并:null 是合法值(清除语义),`null ?? cur` 会把旧值
596
+ // 复活到磁盘(设置面板清空后重启又回来)。settings-service 持久化
597
+ // 时传全量对象,键总在;其他调用方传 partial,键缺 = 保持旧值。
598
+ visionBridgeModel: "visionBridgeModel" in settings ? (settings.visionBridgeModel ?? null) : (cur.visionBridgeModel ?? null),
599
+ subagentDefaultModel: "subagentDefaultModel" in settings
600
+ ? (settings.subagentDefaultModel ?? null)
601
+ : (cur.subagentDefaultModel ?? null),
553
602
  retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
554
603
  softCapTokens: normalizeSoftCapTokens(settings.softCapTokens ?? cur.softCapTokens ?? 0),
555
604
  softCapByModel: normalizeSoftCapByModel(settings.softCapByModel ?? cur.softCapByModel ?? {}),
@@ -11,7 +11,7 @@
11
11
  // ---------------------------------------------------------------------------
12
12
  import { defineTool } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
- import { bilingual, pick } from "./i18n.js";
14
+ import { pick } from "./i18n.js";
15
15
  import { COMPACT_CONTEXT_TOOL_NAME } from "./tool-manager.js";
16
16
  /** 工具名(唯一登记在 tool-manager.ts,在此 re-export 供外部模块引用)。 */
17
17
  export { COMPACT_CONTEXT_TOOL_NAME };
@@ -58,13 +58,13 @@ export function buildCompactionInstructions(focus, customSummary) {
58
58
  }
59
59
  export const CompactContextParams = Type.Object({
60
60
  focus: Type.String({
61
- description: "Specific focus and requirements for context compaction based on the current issue or task. Clearly specify: 1) The active problem/goal being solved; 2) Crucial context to PRESERVE (key architectural decisions, code changes, conventions, user constraints); 3) Distractions or historical details to REMOVE or aggressively condense (failed attempts, resolved debugging outputs, off-topic discussions).\n根据当前问题/任务制定的上下文压缩重点。请指明:1) 当前正在解决的核心任务或问题;2) 必须保留的核心上下文(架构决策、已确认修改、约定规范等);3) 应当丢弃或极简概括的无关历史(错误尝试、冗长排查、其他不相关讨论等)。",
61
+ description: "Compression focus and requirements for the current issue/task. State: 1) the active problem/goal; 2) context to PRESERVE (key decisions, code changes, conventions, user constraints); 3) what to REMOVE or condense (failed attempts, resolved debugging, off-topic history).",
62
62
  }),
63
63
  keepRecentTokens: Type.Optional(Type.Number({
64
- description: "Optional scope of recent tokens to keep uncompacted (e.g. 1000 - 64000). Smaller values (e.g. 3000-8000) compact more aggressively, retaining only immediate context. Larger values retain more recent history. If omitted, the system automatically calculates a reasonable retention scope based on session size.\n可选的近期不压缩保留 token 范围(1000 - 64000)。较小值(如 3000-8000)压缩更彻底,只保留紧贴当前的上下文;较大值保留更多近期操作。不传时系统会根据当前会话规模自动计算合理的保留范围。",
64
+ description: "Recent tokens to keep uncompacted (1000-64000). Smaller values (e.g. 3000-8000) compact more aggressively. Omit = auto-calculated from session size.",
65
65
  })),
66
66
  summary: Type.Optional(Type.String({
67
- description: "Optional custom structured summary text written directly by you. If provided, this summary will be used as the core basis for the compaction entry.\n可选由你自主直接编写的精炼结构化摘要正文。若提供,系统将以此摘要为核心作为压缩总结。",
67
+ description: "Custom structured summary written by you; used as the core basis for the compaction entry if provided.",
68
68
  })),
69
69
  });
70
70
  export function makeCompactContextTool(host, lang) {
@@ -72,7 +72,8 @@ export function makeCompactContextTool(host, lang) {
72
72
  return defineTool({
73
73
  name: COMPACT_CONTEXT_TOOL_NAME,
74
74
  label: "Compact conversation context based on current issue",
75
- description: bilingual("Compress/compact the conversation context based on the current task or problem. You can specify what context to preserve (key decisions, code structure, requirements related to the current issue) and what to drop or summarize heavily (unrelated explorations, verbose tool outputs, resolved debugging steps). You can also control the compaction scope by setting how many recent tokens to keep untouched. Compaction executes when the current turn/run settles, refreshing the context for subsequent turns.", "根据当前任务或问题主动压缩上下文。你可以自主指定需要保留的关键信息(与当前问题相关的决策、代码结构、核心规范)以及需要丢弃或深度压缩的无关内容(无关探索、冗长输出、已解决的历史排查)。还可以通过指定保留最近的 token 数量来控制压缩范围。压缩将在本轮结束后立即执行,并在后续对话中生效。"),
75
+ description: "Compress/compact the conversation context around the current task: specify what to preserve (key decisions, code structure, active requirements) and what to drop or heavily summarize (unrelated exploration, verbose outputs, resolved debugging). " +
76
+ "Optionally control how many recent tokens stay untouched. Compaction executes when the current turn settles, refreshing context for subsequent turns.",
76
77
  promptSnippet: "proactively compact conversation context focusing on the current issue",
77
78
  promptGuidelines: [
78
79
  "Use compact_context when the conversation has grown long, or after extensive debugging/exploration, to focus context strictly on the current problem.",
@@ -62,6 +62,15 @@ export class ComposerDraftsStore {
62
62
  raw = JSON.parse(readFileSync(this.filePath, "utf8"));
63
63
  }
64
64
  catch {
65
+ // 坏文件改名留存而非直接覆盖:先有留存,后续 save 落盘的才是新数据
66
+ //(否则一次解析失败 → 空表 → 下次 save 把全部草稿的原始记录抹掉)。
67
+ try {
68
+ renameSync(this.filePath, `${this.filePath}.corrupt-${Date.now()}`);
69
+ console.warn(`[composer-drafts] 草稿文件解析失败,已改名留存:${this.filePath}(丢草稿可重打,不留坏副本)`);
70
+ }
71
+ catch {
72
+ // 改名失败(占用等)也无妨:空表继续,save 时照常覆盖
73
+ }
65
74
  raw = {};
66
75
  }
67
76
  const now = Date.now();