pi-web-ui 0.97.0 → 0.99.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 +126 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-web-ui.mjs +64 -23
- package/dist/server/agent-service.js +645 -147
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +220 -23
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +7 -5
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +6 -12
- package/dist/server/dsh/dsh-agent-service.js +32 -30
- package/dist/server/dsh/dsh-client.js +45 -9
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +30 -5
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +77 -10
- package/dist/server/index.js +83 -15
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +110 -14
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -26
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +2 -3
- package/dist/server/patch-tool.js +78 -28
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +196 -10
- package/dist/server/plan-mode.js +58 -20
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +3 -5
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +16 -2
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +22 -9
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +22 -11
- package/dist/server/terminals.js +24 -12
- package/dist/server/text-quote.js +59 -0
- package/dist/server/tool-manager.js +12 -3
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +69 -40
- package/extensions/webui.ts +15 -1
- package/package.json +31 -19
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
- package/web/dist/assets/index-BdMaGCcs.css +1 -0
- package/web/dist/assets/index-ByCBN4Kt.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-DwToli0J.css +0 -1
- package/web/dist/assets/index-oI7UKOqJ.js +0 -368
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { getServerBlock, pick } from "../../i18n.js";
|
|
5
5
|
const NOTIFY_GUIDANCE_ZH = [
|
|
6
|
-
|
|
6
|
+
// #499:guidance 只能承诺协议真实支持的级别——success 从未存在于 notice 协议
|
|
7
|
+
// (protocol.ts 仅 info|warning|error),按指引用 success 会被静默降级成 info。
|
|
8
|
+
"- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|error。",
|
|
7
9
|
];
|
|
8
10
|
const NOTIFY_GUIDANCE_EN = [
|
|
9
|
-
"- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|
|
|
11
|
+
"- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|error.",
|
|
10
12
|
];
|
|
11
13
|
/** 语言感知的 notify guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
|
|
12
14
|
export function getNotifyGuidance(lang = "en") {
|
|
@@ -15,30 +15,6 @@ function parseId(raw) {
|
|
|
15
15
|
const n = Number(raw);
|
|
16
16
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
17
17
|
}
|
|
18
|
-
function formatStatus(s) {
|
|
19
|
-
switch (s) {
|
|
20
|
-
case "pending":
|
|
21
|
-
return "pending";
|
|
22
|
-
case "in_progress":
|
|
23
|
-
return "in_progress";
|
|
24
|
-
case "completed":
|
|
25
|
-
return "completed";
|
|
26
|
-
default:
|
|
27
|
-
return s;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export function describeTodos(state, includeDeleted = false, lang = "en") {
|
|
31
|
-
const visible = state.tasks.filter((t) => includeDeleted || t.status !== "deleted");
|
|
32
|
-
if (visible.length === 0)
|
|
33
|
-
return pick(lang, "[todo] (空)", "[todo] (empty)", "markers.todo.list.empty");
|
|
34
|
-
return visible
|
|
35
|
-
.map((t) => {
|
|
36
|
-
const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
|
|
37
|
-
const deps = t.blockedBy.length ? ` ⛓ ${t.blockedBy.join(",")}` : "";
|
|
38
|
-
return `[${formatStatus(t.status)}] #${t.id} ${t.subject}${form}${deps}`;
|
|
39
|
-
})
|
|
40
|
-
.join("\n");
|
|
41
|
-
}
|
|
42
18
|
const TODO_GUIDANCE_ZH = [
|
|
43
19
|
"- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
|
|
44
20
|
"- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
|
|
@@ -64,7 +40,9 @@ export const todoMarker = {
|
|
|
64
40
|
const op = token.op;
|
|
65
41
|
switch (op) {
|
|
66
42
|
case "new": {
|
|
67
|
-
|
|
43
|
+
// #499:subject 是唯一「整段自由文本」参数——args.join 还原含逗号的主题,
|
|
44
|
+
// 否则 `[[todo:new:修复登录页,同步改注册页]]` 的后半段被静默丢弃。
|
|
45
|
+
const subject = token.args.join(",").trim();
|
|
68
46
|
if (!subject)
|
|
69
47
|
return {
|
|
70
48
|
applied: false,
|
|
@@ -179,7 +157,9 @@ export const todoMarker = {
|
|
|
179
157
|
const depsText = deps.length ? deps.join(",") : lang === "zh" ? "(无)" : "(none)";
|
|
180
158
|
return {
|
|
181
159
|
applied: true,
|
|
182
|
-
feedback: pick(lang, `#${id} 依赖:${depsText}`,
|
|
160
|
+
feedback: pick(lang, `#${id} 依赖:${depsText}`,
|
|
161
|
+
// #499:实现是 blockedBy(deps 先完成)——原英文 "blocks" 把依赖方向说反了。
|
|
162
|
+
`#${id} is blocked by: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
|
|
183
163
|
};
|
|
184
164
|
}
|
|
185
165
|
default:
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
* marker.ts — 通用内联标记核心抽象(内置版)。
|
|
3
3
|
* 复刻自 pi-marker-tools,保持相同解析语义,便于 AI 无缝迁移。
|
|
4
4
|
*/
|
|
5
|
-
export const MARKER_OPEN = "[[";
|
|
6
|
-
export const MARKER_CLOSE = "]]";
|
|
7
5
|
// ---------------------------------------------------------------------------
|
|
8
6
|
// 解析器
|
|
9
7
|
// ---------------------------------------------------------------------------
|
|
@@ -38,16 +36,3 @@ export function parseMarkers(text) {
|
|
|
38
36
|
}
|
|
39
37
|
return tokens;
|
|
40
38
|
}
|
|
41
|
-
export function stripMarkers(text) {
|
|
42
|
-
return text.replace(TOKEN_RE, () => "");
|
|
43
|
-
}
|
|
44
|
-
export function replaceToken(text, raw, replacement) {
|
|
45
|
-
return text.split(raw).join(replacement);
|
|
46
|
-
}
|
|
47
|
-
export function serializeToken(token) {
|
|
48
|
-
const parts = [token.tool, token.op, ...token.args];
|
|
49
|
-
const kwargs = Object.entries(token.kwargs)
|
|
50
|
-
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
51
|
-
.map(([k, v]) => `${k}=${v}`);
|
|
52
|
-
return `${MARKER_OPEN}${[...parts, ...kwargs].join(":")}${MARKER_CLOSE}`;
|
|
53
|
-
}
|
|
@@ -11,9 +11,6 @@ export function getMarker(name) {
|
|
|
11
11
|
export function allMarkers() {
|
|
12
12
|
return [...registry.values()];
|
|
13
13
|
}
|
|
14
|
-
export function lookupToken(name) {
|
|
15
|
-
return registry.get(name);
|
|
16
|
-
}
|
|
17
14
|
export function collectGuidance(disabled = new Set(), lang = "en") {
|
|
18
15
|
const out = [];
|
|
19
16
|
for (const m of allMarkers()) {
|
|
@@ -23,9 +23,6 @@ export function loadStateFromBranch(branch, namespace) {
|
|
|
23
23
|
}
|
|
24
24
|
return latest?.state;
|
|
25
25
|
}
|
|
26
|
-
export function hasStateInBranch(branch, namespace) {
|
|
27
|
-
return loadStateFromBranch(branch, namespace) !== undefined;
|
|
28
|
-
}
|
|
29
26
|
/** 追加快照:优先走 sessionManager.appendCustomEntry,否则回退到回调。 */
|
|
30
27
|
export function appendSnapshot(mgr, namespace, state, fallbackSave) {
|
|
31
28
|
const snapshot = {
|
|
@@ -31,15 +31,22 @@ export function createMcpHotReload(deps) {
|
|
|
31
31
|
let debounce = null;
|
|
32
32
|
let poller = null;
|
|
33
33
|
let watcher = null;
|
|
34
|
-
/** 读一次磁盘:指纹 + 清单(`servers` 为 null =
|
|
34
|
+
/** 读一次磁盘:指纹 + 清单(`servers` 为 null = 坏配置/读失败)。文件不在按「没有服务器」算。 */
|
|
35
35
|
function readOnce() {
|
|
36
36
|
let raw;
|
|
37
37
|
try {
|
|
38
38
|
raw = readFileSync(file, "utf8");
|
|
39
39
|
}
|
|
40
|
-
catch {
|
|
41
|
-
//
|
|
42
|
-
|
|
40
|
+
catch (err) {
|
|
41
|
+
// 不变量 3 只覆盖「文件不在」:删掉 mcp.json = 关掉全部 MCP 服务器。
|
|
42
|
+
// #497:EBUSY/EACCES 等临时读失败(OneDrive/杀毒独占、权限误改)既不是
|
|
43
|
+
// 坏配置也不是删除意图——按坏配置口径走(servers: null → 保留在跑的
|
|
44
|
+
// 服务器 + 一次性提示),否则会把在跑的 MCP 服务器全杀还报「成功 0 个」。
|
|
45
|
+
const code = err?.code;
|
|
46
|
+
if (code === "ENOENT")
|
|
47
|
+
return { fp: "empty", servers: {} };
|
|
48
|
+
log(`[mcp] mcp.json 读取失败(${code ?? String(err)}),按坏配置处理:保留在跑的 MCP 服务器`);
|
|
49
|
+
return { fp: `unreadable:${code ?? "unknown"}`, servers: null };
|
|
43
50
|
}
|
|
44
51
|
const parsed = parseMcpConfig(raw);
|
|
45
52
|
if (!parsed)
|
|
@@ -54,7 +61,7 @@ export function createMcpHotReload(deps) {
|
|
|
54
61
|
applied = fp;
|
|
55
62
|
if (!servers) {
|
|
56
63
|
log("[mcp] mcp.json 解析失败,保留在跑的 MCP 服务器");
|
|
57
|
-
deps.onNotice?.("warning", "mcp.json
|
|
64
|
+
deps.onNotice?.("warning", "mcp.json 解析/读取失败,已保留当前 MCP 服务器(恢复可读后会自动重载)", "Failed to parse/read mcp.json — keeping the running MCP servers (it reloads automatically once readable)");
|
|
58
65
|
return "invalid";
|
|
59
66
|
}
|
|
60
67
|
const summary = await deps.reload();
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* 经 ModelAdminHost 与 ClientSession 解耦(同 settings/goal/slash 服务模式)。
|
|
12
12
|
* UI 文案直接中文(服务端 notice 约定)。apiKey/headers 绝不下发浏览器。
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, renameSync
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
16
17
|
import { enrichBatch } from "./model-enrich.js";
|
|
17
18
|
import { pick } from "./i18n.js";
|
|
18
19
|
import { ProviderOAuthFlowManager } from "./provider-oauth-flow.js";
|
|
@@ -65,12 +66,9 @@ export function graduateOverlayModels(providers, official) {
|
|
|
65
66
|
}
|
|
66
67
|
return graduated;
|
|
67
68
|
}
|
|
68
|
-
/** 原子写 JSON(tmp + rename
|
|
69
|
-
* 杀会把整份配置截断,下次读盘即空表。tmp 名带 pid,避免同机多进程互踩。 */
|
|
69
|
+
/** 原子写 JSON(tmp + rename):进程崩溃不留半截文件。 */
|
|
70
70
|
function atomicWriteJson(path, data) {
|
|
71
|
-
|
|
72
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
73
|
-
renameSync(tmp, path);
|
|
71
|
+
writeJsonAtomicSync(path, data);
|
|
74
72
|
}
|
|
75
73
|
/** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
|
|
76
74
|
function stripJsonComments(src) {
|
|
@@ -378,13 +376,8 @@ export class ModelAdminService {
|
|
|
378
376
|
return out;
|
|
379
377
|
}
|
|
380
378
|
writeProviderKeys(data) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
//(Windows 忽略 mode 无害,POSIX 生效)。
|
|
384
|
-
const path = this.providerKeysPath();
|
|
385
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
386
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
387
|
-
renameSync(tmp, path);
|
|
379
|
+
// 原子写(tmp+rename):崩溃不留半截 JSON。
|
|
380
|
+
writeJsonAtomicSync(this.providerKeysPath(), data);
|
|
388
381
|
}
|
|
389
382
|
/** 解析失败的配置文件改名留存为 <name>.corrupt-<timestamp>。改名失败(占用/
|
|
390
383
|
* 跨盘等)也不抛——留存是尽力而为,绝不能阻塞主流程。 */
|
|
@@ -1461,7 +1454,15 @@ export class ModelAdminService {
|
|
|
1461
1454
|
}
|
|
1462
1455
|
const fetched = await ModelAdminService.probeModelsEndpoint(baseUrl, saved.apiKey, saved.authHeader === true ? true : undefined, saved.api, saved.headers, lang);
|
|
1463
1456
|
// Merge: manual values win; fetched fills blanks and appends new ids.
|
|
1464
|
-
|
|
1457
|
+
// #486:probe 是数秒级网络 await,期间用户可能已在设置面板编辑同一服务商——
|
|
1458
|
+
// 以盘上最新条目为合并基准(旧快照 saved 只提供探测参数),否则刷新会用
|
|
1459
|
+
// 旧快照整表覆盖,静默回滚并发编辑(删除的模型复活、手动新增丢失)。
|
|
1460
|
+
const fresh = this.readModelsConfig().providers[pid];
|
|
1461
|
+
if (!fresh) {
|
|
1462
|
+
// probe 期间服务商被删除:不再写回,避免用旧快照复活已删除的条目。
|
|
1463
|
+
return done(false, { error: "provider deleted during refresh" });
|
|
1464
|
+
}
|
|
1465
|
+
const prev = new Map((fresh.models ?? []).map((m) => [m.id, m]));
|
|
1465
1466
|
let added = 0;
|
|
1466
1467
|
for (const f of fetched) {
|
|
1467
1468
|
const cur = prev.get(f.id);
|
|
@@ -1478,11 +1479,11 @@ export class ModelAdminService {
|
|
|
1478
1479
|
const merged = [...prev.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
1479
1480
|
await this.saveModelConfig(pid, {
|
|
1480
1481
|
providerId: pid,
|
|
1481
|
-
name:
|
|
1482
|
-
api:
|
|
1483
|
-
baseUrl:
|
|
1482
|
+
name: fresh.name,
|
|
1483
|
+
api: fresh.api,
|
|
1484
|
+
baseUrl: fresh.baseUrl,
|
|
1484
1485
|
// apiKey/headers 不回传浏览器——saveModelConfig 会保留旧值
|
|
1485
|
-
authHeader:
|
|
1486
|
+
authHeader: fresh.authHeader === true ? true : undefined,
|
|
1486
1487
|
models: merged,
|
|
1487
1488
|
});
|
|
1488
1489
|
this.host.emit({
|
|
@@ -156,12 +156,11 @@ async function fetchJson(fetchFn, url, maxBytes, timeoutMs, signal) {
|
|
|
156
156
|
signal.removeEventListener("abort", onAbort);
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
// 手写 TTL 缓存而非通用 memoize:双键(openrouter/modelsdev)共享一个 Map,
|
|
160
|
+
// 且 compute 是 async fetch——单值同步的 memoizeWithTtl 覆盖不了(issue #470 处置说明)。
|
|
159
161
|
/** 目录抓取缓存(24h TTL,进程内;首跑后无感)。 */
|
|
160
162
|
const catalogCache = new Map();
|
|
161
163
|
const CATALOG_TTL_MS = 24 * 3600 * 1000;
|
|
162
|
-
export function clearEnrichCache() {
|
|
163
|
-
catalogCache.clear();
|
|
164
|
-
}
|
|
165
164
|
export async function getOpenRouterCatalog(fetchFn = fetch, signal) {
|
|
166
165
|
const hit = catalogCache.get("openrouter");
|
|
167
166
|
if (hit && Date.now() - hit.at < CATALOG_TTL_MS)
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
|
-
import {
|
|
14
|
+
import { pick } from "./i18n.js";
|
|
15
|
+
import { applyHashlinePatch, formatHashlineHeader, globalSnapshotStore, } from "./hashline-engine.js";
|
|
15
16
|
import { getLiveLspDiagnostics } from "./lsp-tool.js";
|
|
16
17
|
export const PATCH_TOOL_NAME = "patch";
|
|
17
18
|
export function makePatchTool(options) {
|
|
18
19
|
const cwd = options.cwd;
|
|
20
|
+
const getLang = options.lang ?? (() => "en");
|
|
19
21
|
return defineTool({
|
|
20
22
|
name: PATCH_TOOL_NAME,
|
|
21
23
|
promptSnippet: "apply content-hashed, line-anchored patches to workspace files (prevents stale edits, line drift, and indentation hallucination)",
|
|
@@ -34,10 +36,13 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
|
|
|
34
36
|
description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
|
|
35
37
|
}),
|
|
36
38
|
timeout: Type.Optional(Type.Number({
|
|
37
|
-
|
|
39
|
+
minimum: 1,
|
|
40
|
+
maximum: 300,
|
|
41
|
+
description: "Optional execution timeout in seconds (1-300).",
|
|
38
42
|
})),
|
|
39
43
|
}),
|
|
40
44
|
async execute(_callId, params, _signal, _onUpdate, _ctx) {
|
|
45
|
+
const L = getLang();
|
|
41
46
|
const patchText = typeof params?.patch === "string" ? params.patch : "";
|
|
42
47
|
if (!patchText.trim()) {
|
|
43
48
|
const emptyReport = {
|
|
@@ -47,40 +52,85 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
|
|
|
47
52
|
error: "Empty patch",
|
|
48
53
|
};
|
|
49
54
|
return {
|
|
50
|
-
content: [
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: "text",
|
|
58
|
+
text: pick(L, "错误:未提供任何 patch 补丁内容。", "Error: No patch content provided."),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
51
61
|
details: emptyReport,
|
|
52
62
|
};
|
|
53
63
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
// timeout 此前是死参数:schema 声明了 1-300 秒,execute 从未读取,大补丁
|
|
65
|
+
// 卡住只能等 20 分钟看门狗(issue #462)。这里用 Promise.race 实现真实
|
|
66
|
+
// 超时,覆盖 await 长尾(实时 LSP 诊断等);applyHashlinePatch 是同步
|
|
67
|
+
// 本地文件操作,同步段无法被中断,race 只能兜异步部分。
|
|
68
|
+
const timeoutSec = params.timeout ? Math.min(Math.max(1, params.timeout), 300) : null;
|
|
69
|
+
const finish = async () => {
|
|
70
|
+
const report = applyHashlinePatch(patchText, {
|
|
71
|
+
cwd,
|
|
72
|
+
snapshotStore: globalSnapshotStore,
|
|
73
|
+
});
|
|
74
|
+
if (!report.ok) {
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: "text",
|
|
79
|
+
text: pick(L, `补丁应用失败:\n${report.summary}`, `Patch Failed:\n${report.summary}`),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
details: report,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const textOutput = [report.summary];
|
|
86
|
+
// 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
|
|
87
|
+
for (const r of report.results) {
|
|
88
|
+
if (r.op !== "deleted" && r.newHash) {
|
|
89
|
+
const targetPath = r.newPath || r.filePath;
|
|
90
|
+
textOutput.push(pick(L, `\n${targetPath} 的下一处编辑锚点:\`${formatHashlineHeader(targetPath, r.newHash)}\``, `\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``));
|
|
91
|
+
try {
|
|
92
|
+
const fullPath = resolve(cwd, targetPath);
|
|
93
|
+
const diags = await getLiveLspDiagnostics(fullPath, cwd);
|
|
94
|
+
if (diags) {
|
|
95
|
+
textOutput.push(pick(L, `\n${targetPath} 的实时 LSP 诊断:\n${diags}`, `\nLive LSP diagnostics for ${targetPath}:\n${diags}`));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
59
101
|
return {
|
|
60
|
-
content: [{ type: "text", text:
|
|
102
|
+
content: [{ type: "text", text: textOutput.join("\n") }],
|
|
61
103
|
details: report,
|
|
62
104
|
};
|
|
105
|
+
};
|
|
106
|
+
if (!timeoutSec)
|
|
107
|
+
return finish();
|
|
108
|
+
const timeoutReply = {
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: "text",
|
|
112
|
+
text: pick(L, `补丁执行超时(${timeoutSec}s)。补丁可能已部分应用,请检查目标文件后重试。`, `Patch execution timed out (${timeoutSec}s). The patch may have been partially applied; check the target files and retry.`),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
details: {
|
|
116
|
+
ok: false,
|
|
117
|
+
summary: `Execution timed out after ${timeoutSec}s`,
|
|
118
|
+
results: [],
|
|
119
|
+
error: "timeout",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
let timer;
|
|
123
|
+
try {
|
|
124
|
+
return await Promise.race([
|
|
125
|
+
finish(),
|
|
126
|
+
new Promise((resolveTimeout) => {
|
|
127
|
+
timer = setTimeout(() => resolveTimeout(timeoutReply), timeoutSec * 1000);
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
63
130
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const r of report.results) {
|
|
67
|
-
if (r.op !== "deleted" && r.newHash) {
|
|
68
|
-
const targetPath = r.newPath || r.filePath;
|
|
69
|
-
textOutput.push(`\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``);
|
|
70
|
-
try {
|
|
71
|
-
const fullPath = resolve(cwd, targetPath);
|
|
72
|
-
const diags = await getLiveLspDiagnostics(fullPath, cwd);
|
|
73
|
-
if (diags) {
|
|
74
|
-
textOutput.push(`\nLive LSP diagnostics for ${targetPath}:\n${diags}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch { }
|
|
78
|
-
}
|
|
131
|
+
finally {
|
|
132
|
+
clearTimeout(timer);
|
|
79
133
|
}
|
|
80
|
-
return {
|
|
81
|
-
content: [{ type: "text", text: textOutput.join("\n") }],
|
|
82
|
-
details: report,
|
|
83
|
-
};
|
|
84
134
|
},
|
|
85
135
|
});
|
|
86
136
|
}
|
|
@@ -67,3 +67,28 @@ export function readPermissionFromSession(sm) {
|
|
|
67
67
|
}
|
|
68
68
|
return undefined;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* 从会话 sessionManager entries 回放恢复最后保存的结构化计划状态。
|
|
72
|
+
* customType 为 "plan/update" 或 "pi-web-ui/plan",载荷结构为 { plan: PlanState }。
|
|
73
|
+
*/
|
|
74
|
+
export function readPlanFromSession(sm) {
|
|
75
|
+
try {
|
|
76
|
+
const mgr = sm;
|
|
77
|
+
if (typeof mgr?.getEntries !== "function")
|
|
78
|
+
return null;
|
|
79
|
+
const entries = mgr.getEntries();
|
|
80
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
81
|
+
const e = entries[i];
|
|
82
|
+
if (e?.type === "custom" &&
|
|
83
|
+
(e.customType === "plan/update" || e.customType === "pi-web-ui/plan") &&
|
|
84
|
+
e.data?.plan &&
|
|
85
|
+
Array.isArray(e.data.plan.steps)) {
|
|
86
|
+
return e.data.plan;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
@@ -11,16 +11,111 @@
|
|
|
11
11
|
* - 模型可通过 customTool `plan_update` 更新
|
|
12
12
|
* - 客户端也可通过协议消息 `plan_update` 调整
|
|
13
13
|
* - 状态自动同步到快照 `UiState.plan`
|
|
14
|
+
*
|
|
15
|
+
* 存储模型与隔离设计(防新会话串台):
|
|
16
|
+
* - 持久化源(plansBySession):以真实唯一的 SDK sessionId 为主键,落盘至 plans.json。
|
|
17
|
+
* - 运行时缓存(plansByConv):以会话槽位 conversationId 为主键,仅存活于进程内存,绝不落盘。
|
|
18
|
+
* - 历史文件清洗:严格过滤形如 /^c\d+$/ 的易变短 ID,防止服务重启后新会话被上一个会话的任务附体。
|
|
14
19
|
*/
|
|
20
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname } from "node:path";
|
|
15
22
|
const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
|
|
23
|
+
/** 判断是否为易变的临时 conversationId(如 c1, c2, c99 等)。此类短 ID 绝不入持久化文件。 */
|
|
24
|
+
function isEphemeralConvId(id) {
|
|
25
|
+
return /^c\d+$/.test(id);
|
|
26
|
+
}
|
|
16
27
|
export class PlanManager {
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
filePath;
|
|
29
|
+
/** 按持久化 sessionId 存储的真实计划状态(落盘至 plans.json)。 */
|
|
30
|
+
plansBySession = new Map();
|
|
31
|
+
/** 按运行时 conversationId 存储的临时计划缓存(进程内存态,不落盘)。 */
|
|
32
|
+
plansByConv = new Map();
|
|
33
|
+
/** conversationId 到 sessionId 的映射。 */
|
|
34
|
+
convToSession = new Map();
|
|
35
|
+
constructor(filePath) {
|
|
36
|
+
this.filePath = filePath;
|
|
37
|
+
if (filePath) {
|
|
38
|
+
this.load();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
load() {
|
|
42
|
+
if (!this.filePath)
|
|
43
|
+
return;
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
46
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
47
|
+
// 严格过滤:形如 c1, c2 的临时 conversationId 绝不作为持久化会话键载入,
|
|
48
|
+
// 防止服务重启或历史残留时污染新分配的相同 ID 对话。
|
|
49
|
+
if (isEphemeralConvId(key))
|
|
50
|
+
continue;
|
|
51
|
+
if (val && typeof val === "object" && Array.isArray(val.steps)) {
|
|
52
|
+
this.plansBySession.set(key, val);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// 文件不存在或格式异常,以空状态起步
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
save() {
|
|
61
|
+
if (!this.filePath)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
65
|
+
const obj = {};
|
|
66
|
+
for (const [k, v] of this.plansBySession.entries()) {
|
|
67
|
+
// 双重防护:临时 conversationId 绝不落盘
|
|
68
|
+
if (isEphemeralConvId(k))
|
|
69
|
+
continue;
|
|
70
|
+
obj[k] = v;
|
|
71
|
+
}
|
|
72
|
+
const tmp = `${this.filePath}.tmp.${Date.now()}`;
|
|
73
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
|
|
74
|
+
renameSync(tmp, this.filePath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// 持久化失败绝不阻断主流程
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** 绑定 conversationId 与 sessionId。若 sessionId 已有落盘计划,立即恢复给 conversationId。 */
|
|
81
|
+
bindSession(conversationId, sessionId) {
|
|
82
|
+
this.convToSession.set(conversationId, sessionId);
|
|
83
|
+
// 绑定(或重新绑定)session 时,先清除当前 conversationId 上次残留的内存状态
|
|
84
|
+
this.plansByConv.delete(conversationId);
|
|
85
|
+
const existing = this.plansBySession.get(sessionId);
|
|
86
|
+
if (existing) {
|
|
87
|
+
this.plansByConv.set(conversationId, existing);
|
|
88
|
+
return existing;
|
|
89
|
+
}
|
|
90
|
+
// 绝不从 conversationId 逆向污染全新 sessionId!全新会话以空状态起步。
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/** 解绑并清理指定 conversationId 的运行时内存计划与映射(在会话移除/销毁时调用)。 */
|
|
94
|
+
unbindConversation(conversationId) {
|
|
95
|
+
this.plansByConv.delete(conversationId);
|
|
96
|
+
this.convToSession.delete(conversationId);
|
|
21
97
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
98
|
+
/** 获取指定会话的计划状态。优先使用 conversationId,若无则回退查找绑定的 sessionId。 */
|
|
99
|
+
getPlan(id) {
|
|
100
|
+
const directConv = this.plansByConv.get(id);
|
|
101
|
+
if (directConv)
|
|
102
|
+
return directConv;
|
|
103
|
+
const sessionId = this.convToSession.get(id);
|
|
104
|
+
if (sessionId) {
|
|
105
|
+
const bySession = this.plansBySession.get(sessionId);
|
|
106
|
+
if (bySession) {
|
|
107
|
+
this.plansByConv.set(id, bySession);
|
|
108
|
+
return bySession;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// 兼容以 sessionId 直接查询
|
|
112
|
+
const directSession = this.plansBySession.get(id);
|
|
113
|
+
if (directSession)
|
|
114
|
+
return directSession;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** 设置/全量更新指定会话的计划(可选绑定 sessionId 进行稳定落盘)。 */
|
|
118
|
+
setPlan(conversationId, steps, activeStepId, sessionId) {
|
|
24
119
|
const normalizedSteps = (Array.isArray(steps) ? steps : []).map((s, idx) => {
|
|
25
120
|
const status = (VALID_STATUSES.has(s.status) ? s.status : "pending");
|
|
26
121
|
return {
|
|
@@ -41,12 +136,23 @@ export class PlanManager {
|
|
|
41
136
|
activeStepId: effectiveActiveId,
|
|
42
137
|
updatedAt: Date.now(),
|
|
43
138
|
};
|
|
44
|
-
this.
|
|
139
|
+
this.plansByConv.set(conversationId, state);
|
|
140
|
+
const effectiveSessionId = sessionId ?? this.convToSession.get(conversationId);
|
|
141
|
+
if (effectiveSessionId) {
|
|
142
|
+
this.convToSession.set(conversationId, effectiveSessionId);
|
|
143
|
+
if (normalizedSteps.length === 0) {
|
|
144
|
+
this.plansBySession.delete(effectiveSessionId);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
this.plansBySession.set(effectiveSessionId, state);
|
|
148
|
+
}
|
|
149
|
+
this.save();
|
|
150
|
+
}
|
|
45
151
|
return state;
|
|
46
152
|
}
|
|
47
153
|
/** 增量更新单个步骤的状态或内容。 */
|
|
48
154
|
updateStep(conversationId, stepId, patch) {
|
|
49
|
-
const current = this.
|
|
155
|
+
const current = this.getPlan(conversationId);
|
|
50
156
|
if (!current)
|
|
51
157
|
return null;
|
|
52
158
|
const idx = current.steps.findIndex((s) => s.id === stepId);
|
|
@@ -85,12 +191,92 @@ export class PlanManager {
|
|
|
85
191
|
activeStepId: nextActive,
|
|
86
192
|
updatedAt: Date.now(),
|
|
87
193
|
};
|
|
88
|
-
this.
|
|
194
|
+
this.plansByConv.set(conversationId, nextState);
|
|
195
|
+
const sessId = this.convToSession.get(conversationId);
|
|
196
|
+
if (sessId) {
|
|
197
|
+
this.plansBySession.set(sessId, nextState);
|
|
198
|
+
this.save();
|
|
199
|
+
}
|
|
200
|
+
return nextState;
|
|
201
|
+
}
|
|
202
|
+
/** 增量删除单个步骤。 */
|
|
203
|
+
deleteStep(conversationId, stepId) {
|
|
204
|
+
const current = this.getPlan(conversationId);
|
|
205
|
+
if (!current)
|
|
206
|
+
return null;
|
|
207
|
+
const idx = current.steps.findIndex((s) => s.id === stepId);
|
|
208
|
+
if (idx === -1)
|
|
209
|
+
return current;
|
|
210
|
+
const nextSteps = current.steps.filter((s) => s.id !== stepId);
|
|
211
|
+
let nextActive = current.activeStepId;
|
|
212
|
+
if (current.activeStepId === stepId) {
|
|
213
|
+
const nextPending = nextSteps.find((s) => s.status === "pending" || s.status === "in_progress");
|
|
214
|
+
nextActive = nextPending ? nextPending.id : null;
|
|
215
|
+
}
|
|
216
|
+
const nextState = {
|
|
217
|
+
steps: nextSteps,
|
|
218
|
+
activeStepId: nextActive,
|
|
219
|
+
updatedAt: Date.now(),
|
|
220
|
+
};
|
|
221
|
+
this.plansByConv.set(conversationId, nextState);
|
|
222
|
+
const sessId = this.convToSession.get(conversationId);
|
|
223
|
+
if (sessId) {
|
|
224
|
+
this.plansBySession.set(sessId, nextState);
|
|
225
|
+
this.save();
|
|
226
|
+
}
|
|
227
|
+
return nextState;
|
|
228
|
+
}
|
|
229
|
+
/** 增量新增步骤。 */
|
|
230
|
+
addStep(conversationId, step, afterStepId) {
|
|
231
|
+
const current = this.getPlan(conversationId);
|
|
232
|
+
const existingSteps = current?.steps ?? [];
|
|
233
|
+
const normalized = {
|
|
234
|
+
id: String(step.id ?? `step-${Date.now()}`),
|
|
235
|
+
title: String(step.title ?? "").slice(0, 200),
|
|
236
|
+
status: step.status && VALID_STATUSES.has(step.status) ? step.status : "pending",
|
|
237
|
+
};
|
|
238
|
+
const description = String(step.description ?? "").slice(0, 1000);
|
|
239
|
+
if (description)
|
|
240
|
+
normalized.description = description;
|
|
241
|
+
let nextSteps;
|
|
242
|
+
if (afterStepId) {
|
|
243
|
+
const idx = existingSteps.findIndex((s) => s.id === afterStepId);
|
|
244
|
+
if (idx !== -1) {
|
|
245
|
+
nextSteps = [...existingSteps.slice(0, idx + 1), normalized, ...existingSteps.slice(idx + 1)];
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
nextSteps = [...existingSteps, normalized];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
nextSteps = [...existingSteps, normalized];
|
|
253
|
+
}
|
|
254
|
+
let nextActive = current?.activeStepId ?? null;
|
|
255
|
+
if (!nextActive && normalized.status === "in_progress") {
|
|
256
|
+
nextActive = normalized.id;
|
|
257
|
+
}
|
|
258
|
+
const nextState = {
|
|
259
|
+
steps: nextSteps,
|
|
260
|
+
activeStepId: nextActive,
|
|
261
|
+
updatedAt: Date.now(),
|
|
262
|
+
};
|
|
263
|
+
this.plansByConv.set(conversationId, nextState);
|
|
264
|
+
const sessId = this.convToSession.get(conversationId);
|
|
265
|
+
if (sessId) {
|
|
266
|
+
this.plansBySession.set(sessId, nextState);
|
|
267
|
+
this.save();
|
|
268
|
+
}
|
|
89
269
|
return nextState;
|
|
90
270
|
}
|
|
91
271
|
/** 清除指定会话的计划。 */
|
|
92
272
|
clearPlan(conversationId) {
|
|
93
|
-
this.
|
|
273
|
+
this.plansByConv.delete(conversationId);
|
|
274
|
+
const sessId = this.convToSession.get(conversationId);
|
|
275
|
+
if (sessId) {
|
|
276
|
+
this.plansBySession.delete(sessId);
|
|
277
|
+
this.convToSession.delete(conversationId);
|
|
278
|
+
}
|
|
279
|
+
this.save();
|
|
94
280
|
}
|
|
95
281
|
/** 格式化计划为简洁文本,供模型上下文或诊断使用。 */
|
|
96
282
|
describePlan(conversationId) {
|