pi-web-ui 0.97.0 → 0.98.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 +97 -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 +637 -145
- 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 +8 -4
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +49 -5
- 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 +0 -3
- package/dist/server/patch-tool.js +21 -6
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +150 -5
- 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 +11 -6
- 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 -25
- 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-C88r3r3Q.js} +1 -1
- package/web/dist/assets/index-2jDtjHvW.css +1 -0
- package/web/dist/assets/index-BjdK7s1M.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
|
@@ -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({
|
|
@@ -159,9 +159,6 @@ async function fetchJson(fetchFn, url, maxBytes, timeoutMs, signal) {
|
|
|
159
159
|
/** 目录抓取缓存(24h TTL,进程内;首跑后无感)。 */
|
|
160
160
|
const catalogCache = new Map();
|
|
161
161
|
const CATALOG_TTL_MS = 24 * 3600 * 1000;
|
|
162
|
-
export function clearEnrichCache() {
|
|
163
|
-
catalogCache.clear();
|
|
164
|
-
}
|
|
165
162
|
export async function getOpenRouterCatalog(fetchFn = fetch, signal) {
|
|
166
163
|
const hit = catalogCache.get("openrouter");
|
|
167
164
|
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,7 +52,12 @@ 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
|
}
|
|
@@ -57,7 +67,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
|
|
|
57
67
|
});
|
|
58
68
|
if (!report.ok) {
|
|
59
69
|
return {
|
|
60
|
-
content: [
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: pick(L, `补丁应用失败:\n${report.summary}`, `Patch Failed:\n${report.summary}`),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
61
76
|
details: report,
|
|
62
77
|
};
|
|
63
78
|
}
|
|
@@ -66,12 +81,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
|
|
|
66
81
|
for (const r of report.results) {
|
|
67
82
|
if (r.op !== "deleted" && r.newHash) {
|
|
68
83
|
const targetPath = r.newPath || r.filePath;
|
|
69
|
-
textOutput.push(`\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``);
|
|
84
|
+
textOutput.push(pick(L, `\n${targetPath} 的下一处编辑锚点:\`${formatHashlineHeader(targetPath, r.newHash)}\``, `\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``));
|
|
70
85
|
try {
|
|
71
86
|
const fullPath = resolve(cwd, targetPath);
|
|
72
87
|
const diags = await getLiveLspDiagnostics(fullPath, cwd);
|
|
73
88
|
if (diags) {
|
|
74
|
-
textOutput.push(`\nLive LSP diagnostics for ${targetPath}:\n${diags}`);
|
|
89
|
+
textOutput.push(pick(L, `\n${targetPath} 的实时 LSP 诊断:\n${diags}`, `\nLive LSP diagnostics for ${targetPath}:\n${diags}`));
|
|
75
90
|
}
|
|
76
91
|
}
|
|
77
92
|
catch { }
|
|
@@ -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
|
+
}
|
|
@@ -12,15 +12,84 @@
|
|
|
12
12
|
* - 客户端也可通过协议消息 `plan_update` 调整
|
|
13
13
|
* - 状态自动同步到快照 `UiState.plan`
|
|
14
14
|
*/
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
15
17
|
const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
|
|
16
18
|
export class PlanManager {
|
|
19
|
+
filePath;
|
|
17
20
|
plans = new Map();
|
|
18
|
-
|
|
21
|
+
convToSession = new Map();
|
|
22
|
+
constructor(filePath) {
|
|
23
|
+
this.filePath = filePath;
|
|
24
|
+
if (filePath) {
|
|
25
|
+
this.load();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
load() {
|
|
29
|
+
if (!this.filePath)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
33
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
34
|
+
if (val && typeof val === "object" && Array.isArray(val.steps)) {
|
|
35
|
+
this.plans.set(key, val);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// 文件不存在或格式异常,以空状态起步
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
save() {
|
|
44
|
+
if (!this.filePath)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
48
|
+
const obj = {};
|
|
49
|
+
for (const [k, v] of this.plans.entries()) {
|
|
50
|
+
obj[k] = v;
|
|
51
|
+
}
|
|
52
|
+
const tmp = `${this.filePath}.tmp.${Date.now()}`;
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
|
|
54
|
+
renameSync(tmp, this.filePath);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// 持久化失败绝不阻断主流程
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** 绑定 conversationId 与 sessionId。若 sessionId 已有落盘计划,立即恢复给 conversationId。 */
|
|
61
|
+
bindSession(conversationId, sessionId) {
|
|
62
|
+
this.convToSession.set(conversationId, sessionId);
|
|
63
|
+
const existing = this.plans.get(sessionId);
|
|
64
|
+
if (existing) {
|
|
65
|
+
this.plans.set(conversationId, existing);
|
|
66
|
+
return existing;
|
|
67
|
+
}
|
|
68
|
+
const convExisting = this.plans.get(conversationId);
|
|
69
|
+
if (convExisting) {
|
|
70
|
+
this.plans.set(sessionId, convExisting);
|
|
71
|
+
this.save();
|
|
72
|
+
return convExisting;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/** 获取指定会话的计划状态。优先使用 conversationId,若无则回退查找绑定的 sessionId。 */
|
|
19
77
|
getPlan(conversationId) {
|
|
20
|
-
|
|
78
|
+
const direct = this.plans.get(conversationId);
|
|
79
|
+
if (direct)
|
|
80
|
+
return direct;
|
|
81
|
+
const sessionId = this.convToSession.get(conversationId);
|
|
82
|
+
if (sessionId) {
|
|
83
|
+
const bySession = this.plans.get(sessionId);
|
|
84
|
+
if (bySession) {
|
|
85
|
+
this.plans.set(conversationId, bySession);
|
|
86
|
+
return bySession;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
21
90
|
}
|
|
22
|
-
/**
|
|
23
|
-
setPlan(conversationId, steps, activeStepId) {
|
|
91
|
+
/** 设置/全量更新指定会话的计划(可选绑定 sessionId 进行稳定落盘)。 */
|
|
92
|
+
setPlan(conversationId, steps, activeStepId, sessionId) {
|
|
24
93
|
const normalizedSteps = (Array.isArray(steps) ? steps : []).map((s, idx) => {
|
|
25
94
|
const status = (VALID_STATUSES.has(s.status) ? s.status : "pending");
|
|
26
95
|
return {
|
|
@@ -42,11 +111,17 @@ export class PlanManager {
|
|
|
42
111
|
updatedAt: Date.now(),
|
|
43
112
|
};
|
|
44
113
|
this.plans.set(conversationId, state);
|
|
114
|
+
const effectiveSessionId = sessionId ?? this.convToSession.get(conversationId);
|
|
115
|
+
if (effectiveSessionId) {
|
|
116
|
+
this.convToSession.set(conversationId, effectiveSessionId);
|
|
117
|
+
this.plans.set(effectiveSessionId, state);
|
|
118
|
+
}
|
|
119
|
+
this.save();
|
|
45
120
|
return state;
|
|
46
121
|
}
|
|
47
122
|
/** 增量更新单个步骤的状态或内容。 */
|
|
48
123
|
updateStep(conversationId, stepId, patch) {
|
|
49
|
-
const current = this.
|
|
124
|
+
const current = this.getPlan(conversationId);
|
|
50
125
|
if (!current)
|
|
51
126
|
return null;
|
|
52
127
|
const idx = current.steps.findIndex((s) => s.id === stepId);
|
|
@@ -86,11 +161,81 @@ export class PlanManager {
|
|
|
86
161
|
updatedAt: Date.now(),
|
|
87
162
|
};
|
|
88
163
|
this.plans.set(conversationId, nextState);
|
|
164
|
+
const sessId = this.convToSession.get(conversationId);
|
|
165
|
+
if (sessId) {
|
|
166
|
+
this.plans.set(sessId, nextState);
|
|
167
|
+
}
|
|
168
|
+
this.save();
|
|
169
|
+
return nextState;
|
|
170
|
+
}
|
|
171
|
+
/** 增量删除单个步骤。 */
|
|
172
|
+
deleteStep(conversationId, stepId) {
|
|
173
|
+
const current = this.plans.get(conversationId);
|
|
174
|
+
if (!current)
|
|
175
|
+
return null;
|
|
176
|
+
const idx = current.steps.findIndex((s) => s.id === stepId);
|
|
177
|
+
if (idx === -1)
|
|
178
|
+
return current;
|
|
179
|
+
const nextSteps = current.steps.filter((s) => s.id !== stepId);
|
|
180
|
+
let nextActive = current.activeStepId;
|
|
181
|
+
if (current.activeStepId === stepId) {
|
|
182
|
+
const nextPending = nextSteps.find((s) => s.status === "pending" || s.status === "in_progress");
|
|
183
|
+
nextActive = nextPending ? nextPending.id : null;
|
|
184
|
+
}
|
|
185
|
+
const nextState = {
|
|
186
|
+
steps: nextSteps,
|
|
187
|
+
activeStepId: nextActive,
|
|
188
|
+
updatedAt: Date.now(),
|
|
189
|
+
};
|
|
190
|
+
this.plans.set(conversationId, nextState);
|
|
191
|
+
return nextState;
|
|
192
|
+
}
|
|
193
|
+
/** 增量新增步骤。 */
|
|
194
|
+
addStep(conversationId, step, afterStepId) {
|
|
195
|
+
const current = this.plans.get(conversationId);
|
|
196
|
+
const existingSteps = current?.steps ?? [];
|
|
197
|
+
const normalized = {
|
|
198
|
+
id: String(step.id ?? `step-${Date.now()}`),
|
|
199
|
+
title: String(step.title ?? "").slice(0, 200),
|
|
200
|
+
status: step.status && VALID_STATUSES.has(step.status) ? step.status : "pending",
|
|
201
|
+
};
|
|
202
|
+
const description = String(step.description ?? "").slice(0, 1000);
|
|
203
|
+
if (description)
|
|
204
|
+
normalized.description = description;
|
|
205
|
+
let nextSteps;
|
|
206
|
+
if (afterStepId) {
|
|
207
|
+
const idx = existingSteps.findIndex((s) => s.id === afterStepId);
|
|
208
|
+
if (idx !== -1) {
|
|
209
|
+
nextSteps = [...existingSteps.slice(0, idx + 1), normalized, ...existingSteps.slice(idx + 1)];
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
nextSteps = [...existingSteps, normalized];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
nextSteps = [...existingSteps, normalized];
|
|
217
|
+
}
|
|
218
|
+
let nextActive = current?.activeStepId ?? null;
|
|
219
|
+
if (!nextActive && normalized.status === "in_progress") {
|
|
220
|
+
nextActive = normalized.id;
|
|
221
|
+
}
|
|
222
|
+
const nextState = {
|
|
223
|
+
steps: nextSteps,
|
|
224
|
+
activeStepId: nextActive,
|
|
225
|
+
updatedAt: Date.now(),
|
|
226
|
+
};
|
|
227
|
+
this.plans.set(conversationId, nextState);
|
|
89
228
|
return nextState;
|
|
90
229
|
}
|
|
91
230
|
/** 清除指定会话的计划。 */
|
|
92
231
|
clearPlan(conversationId) {
|
|
93
232
|
this.plans.delete(conversationId);
|
|
233
|
+
const sessId = this.convToSession.get(conversationId);
|
|
234
|
+
if (sessId) {
|
|
235
|
+
this.plans.delete(sessId);
|
|
236
|
+
this.convToSession.delete(conversationId);
|
|
237
|
+
}
|
|
238
|
+
this.save();
|
|
94
239
|
}
|
|
95
240
|
/** 格式化计划为简洁文本,供模型上下文或诊断使用。 */
|
|
96
241
|
describePlan(conversationId) {
|
package/dist/server/plan-mode.js
CHANGED
|
@@ -46,6 +46,9 @@ const BLOCKED_TOOLS = new Set([
|
|
|
46
46
|
"rename",
|
|
47
47
|
// 版本控制写(git add/commit/checkout/reset…;git 的只读子命令走 bash 白名单)
|
|
48
48
|
"git",
|
|
49
|
+
// 代码求值(eval 沙箱的 execute 直接 kernel.execute,可写真实文件系统;
|
|
50
|
+
// 默认关但用户可开,开了就是一条写路径 —— #436)
|
|
51
|
+
"eval",
|
|
49
52
|
// 形态变换(会重写整个文件树)
|
|
50
53
|
"format",
|
|
51
54
|
"prettier",
|
|
@@ -53,25 +56,43 @@ const BLOCKED_TOOLS = new Set([
|
|
|
53
56
|
"autofix",
|
|
54
57
|
]);
|
|
55
58
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
+
* 常驻终端的「写向」工具(server/terminals.ts):直接往 PTY stdin 写字符/按键。
|
|
60
|
+
* 必须整体剥离而不只是补检查:terminal_input 只在自带换行时才过 checkSafety,
|
|
61
|
+
* terminal_key 的 Enter 连检查都没有 —— 「input(无换行) + key(Enter)」两步即可
|
|
62
|
+
* 绕过 bash 只读白名单执行任意命令(#436)。且 PTY 的行编辑缓冲在 shell 进程内,
|
|
63
|
+
* 服务端拿不到可靠的「待提交行」(退格/Ctrl+U/补全都无法镜像),按缓冲检查既会
|
|
64
|
+
* 误放也会误拦 —— 所以计划模式下干脆不给这两件工具:调研用 bash(走白名单),
|
|
65
|
+
* 观察已有终端用 terminal_read/terminal_list/terminal_wait(只读)。
|
|
59
66
|
*/
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
const TERMINAL_WRITE_TOOLS = new Set(["terminal_input", "terminal_key"]);
|
|
68
|
+
/**
|
|
69
|
+
* 三个只读闸门(计划 / 审查者 / 目标审查)共用的「派发类」工具名:它们会把活
|
|
70
|
+
* 派到**别的会话**执行(delegate_task 走子代理 spawn 通道;schedule_task 到期
|
|
71
|
+
* 唤醒对话跑 prompt),而这三道闸门都是会话级的 —— 放行即绕过。
|
|
72
|
+
*
|
|
73
|
+
* 名单以 server/tool-manager.ts 实际注册的工具名为事实源(#436):历史名单里的
|
|
74
|
+
* spawn / spawn_agent / subagent_spawn / schedule_agent / host_schedule /
|
|
75
|
+
* create_goal / set_goal / start_goal_wizard / create_conversation /
|
|
76
|
+
* fork_conversation / schedule / set_plan_mode 等都是协议消息名或从未注册的
|
|
77
|
+
* 幽灵名,一个真实工具都没拦住,反而掩盖了 schedule_task 这类真实旁路工具
|
|
78
|
+
* 完全可用的事实。subagent 是单 action 工具,由各闸门按 action 细分(只读
|
|
79
|
+
* action 放行,见各闸门的判定函数)。
|
|
80
|
+
*/
|
|
81
|
+
export const SESSION_DISPATCH_TOOLS = new Set(["delegate_task", "schedule_task"]);
|
|
82
|
+
/**
|
|
83
|
+
* 计划模式下也禁掉的「旁路工具」(真实派发类,见 SESSION_DISPATCH_TOOLS):
|
|
84
|
+
* 它们能在**别的会话**里干活,而闸门是会话级的 —— 子代理/排程对话的
|
|
85
|
+
* planMode 是 false,等于绕过只读约束。调研靠本对话的 read/grep/只读 bash
|
|
86
|
+
* 足够,需要并行调研时先退出计划模式。
|
|
87
|
+
*/
|
|
88
|
+
const BYPASS_TOOLS = SESSION_DISPATCH_TOOLS;
|
|
73
89
|
/** 计划模式下从模型视野中彻底隐藏的写类工具与旁路工具全集。 */
|
|
74
|
-
export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([
|
|
90
|
+
export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([
|
|
91
|
+
...BLOCKED_TOOLS,
|
|
92
|
+
...BYPASS_TOOLS,
|
|
93
|
+
...TERMINAL_WRITE_TOOLS,
|
|
94
|
+
"subagent",
|
|
95
|
+
]);
|
|
75
96
|
/** bash 类工具名(terminalBash 开关分流后可能是这几个)。 */
|
|
76
97
|
const BASH_TOOLS = new Set(["bash", "bash_execute", "run_command", "shell", "terminal", "terminal_exec"]);
|
|
77
98
|
/** 计划模式放行的只读命令(basename,无参数)。刻意保守:宁可拒一次让模型换命令。 */
|
|
@@ -238,6 +259,10 @@ function segmentIsReadOnly(segment) {
|
|
|
238
259
|
return false;
|
|
239
260
|
if (name === "awk" && /system\s*\(/.test(rest))
|
|
240
261
|
return false;
|
|
262
|
+
// find 的 -exec/-delete/-fprintf 族有写副作用(issue #483):白名单命中不等于只读,
|
|
263
|
+
// `find . -name '*.log' -delete` / `-exec touch x {} +` 无任何重定向特征,必须显式拒。
|
|
264
|
+
if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint0|fprint|fprintf|fls)$/.test(a)))
|
|
265
|
+
return false;
|
|
241
266
|
// 解释器:只放行 --version/--help 这类无副作用调用。
|
|
242
267
|
if (name === "node" || name === "python" || name === "python3" || name === "npm") {
|
|
243
268
|
return args.length > 0 && args.every((a) => INTERPRETER_SAFE_ARGS.has(a));
|
|
@@ -274,6 +299,7 @@ function commandOf(params) {
|
|
|
274
299
|
* 计划模式闸门(纯函数):命中即拒绝,并把可操作的替代路径告诉模型。
|
|
275
300
|
* - 写类工具 → 拒(提示:把要做的事写进计划,用户确认后另起一轮实施)
|
|
276
301
|
* - bash 非常规命令 → 拒(提示:用 read/grep/只读 git,或给出计划)
|
|
302
|
+
* - 终端写向工具(terminal_input/terminal_key)→ 拒(会绕过只读命令白名单)
|
|
277
303
|
* - 旁路工具(spawn/delegate/目标模式/排程)→ 拒(会绕过本会话只读约束)
|
|
278
304
|
*/
|
|
279
305
|
export function planModeDenial(toolName, params) {
|
|
@@ -283,14 +309,26 @@ export function planModeDenial(toolName, params) {
|
|
|
283
309
|
if (name === "subagent") {
|
|
284
310
|
const action = typeof params === "object" && params !== null ? params.action : undefined;
|
|
285
311
|
const act = typeof action === "string" ? action.trim().toLowerCase() : "";
|
|
286
|
-
|
|
312
|
+
// 与 goal-review-gate 同口径:只读 action(get_result/list/templates,看状态
|
|
313
|
+
// 与名录)放行;其余(spawn/steer/stop/wait_all/handoff,见 subagents.ts 的
|
|
314
|
+
// SUBAGENT_ACTIONS)或未传 action 一律拒 —— steer/handoff 会向运行中的子代理
|
|
315
|
+
// 会话投指令驱动它继续写代码,子代理会话 planMode=false 工具齐全,等于绕过
|
|
316
|
+
// 本会话的只读约束(#436 此前只拒了 spawn)。
|
|
317
|
+
if (act !== "get_result" && act !== "list" && act !== "templates") {
|
|
287
318
|
return {
|
|
288
319
|
kind: "bypass-tool",
|
|
289
|
-
reason: `计划模式:不能调用 subagent(action="spawn")(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
|
|
290
|
-
reasonEn: `Plan mode: subagent(action="spawn") is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
|
|
320
|
+
reason: `计划模式:不能调用 subagent(action="${act || "spawn"}")(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
|
|
321
|
+
reasonEn: `Plan mode: subagent(action="${act || "spawn"}") is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
|
|
291
322
|
};
|
|
292
323
|
}
|
|
293
324
|
}
|
|
325
|
+
if (TERMINAL_WRITE_TOOLS.has(name)) {
|
|
326
|
+
return {
|
|
327
|
+
kind: "write-tool",
|
|
328
|
+
reason: `计划模式:不能调用 ${name}(终端输入/按键会绕过只读命令白名单,见 #436)。调研请用 bash 跑只读命令;观察已有终端用 terminal_read/terminal_list/terminal_wait。`,
|
|
329
|
+
reasonEn: `Plan mode: ${name} is unavailable (terminal input/keys would bypass the read-only command allowlist). Run read-only commands via bash; observe existing terminals with terminal_read/terminal_list/terminal_wait.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
294
332
|
if (BYPASS_TOOLS.has(name)) {
|
|
295
333
|
return {
|
|
296
334
|
kind: "bypass-tool",
|
|
@@ -23,6 +23,8 @@ export const SLOT_EXAMPLES = {
|
|
|
23
23
|
"goalbar.actions": `{ "ui": { "goalbar.actions": [{ "id": "m", "label": "目标条", "kind": "action", "action": "my:m" }] } }`,
|
|
24
24
|
"notice.actions": `{ "ui": { "notice.actions": [{ "id": "m", "label": "通知动作", "kind": "action", "action": "my:m" }] } }`,
|
|
25
25
|
"modal.dialog": `{ "modal": [{ "id": "m", "label": "弹窗", "kind": "action", "action": "my:m" }] }`,
|
|
26
|
+
"sidebar.left": `{ "sidebar.left": [{ "id": "m", "label": "左侧悬浮", "kind": "action", "action": "my:m" }] }`,
|
|
27
|
+
"sidebar.right": `{ "sidebar.right": [{ "id": "m", "label": "右侧悬浮", "kind": "action", "action": "my:m" }] }`,
|
|
26
28
|
};
|
|
27
29
|
/** 宿主方法表(与 server/plugins.ts#PluginHost 同语义的精简版:只收发现用的注册/调用面)。
|
|
28
30
|
* needs="-" = 观察/基础设施类(无需能力声明);其余走 can() 门控。 */
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* 设计上服务端不做网络探测(不拉 manifest):名称/简介/图标由条目本身携带
|
|
15
15
|
* (作者填),id 由来源推导或作者显式指定 —— 保持离线可用、可单测。
|
|
16
16
|
*/
|
|
17
|
-
import { existsSync,
|
|
18
|
-
import {
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
19
19
|
import { normalizeIconSvg } from "./icon-svg.js";
|
|
20
20
|
import { pick } from "./i18n.js";
|
|
21
21
|
/** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
|
|
@@ -61,10 +61,7 @@ function readJsonSafe(path, fallback) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
function atomicWrite(path, data) {
|
|
64
|
-
|
|
65
|
-
const tmp = `${path}.tmp-${process.pid}`;
|
|
66
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
67
|
-
renameSync(tmp, path);
|
|
64
|
+
writeJsonAtomicSync(path, data);
|
|
68
65
|
}
|
|
69
66
|
/** 把任意对象规范化为合法条目;非法则返回 null。 */
|
|
70
67
|
function toEntry(raw, builtin) {
|
|
@@ -75,6 +72,7 @@ function toEntry(raw, builtin) {
|
|
|
75
72
|
if (!ID_RE.test(id))
|
|
76
73
|
return null;
|
|
77
74
|
const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : id;
|
|
75
|
+
const nameEn = typeof raw.nameEn === "string" && raw.nameEn.trim() ? raw.nameEn.trim() : undefined;
|
|
78
76
|
const description = typeof raw.description === "string" && raw.description.trim() ? raw.description.trim() : undefined;
|
|
79
77
|
const descriptionEn = typeof raw.descriptionEn === "string" && raw.descriptionEn.trim() ? raw.descriptionEn.trim() : undefined;
|
|
80
78
|
const icon = typeof raw.icon === "string" && raw.icon.trim() ? raw.icon.trim() : undefined;
|
|
@@ -85,6 +83,7 @@ function toEntry(raw, builtin) {
|
|
|
85
83
|
name,
|
|
86
84
|
source,
|
|
87
85
|
builtin,
|
|
86
|
+
...(nameEn ? { nameEn } : {}),
|
|
88
87
|
...(description ? { description } : {}),
|
|
89
88
|
...(descriptionEn ? { descriptionEn } : {}),
|
|
90
89
|
...(icon ? { icon } : {}),
|
|
@@ -231,6 +230,7 @@ export function writeCustomCatalog(customPath, incoming, replace) {
|
|
|
231
230
|
id: e.id,
|
|
232
231
|
source: e.source,
|
|
233
232
|
name: e.name,
|
|
233
|
+
...(e.nameEn ? { nameEn: e.nameEn } : {}),
|
|
234
234
|
...(e.description ? { description: e.description } : {}),
|
|
235
235
|
...(e.descriptionEn ? { descriptionEn: e.descriptionEn } : {}),
|
|
236
236
|
...(e.icon ? { icon: e.icon } : {}),
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - 只有真正发生变更的写才落盘(临时文件 + rename 原子写);写失败 best-effort
|
|
14
14
|
* (内存态仍生效,本次会话可用)。
|
|
15
15
|
*/
|
|
16
|
-
import { readFileSync
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
18
19
|
const FILE = "plugin-dom.json";
|
|
19
20
|
/** manifest.permissions 条目 → 是否请求特权 DOM(只看 `:` 前的族名,与宿主 can() 同口径)。
|
|
20
21
|
* 例外:`dom:anchor` 只是客户端锚点挂载点的范围 DOM(免用户授权),不触发 bundle 403;
|
|
@@ -71,9 +72,7 @@ export class PluginDomConsent {
|
|
|
71
72
|
else
|
|
72
73
|
this.granted.delete(id);
|
|
73
74
|
try {
|
|
74
|
-
|
|
75
|
-
writeFileSync(tmp, JSON.stringify({ v: 1, granted: this.list() }), "utf8");
|
|
76
|
-
renameSync(tmp, join(this.dataDir, FILE));
|
|
75
|
+
writeJsonAtomicSync(join(this.dataDir, FILE), { v: 1, granted: this.list() });
|
|
77
76
|
}
|
|
78
77
|
catch {
|
|
79
78
|
/* 写失败 best-effort:内存态仍生效,本次会话可用 */
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
|
|
17
17
|
*/
|
|
18
18
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
19
|
-
import { chmodSync, existsSync,
|
|
19
|
+
import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
20
20
|
import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile, appendFile as fspAppendFile, stat as fspStat, } from "node:fs/promises";
|
|
21
21
|
import { dirname, join, relative, resolve } from "node:path";
|
|
22
|
+
import { writeAtomicSync } from "./atomic-file.js";
|
|
22
23
|
import { createRequire } from "node:module";
|
|
23
24
|
import { spawnSync } from "node:child_process";
|
|
24
25
|
// 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
|
|
@@ -26,10 +27,7 @@ import { spawnSync } from "node:child_process";
|
|
|
26
27
|
import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
|
|
27
28
|
/** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
|
|
28
29
|
function atomicWrite(file, data) {
|
|
29
|
-
|
|
30
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
31
|
-
writeFileSync(tmp, data);
|
|
32
|
-
renameSync(tmp, file);
|
|
30
|
+
writeAtomicSync(file, data);
|
|
33
31
|
}
|
|
34
32
|
// ---------------------------------------------------------------------------
|
|
35
33
|
// storage.json 的进程内「读-改-写」互斥
|