pi-web-ui 0.95.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +306 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
package/dist/server/eval-tool.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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.",
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
956
|
-
//
|
|
957
|
-
|
|
958
|
-
const safe =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
31
|
-
`
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
991
|
-
if (
|
|
992
|
-
reviewerVerdict =
|
|
993
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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")
|