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
@@ -22,7 +22,7 @@ import { join } from "node:path";
22
22
  import readline from "node:readline";
23
23
  import { defineTool } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
- import { bilingual, pick } from "./i18n.js";
25
+ import { pick } from "./i18n.js";
26
26
  import { EVAL_TOOL_NAME } from "./tool-manager.js";
27
27
  export { EVAL_TOOL_NAME };
28
28
  const DEFAULT_TIMEOUT_SECONDS = 15;
@@ -194,6 +194,59 @@ rl.on("line", (line) => {
194
194
  }
195
195
  });
196
196
  `;
197
+ /**
198
+ * 净化 Eval 子进程环境变量:仅保留操作系统基础变量,剥离所有包含 key/token/secret/auth/cred 等敏感环境变量,
199
+ * 杜绝沙箱脚本窃取宿主云凭据与 API Key。
200
+ */
201
+ export function sanitizeEvalEnv(sourceEnv) {
202
+ const allowedKeys = new Set([
203
+ "path",
204
+ "pathext",
205
+ "systemroot",
206
+ "windir",
207
+ "temp",
208
+ "tmp",
209
+ "tmpdir",
210
+ "home",
211
+ "userprofile",
212
+ "lang",
213
+ "lc_all",
214
+ "term",
215
+ "shell",
216
+ "comspec",
217
+ "os",
218
+ "number_of_processors",
219
+ "processor_architecture",
220
+ "appdata",
221
+ "localappdata",
222
+ "homedrive",
223
+ "homepath",
224
+ "systemdrive",
225
+ "programdata",
226
+ "programfiles",
227
+ "programfiles(x86)",
228
+ "commonprogramfiles",
229
+ "node_env",
230
+ ]);
231
+ const cleanEnv = {};
232
+ for (const [key, value] of Object.entries(sourceEnv)) {
233
+ if (value === undefined)
234
+ continue;
235
+ const lowerKey = key.toLowerCase();
236
+ if (lowerKey.includes("key") ||
237
+ lowerKey.includes("token") ||
238
+ lowerKey.includes("secret") ||
239
+ lowerKey.includes("auth") ||
240
+ lowerKey.includes("pass") ||
241
+ lowerKey.includes("cred")) {
242
+ continue;
243
+ }
244
+ if (allowedKeys.has(lowerKey) || lowerKey.startsWith("npm_") || lowerKey.startsWith("python")) {
245
+ cleanEnv[key] = value;
246
+ }
247
+ }
248
+ return cleanEnv;
249
+ }
197
250
  /** 单个持久内核工作进程包装。 */
198
251
  class EvalKernel {
199
252
  language;
@@ -221,7 +274,7 @@ class EvalKernel {
221
274
  stdio: ["pipe", "pipe", "pipe"],
222
275
  detached: process.platform !== "win32",
223
276
  env: {
224
- ...process.env,
277
+ ...sanitizeEvalEnv(process.env),
225
278
  PI_EVAL_WORKDIR: this.workdir,
226
279
  PI_EVAL_PROJECT_DIR: this.cwd,
227
280
  },
@@ -442,29 +495,27 @@ export function makeEvalTool(opts) {
442
495
  return defineTool({
443
496
  name: EVAL_TOOL_NAME,
444
497
  label: "Execute code in persistent sandbox",
445
- description: bilingual("Execute Python or JavaScript/TypeScript code in an isolated evaluation sandbox. " +
498
+ description: "Execute Python or JavaScript/TypeScript code in an isolated evaluation sandbox. " +
446
499
  "Variables and imported modules persist across calls within the conversation. " +
447
- "Ideal for quick calculations, data transformations, algorithm verification, and inspecting outputs without creating temporary script files.", "在隔离的代码求值沙箱中执行 Python 或 JavaScript/TypeScript 代码。" +
448
- "变量与导入的模块在会话内的多次调用间持续保留。" +
449
- "适用于无需创建临时文件的即时计算、数据转换、算法验证与推演。"),
450
- promptSnippet: bilingual("evaluate Python or JS/TS code with persistent state (default-off sandbox)", "在保持变量状态的沙箱中执行 Python 或 JS/TS 代码(默认关闭)"),
500
+ "Ideal for quick calculations, data transformations, algorithm verification, and inspecting outputs without creating temporary script files.",
501
+ promptSnippet: "evaluate Python or JS/TS code with persistent state (default-off sandbox)",
451
502
  parameters: Type.Object({
452
503
  code: Type.String({
453
- description: bilingual("The code snippet to evaluate. Top-level variables and functions are preserved across calls.", "要执行的代码片段。顶层变量和函数会在后续调用中持续保留。"),
504
+ description: "The code snippet to evaluate. Top-level variables and functions are preserved across calls.",
454
505
  }),
455
506
  language: Type.Optional(Type.Union([Type.Literal("py"), Type.Literal("js"), Type.Literal("ts")], {
456
- description: bilingual('Target language: "py" for Python (default), "js" or "ts" for Node.js sandbox.', '目标语言:"py" 为 Python(默认),"js" 或 "ts" 为 Node.js 沙箱。'),
507
+ description: 'Target language: "py" for Python (default), "js" or "ts" for Node.js sandbox.',
457
508
  })),
458
509
  title: Type.Optional(Type.String({
459
- description: bilingual('Optional short label for this step (e.g. "Calculate metrics", "Parse payload").', '可选的简短标题(例如 "计算指标"、"解析数据")。'),
510
+ description: 'Optional short label for this step (e.g. "Calculate metrics", "Parse payload").',
460
511
  })),
461
512
  timeout: Type.Optional(Type.Integer({
462
513
  minimum: 1,
463
514
  maximum: MAX_TIMEOUT_SECONDS,
464
- description: bilingual(`Execution timeout in seconds (default: ${DEFAULT_TIMEOUT_SECONDS}, maximum: ${MAX_TIMEOUT_SECONDS}).`, `执行超时秒数(默认 ${DEFAULT_TIMEOUT_SECONDS},最大 ${MAX_TIMEOUT_SECONDS})。`),
515
+ description: `Execution timeout in seconds (default: ${DEFAULT_TIMEOUT_SECONDS}, maximum: ${MAX_TIMEOUT_SECONDS}).`,
465
516
  })),
466
517
  reset: Type.Optional(Type.Boolean({
467
- description: bilingual("Whether to reset the sandbox environment before executing (clears all previous variables).", "是否在执行前重置沙箱环境(清空之前的所有变量)。"),
518
+ description: "Whether to reset the sandbox environment before executing (clears all previous variables).",
468
519
  })),
469
520
  }),
470
521
  execute: async (_id, params) => {
@@ -498,14 +549,16 @@ export function makeEvalTool(opts) {
498
549
  if (text.length > MAX_OUTPUT_CHARS) {
499
550
  text = text.slice(0, MAX_OUTPUT_CHARS) + `\n… [${text.length - MAX_OUTPUT_CHARS} chars truncated]`;
500
551
  }
552
+ const maxDetailChars = MAX_OUTPUT_CHARS;
553
+ const truncateDetail = (s) => s && s.length > maxDetailChars ? s.slice(0, maxDetailChars) + `\n… [truncated]` : s;
501
554
  return {
502
555
  content: [{ type: "text", text }],
503
556
  details: {
504
557
  language,
505
558
  durationMs,
506
559
  ok: res.ok,
507
- stdout: res.stdout,
508
- stderr: res.stderr,
560
+ stdout: truncateDetail(res.stdout),
561
+ stderr: truncateDetail(res.stderr),
509
562
  result: res.result,
510
563
  error: res.error,
511
564
  reset: params.reset ?? false,
@@ -64,12 +64,20 @@ export async function safeDestination(root, name) {
64
64
  }
65
65
  return path;
66
66
  }
67
- function byteLimit() {
67
+ /**
68
+ * Transform that enforces the 1 GiB expanded-size cap on the bytes that
69
+ * actually flow through it. `total` makes several transforms share one global
70
+ * counter — for ZIP extraction the header-declared uncompressedSize is
71
+ * attacker-controlled, so the cap must be enforced on REAL decompressed output.
72
+ */
73
+ function byteLimit(total) {
68
74
  let bytes = 0;
69
75
  return new Transform({
70
76
  transform(chunk, _encoding, done) {
71
77
  bytes += chunk.length;
72
- done(bytes > MAX_BYTES ? new Error("Archive exceeds 1 GiB expanded size") : null, chunk);
78
+ if (total)
79
+ total.bytes += chunk.length;
80
+ done((total ? total.bytes : bytes) > MAX_BYTES ? new Error("Archive exceeds 1 GiB expanded size") : null, chunk);
73
81
  },
74
82
  });
75
83
  }
@@ -119,7 +127,10 @@ export async function createArchive(source, outputDir) {
119
127
  async function unzip(source, stage) {
120
128
  const zip = await new Promise((ok, fail) => yauzl.open(source, { lazyEntries: true, strictFileNames: true }, (err, z) => (err ? fail(err) : ok(z))));
121
129
  let count = 0;
122
- let bytes = 0;
130
+ // 全局实量字节计数:zip 头声明的 uncompressedSize 可被伪造(头写小、解压输出
131
+ // 无限膨胀的 zip 炸弹),上限必须按每个条目实际解压输出累加,超限抛错 ——
132
+ // extractArchive 的 finally 会清理临时目录。tar 分支不受影响。
133
+ const total = { bytes: 0 };
123
134
  const paths = new Set();
124
135
  await new Promise((ok, fail) => {
125
136
  const abort = (err) => {
@@ -135,7 +146,7 @@ async function unzip(source, stage) {
135
146
  const type = (entry.externalFileAttributes >>> 16) & 0o170000;
136
147
  if (type && type !== 0o100000 && type !== 0o040000)
137
148
  throw new Error("Archive contains a link or special file");
138
- if (++count > MAX_ENTRIES || (bytes += entry.uncompressedSize) > MAX_BYTES)
149
+ if (++count > MAX_ENTRIES)
139
150
  throw new Error("Archive limit exceeded (20,000 entries / 1 GiB)");
140
151
  const dest = join(stage, name);
141
152
  if (entry.fileName.endsWith("/"))
@@ -143,7 +154,7 @@ async function unzip(source, stage) {
143
154
  else {
144
155
  await mkdir(dirname(dest), { recursive: true });
145
156
  const stream = await new Promise((resolveStream, reject) => zip.openReadStream(entry, (err, s) => (err ? reject(err) : resolveStream(s))));
146
- await pipeline(stream, byteLimit(), createWriteStream(dest, { flags: "wx", mode: 0o600 }));
157
+ await pipeline(stream, byteLimit(total), createWriteStream(dest, { flags: "wx", mode: 0o600 }));
147
158
  }
148
159
  zip.readEntry();
149
160
  })().catch(abort);
@@ -5,9 +5,9 @@
5
5
  * 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
6
6
  * 经 FilesHost 回调与 ClientSession 解耦。
7
7
  */
8
- import { Dirent, mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
8
+ import { Dirent, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { resolve, relative, sep } from "node:path";
10
+ import { resolve, relative, sep, isAbsolute } from "node:path";
11
11
  import { pick } from "./i18n.js";
12
12
  import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
13
13
  import { extractOfficeText, isOfficeFile, OFFICE_MAX_FILE_BYTES } from "./office-parse.js";
@@ -48,11 +48,15 @@ export function desktopDirWire(homeWire) {
48
48
  }
49
49
  /** wire 路径统一用 "/"。绝对 = posix "/...";win32 还有 "C:/..." / 裸 "C:"。
50
50
  * 机器浏览(越过工作区根换盘符)发送这些路径;工作区相对树不会产生它们(Windows
51
- * 文件名不能含 ":",相对路径经 relative() 归一化后也不以 "/" 开头)。 */
51
+ * 文件名不能含 ":",相对路径经 relative() 归一化后也不以 "/" 开头)。
52
+ * win32 还必须把盘符相对路径("D:x",冒号后无分隔符 —— resolve 会落到该盘的
53
+ * 当前目录)与 UNC("\\host\share";"//host/share" 已被上面的 "/" 前缀覆盖)
54
+ * 判为绝对:这些形态若被当工作区相对路径处理,会在跨盘/UNC 下绕过 ".." 检查
55
+ * 逃出工作区。posix 上 "C:x" 是合法文件名,故盘符检查必须限定在 win32。 */
52
56
  export function isAbsoluteWirePath(p) {
53
- if (p === MACHINE_ROOT || p.startsWith("/"))
57
+ if (p === MACHINE_ROOT || p.startsWith("/") || p.startsWith("\\\\"))
54
58
  return true;
55
- return IS_WIN32 && /^[A-Za-z]:([\\/]|$)/.test(p);
59
+ return IS_WIN32 && /^[A-Za-z]:/.test(p);
56
60
  }
57
61
  /** 去掉结尾 "/"(保留 posix 根 "/" 本身),归一成规范的 wire 形式。前端面包屑
58
62
  * 不产生结尾斜杠,这里只对补全/直接输入做防御性清理。 */
@@ -132,7 +136,11 @@ function ignoredEntries() {
132
136
  export function workspacePath(root, raw) {
133
137
  const abs = resolve(root, raw);
134
138
  const rawRel = relative(root, abs);
135
- if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
139
+ // 只查 ".." 拦不住 Windows 的跨盘/UNC:relative("C:/a","D:/x") 与
140
+ // relative("C:/a","//h/s") 返回绝对路径("D:\x"、"\\h\s\"),不以 ".." 开头;
141
+ // 盘符相对路径("D:x")经 resolve 落到该盘当前目录后也归此形 —— 必须同时
142
+ // 拒绝 isAbsolute 的结果,否则 "D:x" 这类输入可逃出工作区根。
143
+ if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`) || isAbsolute(rawRel))
136
144
  return null;
137
145
  // Normalize to forward slashes: the wire protocol and the frontend always
138
146
  // use "/", but relative() returns "\\" on Windows.
@@ -952,10 +960,14 @@ export class FilesService {
952
960
  else {
953
961
  wp = { abs: root, rel: "" };
954
962
  }
955
- // Basename only — strips any path separators / ".." the name carries;
956
- // reject empty results and Windows-illegal characters outright.
957
- const base = name.split(/[\\/]/).pop() ?? "";
958
- const safe = (base.replace(/[/:*?"<>|\x00-\x1f]/g, "_").trim() || "file").slice(0, 200);
963
+ // 与 createEntry/renameEntry 同口径(this.sanitizeName):只取 basename,
964
+ // 空 / "." / ".." / Windows 保留名 / 尾点空格一律拒绝 —— 旧的内联替换
965
+ // 会放过 "con.txt"、"a.." 这类「建了删不掉」的名字。
966
+ const safe = this.sanitizeName(name);
967
+ if (!safe) {
968
+ emitErr(`文件名不合法:${name}`, `Invalid file name: ${name}`);
969
+ return;
970
+ }
959
971
  const abs = resolve(wp.abs, safe);
960
972
  let uploadRel;
961
973
  if (absDir) {
@@ -984,7 +996,14 @@ export class FilesService {
984
996
  return;
985
997
  }
986
998
  mkdirSync(wp.abs, { recursive: true });
987
- writeFileSync(abs, buf);
999
+ if (existsSync(abs)) {
1000
+ emitErr(`已存在:${uploadRel}`, `Already exists: ${uploadRel}`);
1001
+ return;
1002
+ }
1003
+ // wx:与预检之间存在窗口(TOCTOU),并发同名上传走 EEXIST 落到 catch
1004
+ // 报错 —— 两个上传路径语义统一为「目标已存在拒绝」,绝不静默截断覆盖
1005
+ // (/api/file-transfer/upload 一直是 open(dest, "wx") 拒绝)。
1006
+ writeFileSync(abs, buf, { flag: "wx" });
988
1007
  this.host.emit({
989
1008
  type: "notice",
990
1009
  level: "info",
@@ -12,11 +12,111 @@
12
12
  * 结构化子集 GoalConversation 传入(真实 Conversation 满足该结构),会话创建/对话框
13
13
  * 取消/git diff 等宿主能力走回调,便于独立测试。UI 文案直接中文(服务端 notice 约定)。
14
14
  */
15
+ import { mkdirSync, rmSync } from "node:fs";
15
16
  import { join } from "node:path";
16
17
  import { Type } from "typebox";
17
18
  import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
- import { bilingual, pick } from "./i18n.js";
19
+ import { pick } from "./i18n.js";
19
20
  import { parseModelSpec } from "./attachments.js";
21
+ /**
22
+ * 自主模式完成标记(与注入对话的【目标…】约定严格一致):
23
+ * - 【目标已达成】/【目标完成】/【目标达成】——必须带全角括号;
24
+ * - GOAL 后必须跟至少一个分隔符(冒号/下划线/空白)且 COMPLETED/PASSED 为整词。
25
+ * 刻意不收裸子串(如「目标已达成」不带括号):模型在计划、复述目标或假设句里
26
+ * 也会写出这些字样("如果测试全绿则目标已达成"),裸匹配会把中间轮误判成 pass。
27
+ */
28
+ const GOAL_COMPLETION_RE = /【目标(?:已)?(?:达成|完成)】|(?<![A-Za-z])GOAL(?:[::_]|\s+)+(?:IS\s+)?(?:COMPLETED|PASSED)(?![A-Za-z])/i;
29
+ /** 自主轮次完成信号判定(纯函数,供 runGoalReview 与单测共用)。 */
30
+ export function isGoalCompletionSignal(finalText) {
31
+ return GOAL_COMPLETION_RE.test(finalText);
32
+ }
33
+ /** 提取 raw 中第一个括号平衡的 {...} 子串(字符串字面量内的引号/转义/花括号不参与配对)。 */
34
+ function firstBalancedJsonObject(raw) {
35
+ const start = raw.indexOf("{");
36
+ if (start < 0)
37
+ return undefined;
38
+ let depth = 0;
39
+ let inString = false;
40
+ let escaped = false;
41
+ for (let i = start; i < raw.length; i++) {
42
+ const ch = raw[i];
43
+ if (inString) {
44
+ if (escaped)
45
+ escaped = false;
46
+ else if (ch === "\\")
47
+ escaped = true;
48
+ else if (ch === '"')
49
+ inString = false;
50
+ continue;
51
+ }
52
+ if (ch === '"')
53
+ inString = true;
54
+ else if (ch === "{")
55
+ depth++;
56
+ else if (ch === "}") {
57
+ depth--;
58
+ if (depth === 0)
59
+ return raw.slice(start, i + 1);
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * 解析审查模型的 verdict 输出(纯函数)。优先取第一个平衡 {...} 做 JSON.parse:
66
+ * 模型常包 markdown 围栏或前后闲话,feedback 里也可能有 \" 转义与嵌套引号,
67
+ * 这些由 JSON 语义天然处理;整体解析失败(单引号/尾逗号等)再退回旧的宽松
68
+ * 正则逐字段抠。两者都失败返回 undefined,调用方按「无 JSON」处理。
69
+ */
70
+ export function parseReviewerVerdict(raw) {
71
+ const json = firstBalancedJsonObject(raw);
72
+ if (json !== undefined) {
73
+ try {
74
+ const value = JSON.parse(json);
75
+ if (value && typeof value === "object" && !Array.isArray(value)) {
76
+ if (value.verdict === "pass" || value.verdict === "fail") {
77
+ return { verdict: value.verdict, feedback: typeof value.feedback === "string" ? value.feedback : "" };
78
+ }
79
+ }
80
+ }
81
+ catch {
82
+ // 不是合法 JSON(围栏残留/单引号/尾逗号)→ 落到正则兜底
83
+ }
84
+ }
85
+ const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
86
+ if (m) {
87
+ const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
88
+ return { verdict: m[1], feedback: fm?.[1] ?? "" };
89
+ }
90
+ return undefined;
91
+ }
92
+ /** diff 正文进审查 prompt 的截断上限(完整规模信息走 [diff-meta] 尾段)。 */
93
+ export const GIT_DIFF_CAP = 60_000;
94
+ /**
95
+ * 由 git 原始输出构造「变更指纹」(纯函数,供 AgentService.gitDiff 与单测共用)。
96
+ * - diff 正文非空 → 截断正文 + [diff-meta] 尾段(完整字符数 + 排序后的 status
97
+ * 指纹)。尾段永不参与截断:大 diff 两轮的前 60_000 字符可能完全相同(改动
98
+ * 落在截断线之后),只比截断正文会把持续推进误判成停滞;对内容变化敏感的
99
+ * 完整字符数让 prevDiff 等值比较能区分「真没变」与「变了但被截断」。
100
+ * - diff 正文为空 → 排序后的 `git status --porcelain` 指纹(未跟踪文件不进
101
+ * diff,却是新工作区最常见的实际进展);两段都空(返回 "")才算真停滞。
102
+ * - status 输出为空/拍不到 → 对应段省略,退化为旧版纯 diff 行为。
103
+ */
104
+ export function buildDiffFingerprint(diffOut, statusOut) {
105
+ let status = "";
106
+ if (statusOut.trim() !== "") {
107
+ // porcelain 不承诺输出有序,显式排序保证指纹逐轮稳定可比。
108
+ status = statusOut
109
+ .split("\n")
110
+ .filter((line) => line.trim() !== "")
111
+ .sort()
112
+ .join("\n")
113
+ .slice(0, 20_000);
114
+ }
115
+ if (diffOut.trim() === "")
116
+ return status;
117
+ const meta = `\n[diff-meta] chars=${diffOut.length}${status ? `\n[git-status]\n${status}` : ""}`;
118
+ return diffOut.slice(0, GIT_DIFF_CAP) + meta;
119
+ }
20
120
  /** System prompt for the goal-wizard session. The wizard asks the user a few
21
121
  * questions (via its goal_ask tool) to scope a raw requirement into a precise,
22
122
  * reviewable goal, then emits ONLY the final goal text as its last message. */
@@ -27,8 +127,11 @@ function wizardPrompt(draft) {
27
127
  `# User's raw requirement`, // eslint-disable-line no-regex-spaces
28
128
  draft,
29
129
  ``,
30
- `Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details. Keep it concise — usually 2 to 4 questions: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment).`, // eslint-disable-line max-len
31
- `Prefer multiple-choice (goal_ask with options) when you can offer clear choices; use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
130
+ `Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details.`,
131
+ `Convergence guidelines:`,
132
+ `- Ask ONE question at a time, strictly 1 to 3 questions total: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment).`, // eslint-disable-line max-len
133
+ `- Prefer multiple-choice with 2-4 mutually exclusive options and place your recommended choice FIRST.`,
134
+ `- In each option, concisely explain the impact or tradeoff. Use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
32
135
  `Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`, // eslint-disable-line max-len
33
136
  `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
34
137
  `If the user cancels or stops answering (the tool reports a cancellation), still produce a sensible best-effort goal from what you already know.`, // eslint-disable-line max-len
@@ -363,6 +466,7 @@ export class GoalService {
363
466
  draft,
364
467
  }), { draft });
365
468
  let refinedGoal = "";
469
+ let goalEphemeralDir;
366
470
  try {
367
471
  const wmSpec = opts?.wizardModel ? this.resolveReviewModel(opts.wizardModel) : null; // reuse the honest "provider/id" parser
368
472
  const services = await createAgentSessionServices({
@@ -387,10 +491,14 @@ export class GoalService {
387
491
  const goalAsk = defineTool({
388
492
  name: "goal_ask",
389
493
  label: "Ask the user",
390
- description: bilingual("Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.", "一次只向用户提一个问题,以明确目标范围。给出清晰的问题和 2-4 个简洁选项;或提开放式问题。返回用户选择的答案。"),
494
+ description: "Ask the user ONE focused question at a time to scope down the goal. " +
495
+ "Provide 2-4 mutually exclusive options with the recommended option first, " +
496
+ "briefly noting its impact or tradeoff; or ask an open question. Returns the user's chosen answer.",
391
497
  parameters: Type.Object({
392
- question: Type.String({ description: bilingual("The question to ask", "要问的问题") }),
393
- options: Type.Optional(Type.Array(Type.String())),
498
+ question: Type.String({ description: "The question to ask" }),
499
+ options: Type.Optional(Type.Array(Type.String(), {
500
+ description: "2-4 mutually exclusive options (recommended option first)",
501
+ })),
394
502
  }),
395
503
  // ONE question at a time. Sequential execution prevents the agent from
396
504
  // firing parallel goal_ask calls whose dialogs would overwrite each other
@@ -496,9 +604,16 @@ export class GoalService {
496
604
  }
497
605
  },
498
606
  });
607
+ const sm = SessionManager.inMemory(this.host.cwd());
608
+ goalEphemeralDir = join(services.agentDir, "goal-sessions", `wizard-${Date.now()}`);
609
+ try {
610
+ mkdirSync(goalEphemeralDir, { recursive: true });
611
+ sm.sessionDir = goalEphemeralDir;
612
+ }
613
+ catch { }
499
614
  const srv = await createAgentSessionFromServices({
500
615
  services,
501
- sessionManager: SessionManager.inMemory(this.host.cwd()),
616
+ sessionManager: sm,
502
617
  customTools: [goalAsk],
503
618
  ...(model ? { model } : {}),
504
619
  });
@@ -550,6 +665,12 @@ export class GoalService {
550
665
  wgoal.wizard.status = "";
551
666
  wgoal.wizard.statusEn = "";
552
667
  this.wizardSession = null;
668
+ if (goalEphemeralDir) {
669
+ try {
670
+ rmSync(goalEphemeralDir, { recursive: true, force: true });
671
+ }
672
+ catch { }
673
+ }
553
674
  this.emitGoalStatus();
554
675
  }
555
676
  // Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal. The raw
@@ -728,9 +849,12 @@ export class GoalService {
728
849
  // Goal review hook: after the run finished normally, if a goal is
729
850
  // active (and it belonged to the ACTIVE conversation) and we're not
730
851
  // already mid-review, spawn the isolated reviewer.
852
+ // Only run when verdict is pending — failed/blocked terminal goals must not
853
+ // re-trigger reviews on subsequent unrelated conversational turns.
731
854
  if (g.goal &&
732
855
  g.conversationId === conv.id &&
733
856
  !g.reviewing &&
857
+ g.verdict === "pending" &&
734
858
  !conv.wizardRunning &&
735
859
  !this.host.isDisposed() &&
736
860
  this.goalEnabled()) {
@@ -913,9 +1037,8 @@ export class GoalService {
913
1037
  const isAutonomous = !g.reviewModel;
914
1038
  if (isAutonomous) {
915
1039
  // DSH 风格自主轮次驱动(免拉起独立审查会话,省 token + 零启动延迟):
916
- // 检查模型自身是否在输出中表明目标已达成
917
- const completionRegex = /【目标(?:已)?(?:达成|完成)】|GOAL(?:[::_]|\s+)*(?:IS\s+)?(?:COMPLETED|PASSED)|目标已达成|目标已完成/i;
918
- const isCompleted = completionRegex.test(finalText);
1040
+ // 检查模型自身是否在输出中表明目标已达成(只认约定标记,见 GOAL_COMPLETION_RE)
1041
+ const isCompleted = isGoalCompletionSignal(finalText);
919
1042
  if (isCompleted) {
920
1043
  reviewerVerdict = "pass";
921
1044
  reviewerFeedback = pick(this.lang(), "模型自主验证:目标已达成", "Model autonomous evaluation: Goal completed", "goal.autonomous.pass");
@@ -987,11 +1110,10 @@ export class GoalService {
987
1110
  await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
988
1111
  // Parse the reviewer's final output (expected to be a JSON object).
989
1112
  const raw = reviewer.getLastAssistantText() ?? "";
990
- const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
991
- if (m) {
992
- reviewerVerdict = m[1];
993
- const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
994
- reviewerFeedback = fm?.[1] ?? "";
1113
+ const parsed = parseReviewerVerdict(raw);
1114
+ if (parsed) {
1115
+ reviewerVerdict = parsed.verdict;
1116
+ reviewerFeedback = parsed.feedback;
995
1117
  }
996
1118
  else {
997
1119
  // No JSON — assume fail with the raw output as feedback.
@@ -1063,8 +1185,7 @@ export class GoalService {
1063
1185
  catch {
1064
1186
  // Best-effort.
1065
1187
  }
1066
- g.conversationId = null;
1067
- g.goal = null; // loop blocked — clear the active goal
1188
+ g.reviewing = false;
1068
1189
  this.emitGoalStatus();
1069
1190
  this.host.flushSnapshot();
1070
1191
  return;
@@ -1075,6 +1196,8 @@ export class GoalService {
1075
1196
  if (!isLastRound) {
1076
1197
  g.status = `本轮不通过,正在把意见交给 agent 修改(${roundsZh})…`;
1077
1198
  g.statusEn = `Round failed, sending feedback to the agent (${roundsEn})…`;
1199
+ // 注入修改意见后保持 verdict 为 pending,让 agent 下一次答完后继续下一轮审查
1200
+ g.verdict = "pending";
1078
1201
  this.host.emit({
1079
1202
  type: "notice",
1080
1203
  level: "warning",
@@ -1126,8 +1249,7 @@ export class GoalService {
1126
1249
  text: "目标未通过审查(已达最大轮数)",
1127
1250
  textEn: "Goal failed review (max rounds reached)",
1128
1251
  });
1129
- g.conversationId = null;
1130
- g.goal = null; // loop exhausted — clear the active goal
1252
+ g.reviewing = false; // 审查结束:保留 g.goal 与 g.conversationId,让用户看到未通过的目标,不丢弃目标文本
1131
1253
  this.emitGoalStatus();
1132
1254
  this.host.flushSnapshot();
1133
1255
  }
@@ -287,6 +287,9 @@ export function parseHashlinePatch(patchText) {
287
287
  // 快照存储与三方合并恢复(Snapshot Store & 3-Way Merge Recovery)
288
288
  // ----------------------------------------------------------------------------
289
289
  export class HashlineSnapshotStore {
290
+ /** 单个文件保留的快照代数上限(LRU):全局单例会跨整个会话存活,反复编辑
291
+ * 同一文件会让快照按编辑次数无界增长,必须驱逐最旧一代。 */
292
+ static MAX_PER_FILE = 200;
290
293
  snapshots = new Map(); // path -> (hash -> content)
291
294
  record(filePath, text) {
292
295
  const hash = computeFileHash(text);
@@ -295,11 +298,29 @@ export class HashlineSnapshotStore {
295
298
  fileMap = new Map();
296
299
  this.snapshots.set(filePath, fileMap);
297
300
  }
301
+ // 先删后插:覆盖同代快照的同时把访问序刷到尾部(Map 插入序即 LRU 序)
302
+ fileMap.delete(hash);
298
303
  fileMap.set(hash, text);
304
+ while (fileMap.size > HashlineSnapshotStore.MAX_PER_FILE) {
305
+ const oldest = fileMap.keys().next().value;
306
+ if (oldest === undefined)
307
+ break;
308
+ fileMap.delete(oldest);
309
+ }
299
310
  return hash;
300
311
  }
301
312
  get(filePath, hash) {
302
- return this.snapshots.get(filePath)?.get(hash.toUpperCase());
313
+ const fileMap = this.snapshots.get(filePath);
314
+ if (!fileMap)
315
+ return undefined;
316
+ const key = hash.toUpperCase();
317
+ const hit = fileMap.get(key);
318
+ if (hit !== undefined) {
319
+ // get 命中即 refresh:重插到尾部,正在参与三方合并的快照不被 LRU 驱逐
320
+ fileMap.delete(key);
321
+ fileMap.set(key, hit);
322
+ }
323
+ return hit;
303
324
  }
304
325
  }
305
326
  /** 全局会话级快照单例 */
@@ -433,9 +454,10 @@ export function applyHashlinePatch(patchText, options = {}) {
433
454
  const liveHash = computeFileHash(currentText);
434
455
  let workingText = currentText;
435
456
  let isRecovered = false;
436
- // 哈希校验与三方冲突自愈
457
+ // 哈希校验与三方冲突自愈(快照按绝对路径优先隔离,兼容相对路径回退)
458
+ const absFilePath = resolve(cwd, sec.filePath);
437
459
  if (sec.expectedHash && sec.expectedHash !== liveHash) {
438
- const snapshot = store.get(sec.filePath, sec.expectedHash);
460
+ const snapshot = store.get(absFilePath, sec.expectedHash) ?? store.get(sec.filePath, sec.expectedHash);
439
461
  if (snapshot) {
440
462
  const recovery = tryRecoverEdits(snapshot, currentText, sec.hunks, sec.filePath);
441
463
  if (recovery.success && recovery.remappedHunks) {
@@ -548,26 +570,39 @@ export function applyHashlinePatch(patchText, options = {}) {
548
570
  }
549
571
  const replacementSpans = [];
550
572
  for (const h of sec.hunks) {
551
- if (h.kind === "put_range" ||
573
+ let s;
574
+ let e;
575
+ if (h.kind === "insert_before" || h.kind === "insert_after") {
576
+ // insert 类 hunk 也纳入重叠校验(锚点单行):执行时按行号降序 splice,
577
+ // 若锚点行被同补丁的 replace/cut 覆盖删除、或与其它 insert 锚点重合,
578
+ // 插入位置会随前面的改动串位,产出与模型意图不符的内容。保守策略:
579
+ // 锚点行与任何 replace span / 其它 insert 锚点重叠即报错。
580
+ s = h.lineStart === -1 ? lines.length : (h.lineStart ?? 1);
581
+ e = s;
582
+ }
583
+ else if (h.kind === "put_range" ||
552
584
  h.kind === "put_block" ||
553
585
  h.kind === "cut_range" ||
554
586
  h.kind === "cut_block" ||
555
587
  h.kind === "paste_over_range" ||
556
588
  h.kind === "paste_over_block") {
557
- const s = h.lineStart === -1 ? lines.length : (h.lineStart ?? 1);
558
- const e = h.lineEnd ?? s;
559
- for (const prev of replacementSpans) {
560
- if (!(e < prev.start || s > prev.end)) {
561
- return {
562
- ok: false,
563
- summary: `补丁段存在重叠的行范围:${sec.filePath}(行 ${s}-${e} 与行 ${prev.start}-${prev.end} 发生重叠)。请合并为一个连续的修改块。`,
564
- results: [],
565
- error: `Overlapping hunks in ${sec.filePath} (${s}-${e} overlaps with ${prev.start}-${prev.end})`,
566
- };
567
- }
589
+ s = h.lineStart === -1 ? lines.length : (h.lineStart ?? 1);
590
+ e = h.lineEnd ?? s;
591
+ }
592
+ else {
593
+ continue;
594
+ }
595
+ for (const prev of replacementSpans) {
596
+ if (!(e < prev.start || s > prev.end)) {
597
+ return {
598
+ ok: false,
599
+ summary: `补丁段存在重叠的行范围:${sec.filePath}(行 ${s}-${e} 与行 ${prev.start}-${prev.end} 发生重叠)。请合并为一个连续的修改块。`,
600
+ results: [],
601
+ error: `Overlapping hunks in ${sec.filePath} (${s}-${e} overlaps with ${prev.start}-${prev.end})`,
602
+ };
568
603
  }
569
- replacementSpans.push({ start: s, end: e });
570
604
  }
605
+ replacementSpans.push({ start: s, end: e });
571
606
  }
572
607
  // 按行号从大到小排序执行,避免前方修改引起后续行号错位
573
608
  const sortedHunks = [...sec.hunks]
@@ -642,13 +677,16 @@ export function applyHashlinePatch(patchText, options = {}) {
642
677
  }
643
678
  }
644
679
  // ===== 阶段 2: 全部校验通过,统一落盘 =====
645
- for (const p of plannedDeletes) {
646
- remove(p);
647
- }
680
+ // 先执行所有写入,确保新文件与移动目标成功落盘,避免先删后写异常时永久丢失源文件
648
681
  for (const [p, content] of plannedWrites.entries()) {
649
682
  write(p, content);
650
- // 记住新快照
651
- store.record(p, content);
683
+ // 记住新快照(统一采用绝对路径隔离多工作区)
684
+ store.record(resolve(cwd, p), content);
685
+ }
686
+ for (const p of plannedDeletes) {
687
+ if (!plannedWrites.has(p)) {
688
+ remove(p);
689
+ }
652
690
  }
653
691
  const summaryParts = results.map((r) => {
654
692
  if (r.op === "deleted")