pi-web-ui 0.94.1 → 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 +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- 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 +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- 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-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- 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/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- 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/workspace-snapshot.js +113 -0
- 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 +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.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-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -34,6 +34,7 @@ export const SUBAGENT_TOOL_NAMES = [
|
|
|
34
34
|
"subagent_stop",
|
|
35
35
|
"subagent_wait_all",
|
|
36
36
|
"subagent_templates",
|
|
37
|
+
"subagent_handoff",
|
|
37
38
|
];
|
|
38
39
|
/** 独立宽松编辑工具(定义见 edit-soft-tool.ts)。 */
|
|
39
40
|
export const EDIT_SOFT_TOOL_NAME = "edit_soft";
|
|
@@ -58,16 +59,28 @@ export const SKILL_TOOL_NAME = "skill";
|
|
|
58
59
|
export const SCHEDULE_TASK_TOOL_NAME = "schedule_task";
|
|
59
60
|
export const SCHEDULE_LIST_TOOL_NAME = "schedule_list";
|
|
60
61
|
export const SCHEDULE_CANCEL_TOOL_NAME = "schedule_cancel";
|
|
62
|
+
/** 持久代码求值沙箱工具(定义见 eval-tool.ts):在受控持久内核中执行 Python 或 JS/TS 代码。 */
|
|
63
|
+
export const EVAL_TOOL_NAME = "eval";
|
|
61
64
|
/** 展示文件工具(定义见 present-files-tool.ts):把图片/视频/文本作为预览卡片
|
|
62
65
|
* 推到对话里,卡片带预览/本地打开/在文件夹中显示/下载/复制路径。 */
|
|
63
66
|
export const PRESENT_FILES_TOOL_NAME = "present_files";
|
|
64
67
|
/** 文件认领工具(定义见 claim-files-tool.ts):声明要改哪些文件,让同项目的
|
|
65
68
|
* 并行对话绕行(纯建议,不拦编辑)。 */
|
|
66
69
|
export const CLAIM_FILES_TOOL_NAME = "claim_files";
|
|
70
|
+
/** 任务计划看板更新工具(定义见 plan-manager.ts,Plan Mode / 步骤状态机)。 */
|
|
71
|
+
export const PLAN_UPDATE_TOOL_NAME = "plan_update";
|
|
72
|
+
/** 主动上下文压缩工具(定义见 compact-context-tool.ts)。 */
|
|
73
|
+
export const COMPACT_CONTEXT_TOOL_NAME = "compact_context";
|
|
74
|
+
/** 高可靠结构化补丁工具(定义见 patch-tool.ts):基于内容哈希锚点与语法块的行补丁工具。 */
|
|
75
|
+
export const PATCH_TOOL_NAME = "patch";
|
|
76
|
+
/** 原生语言服务器工具(定义见 lsp-tool.ts):代码定义跳转、引用查询、类型悬停与诊断。 */
|
|
77
|
+
export const LSP_TOOL_NAME = "lsp";
|
|
67
78
|
/** 旧工具名(持久化迁移用;新代码一律用 MARKERS_LIST_TOOL_NAME)。 */
|
|
68
79
|
export const LEGACY_MARKERS_LIST_TOOL_NAME = "markers_list";
|
|
69
|
-
/** 可开关的 Agent 工具总目录(共
|
|
70
|
-
*
|
|
80
|
+
/** 可开关的 Agent 工具总目录(共 27 个)。核心内置工具 bash/read/edit/write 不进
|
|
81
|
+
* 目录——目录条目 = OTHER_AGENT_TOOLS 自动渲染的设置行,而这四个在设置页
|
|
82
|
+
* 「核心工具」区单独开关(见 SettingsModal 的 CORE_BUILTIN_TOOL_NAMES 区块),
|
|
83
|
+
* 禁用名单同样接受它们(normalizeDisabledAgentTools)。 */
|
|
71
84
|
export const AGENT_TOOL_CATALOG = [
|
|
72
85
|
...TERMINAL_TOOL_NAMES.map((name) => ({
|
|
73
86
|
name,
|
|
@@ -135,6 +148,31 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
135
148
|
descKey: "claimFilesEnabledDesc",
|
|
136
149
|
offHintKey: "claimFilesOffHint",
|
|
137
150
|
},
|
|
151
|
+
// 结构化任务计划更新(Plan Mode / Step State Machine),默认开。
|
|
152
|
+
{
|
|
153
|
+
name: PLAN_UPDATE_TOOL_NAME,
|
|
154
|
+
group: "other",
|
|
155
|
+
defaultOn: true,
|
|
156
|
+
dshVisible: true,
|
|
157
|
+
descKey: "planUpdateEnabledDesc",
|
|
158
|
+
offHintKey: "planUpdateOffHint",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: PATCH_TOOL_NAME,
|
|
162
|
+
group: "other",
|
|
163
|
+
defaultOn: true,
|
|
164
|
+
dshVisible: false,
|
|
165
|
+
descKey: "patchToolEnabledDesc",
|
|
166
|
+
offHintKey: "patchToolOffHint",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: LSP_TOOL_NAME,
|
|
170
|
+
group: "other",
|
|
171
|
+
defaultOn: false,
|
|
172
|
+
dshVisible: false,
|
|
173
|
+
descKey: "lspToolEnabledDesc",
|
|
174
|
+
offHintKey: "lspToolOffHint",
|
|
175
|
+
},
|
|
138
176
|
// 展示文件给用户(图片/视频内联、文本开预览弹窗、本地打开按钮):默认开,
|
|
139
177
|
// 不打开模型根本不知道能“给用户看”;DSH 引擎没有该 customTool(走 shipped preset)。
|
|
140
178
|
{
|
|
@@ -155,6 +193,16 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
155
193
|
descKey: "skillEnabledDesc",
|
|
156
194
|
offHintKey: "skillOffHint",
|
|
157
195
|
},
|
|
196
|
+
// 持久代码求值沙箱(Python / Node.js):默认关(opt-in,防工具挤占);
|
|
197
|
+
// DSH 引擎没有该 customTool。
|
|
198
|
+
{
|
|
199
|
+
name: EVAL_TOOL_NAME,
|
|
200
|
+
group: "other",
|
|
201
|
+
defaultOn: false,
|
|
202
|
+
dshVisible: false,
|
|
203
|
+
descKey: "evalEnabledDesc",
|
|
204
|
+
offHintKey: "evalOffHint",
|
|
205
|
+
},
|
|
158
206
|
// 定时/延时唤醒:默认开(不打开 AI 根本不知道能定时;60s 间隔底线+面板可随时取消),
|
|
159
207
|
// DSH 引擎没有该 customTool(走 goal-rpc,无 customTool 注册面)。
|
|
160
208
|
{
|
|
@@ -181,6 +229,16 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
181
229
|
descKey: "scheduleTaskEnabledDesc",
|
|
182
230
|
offHintKey: "scheduleTaskOffHint",
|
|
183
231
|
},
|
|
232
|
+
// 主动上下文压缩:默认开(让 AI 可以根据当前问题主动精简上下文)。
|
|
233
|
+
// DSH 引擎无 customTool 注册面,不接。
|
|
234
|
+
{
|
|
235
|
+
name: COMPACT_CONTEXT_TOOL_NAME,
|
|
236
|
+
group: "other",
|
|
237
|
+
defaultOn: true,
|
|
238
|
+
dshVisible: false,
|
|
239
|
+
descKey: "compactContextEnabledDesc",
|
|
240
|
+
offHintKey: "compactContextOffHint",
|
|
241
|
+
},
|
|
184
242
|
// todo_list 唯一例外:行不在「其他」组,固定在上面的 markers 分区(设置页循环
|
|
185
243
|
// 跳过它,见 OTHER_AGENT_TOOLS;文案 key 照给,万一哪天搬家不用补)。
|
|
186
244
|
{
|
|
@@ -192,13 +250,18 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
192
250
|
offHintKey: "todoListOffHint",
|
|
193
251
|
},
|
|
194
252
|
];
|
|
253
|
+
/** SDK 核心内置工具名(可被门控显式禁用或被预设白名单过滤)。 */
|
|
254
|
+
export const CORE_BUILTIN_TOOL_NAMES = ["bash", "read", "edit", "write"];
|
|
255
|
+
export function isCoreBuiltinTool(name) {
|
|
256
|
+
return CORE_BUILTIN_TOOL_NAMES.includes(name);
|
|
257
|
+
}
|
|
195
258
|
const KNOWN_NAMES = new Set(AGENT_TOOL_CATALOG.map((t) => t.name));
|
|
196
259
|
/** 是否为本表登记的可开关工具(未知名一律 false,不抛错)。 */
|
|
197
260
|
export function isKnownAgentTool(name) {
|
|
198
261
|
return KNOWN_NAMES.has(name);
|
|
199
262
|
}
|
|
200
263
|
/** 归一化禁用名单:非数组回落默认(= 默认关的那些);数组则只保留已知工具名
|
|
201
|
-
*
|
|
264
|
+
* (去重;未知名丢弃,防旧文件/手写脏数据污染)。支持登记核心内置工具。 */
|
|
202
265
|
export function normalizeDisabledAgentTools(v) {
|
|
203
266
|
if (!Array.isArray(v))
|
|
204
267
|
return defaultDisabledAgentTools();
|
|
@@ -206,8 +269,9 @@ export function normalizeDisabledAgentTools(v) {
|
|
|
206
269
|
for (const x of v) {
|
|
207
270
|
// 旧名迁移:markers_list → todo_list(改名前已关闭的用户保持关闭)。
|
|
208
271
|
const name = x === LEGACY_MARKERS_LIST_TOOL_NAME ? MARKERS_LIST_TOOL_NAME : x;
|
|
209
|
-
if (typeof name === "string" && KNOWN_NAMES.has(name) && !out.includes(name))
|
|
272
|
+
if (typeof name === "string" && (KNOWN_NAMES.has(name) || isCoreBuiltinTool(name)) && !out.includes(name)) {
|
|
210
273
|
out.push(name);
|
|
274
|
+
}
|
|
211
275
|
}
|
|
212
276
|
return out;
|
|
213
277
|
}
|
|
@@ -261,26 +325,179 @@ export function setAgentToolsEnabled(session, names, on) {
|
|
|
261
325
|
}
|
|
262
326
|
}
|
|
263
327
|
/**
|
|
264
|
-
* 全量重放(创建会话 / reload 后 /
|
|
265
|
-
*
|
|
328
|
+
* 全量重放(创建会话 / reload 后 / 设置变更后调):按禁用名单把目录内工具与
|
|
329
|
+
* 核心内置工具(bash/read/edit/write,禁用名单接受它们)逐个加回或剔除;
|
|
330
|
+
* 基线之外的工具(插件工具等)原样不动。
|
|
331
|
+
* 支持传入 preset(预设 id),按预设白名单做二次过滤。
|
|
332
|
+
* 复原基线取 session.getAllTools()(全集,含被禁用的)——基线里没有的工具不会凭空发明。
|
|
266
333
|
* Session 未就绪时静默跳过(下次创建/reload 会再应用)。
|
|
267
334
|
*/
|
|
268
|
-
export function applyAgentToolsGating(session, disabled) {
|
|
335
|
+
export function applyAgentToolsGating(session, disabled, preset) {
|
|
269
336
|
try {
|
|
270
337
|
const off = new Set(disabled);
|
|
271
|
-
const
|
|
338
|
+
const allNames = session.getAllTools().map((t) => t.name);
|
|
339
|
+
const names = new Set(allNames);
|
|
272
340
|
for (const t of AGENT_TOOL_CATALOG) {
|
|
273
341
|
if (off.has(t.name))
|
|
274
342
|
names.delete(t.name);
|
|
275
343
|
else
|
|
276
344
|
names.add(t.name);
|
|
277
345
|
}
|
|
278
|
-
|
|
346
|
+
for (const core of CORE_BUILTIN_TOOL_NAMES) {
|
|
347
|
+
if (off.has(core))
|
|
348
|
+
names.delete(core);
|
|
349
|
+
else if (allNames.includes(core))
|
|
350
|
+
names.add(core);
|
|
351
|
+
}
|
|
352
|
+
const filtered = filterToolsByPreset(names, preset);
|
|
353
|
+
session.setActiveToolsByName(filtered);
|
|
279
354
|
}
|
|
280
355
|
catch {
|
|
281
356
|
// Session 未就绪——下次创建/reload 会再应用。
|
|
282
357
|
}
|
|
283
358
|
}
|
|
359
|
+
/** pi 引擎内置 Agent 预设名录(对齐 DSH 预设体系,会话级工具白名单)。 */
|
|
360
|
+
export const PI_AGENT_PRESETS = [
|
|
361
|
+
{
|
|
362
|
+
id: "standard",
|
|
363
|
+
trust: "system",
|
|
364
|
+
isDefault: true,
|
|
365
|
+
name: "全功能",
|
|
366
|
+
description: "提供全部可用工具与扩展能力(默认)",
|
|
367
|
+
order: 0,
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
id: "minimal",
|
|
371
|
+
trust: "system",
|
|
372
|
+
isDefault: false,
|
|
373
|
+
name: "极简模式",
|
|
374
|
+
description: "仅保留 bash 与 read;插件工具、技能名录与终端引导同步隐藏",
|
|
375
|
+
order: 1,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: "code",
|
|
379
|
+
trust: "system",
|
|
380
|
+
isDefault: false,
|
|
381
|
+
name: "代码开发",
|
|
382
|
+
description: "专注于代码读写与执行(bash, read, edit, write, edit_soft);插件工具与技能名录同步隐藏",
|
|
383
|
+
order: 2,
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
id: "reader",
|
|
387
|
+
trust: "system",
|
|
388
|
+
isDefault: false,
|
|
389
|
+
name: "只读分析",
|
|
390
|
+
description: "仅保留只读工具,禁止写操作;插件工具同步隐藏(读写未知,保守处理)",
|
|
391
|
+
order: 3,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
id: "ask",
|
|
395
|
+
trust: "system",
|
|
396
|
+
isDefault: false,
|
|
397
|
+
name: "纯对话",
|
|
398
|
+
description: "无工具问答模式,模型不调用任何工具;插件工具与技能名录同步隐藏",
|
|
399
|
+
order: 4,
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
/** pi 引擎权限预设选项(三档沙箱策略)。 */
|
|
403
|
+
export const PI_PERMISSION_OPTIONS = [
|
|
404
|
+
{
|
|
405
|
+
value: "read-only",
|
|
406
|
+
name: "只读模式",
|
|
407
|
+
description: "禁止所有文件修改(write/edit/edit_soft)及任何非只读操作",
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
value: "workspace-write-never",
|
|
411
|
+
name: "工作区内修改",
|
|
412
|
+
description: "仅允许在当前工作区目录下修改文件,工作区外写操作一律拒绝",
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
value: "danger-full-access",
|
|
416
|
+
name: "完全权限",
|
|
417
|
+
description: "允许修改任意目录文件及执行全量操作(需要二次确认)",
|
|
418
|
+
},
|
|
419
|
+
];
|
|
420
|
+
/** 按预设过滤活跃工具名。 */
|
|
421
|
+
export function filterToolsByPreset(tools, preset) {
|
|
422
|
+
const all = Array.from(tools);
|
|
423
|
+
if (!preset || preset === "standard")
|
|
424
|
+
return all;
|
|
425
|
+
if (preset === "ask")
|
|
426
|
+
return [];
|
|
427
|
+
if (preset === "minimal") {
|
|
428
|
+
return all.filter((n) => n === "bash" || n === "read");
|
|
429
|
+
}
|
|
430
|
+
if (preset === "code") {
|
|
431
|
+
const codeSet = new Set(["bash", "read", "edit", "write", "edit_soft"]);
|
|
432
|
+
return all.filter((n) => codeSet.has(n));
|
|
433
|
+
}
|
|
434
|
+
if (preset === "reader") {
|
|
435
|
+
const writeTools = new Set([
|
|
436
|
+
"write",
|
|
437
|
+
"edit",
|
|
438
|
+
"edit_soft",
|
|
439
|
+
"bash",
|
|
440
|
+
"terminal_create",
|
|
441
|
+
"terminal_input",
|
|
442
|
+
"terminal_close",
|
|
443
|
+
"terminal_key",
|
|
444
|
+
]);
|
|
445
|
+
return all.filter((n) => !writeTools.has(n));
|
|
446
|
+
}
|
|
447
|
+
return all;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* pi 预设语义总表(唯一事实源,本文件是唯一定义处):
|
|
451
|
+
*
|
|
452
|
+
* 预设是「禁用名单之外」的第二层门控,按会话生效(conv.agentPreset),新对话
|
|
453
|
+
* 取默认预设、首轮发言后锁定。标准预设不过滤;其余预设同时约束四处:
|
|
454
|
+
*
|
|
455
|
+
* - 目录/内置工具(ActiveSet):filterToolsByPreset 直接过滤活跃集
|
|
456
|
+
* (standard 全留 / ask 全拔 / minimal 仅 bash+read /
|
|
457
|
+
* code 仅 bash+read+edit+write+edit_soft / reader 拔写类)。
|
|
458
|
+
* - 插件工具(registerAgentTool 动态注册):presetAllowsPluginTools —— 只有
|
|
459
|
+
* standard 保留,其余已知预设一律拔掉。插件工具读写性质未知,保守按最严处理
|
|
460
|
+
* (minimal/code/ask 是白名单语义本来就过不去;reader 是 deny 名单,
|
|
461
|
+
* 未知工具同样不放行;未知预设 id 按不过滤,与 filterToolsByPreset 同口径)。
|
|
462
|
+
* - 技能名录段({{skills}} 名录+全文注入):presetShowsSkillCatalog —— 与
|
|
463
|
+
* skill 加载工具同进退(minimal/code/ask 下 loader 不在,列出来只是噪音;
|
|
464
|
+
* reader 下 loader 可用,保留)。子代理模板的技能白名单是显式配置,
|
|
465
|
+
* 优先级高于预设,不参与此门控。
|
|
466
|
+
* - 终端引导(TERMINAL_TOOLS_GUIDANCE):isTerminalGuidanceOn(disabled, preset) ——
|
|
467
|
+
* 只教「开关开着且预设下仍可用」的终端工具,不教不存在的工具。
|
|
468
|
+
* - 并行提醒(parallel-work-reminder):信息层,不按预设开关(只看
|
|
469
|
+
* parallelReminderEnabled 开关);但文案里的问卷指引按
|
|
470
|
+
* presetHasQuestionnaire 切换措辞——有问卷工具时调工具,否则正文提问。
|
|
471
|
+
* 认领信息是事实层,照常展示(store 是按项目全局的)。
|
|
472
|
+
*
|
|
473
|
+
* 显式配置永远优先于预设:disabledAgentTools / disabledPluginTools /
|
|
474
|
+
* disabledSkills / 子代理模板白名单照常生效,预设只做减法不做加法
|
|
475
|
+
* (开关关掉的东西,standard 也不会加回来)。
|
|
476
|
+
*/
|
|
477
|
+
/**
|
|
478
|
+
* 预设是否允许插件工具(动态注册,读写未知):只有 standard 允许;
|
|
479
|
+
* 其余已知预设一律拒绝。未知 id 按不过滤(与 filterToolsByPreset 同口径,
|
|
480
|
+
* 防手写脏配置把插件工具全灭)。
|
|
481
|
+
*/
|
|
482
|
+
export function presetAllowsPluginTools(preset) {
|
|
483
|
+
if (!preset || preset === "standard")
|
|
484
|
+
return true;
|
|
485
|
+
return !PI_AGENT_PRESETS.some((p) => p.id === preset);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 预设下是否展示技能名录段(与 skill 加载工具同进退,由 filterToolsByPreset
|
|
489
|
+
* 单源推导,不另维护名单:minimal/code/ask 藏,standard/reader 留)。
|
|
490
|
+
*/
|
|
491
|
+
export function presetShowsSkillCatalog(preset) {
|
|
492
|
+
return filterToolsByPreset([SKILL_TOOL_NAME], preset).includes(SKILL_TOOL_NAME);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* 预设下问卷工具是否可用(并行提醒等服务端文案用:有则指引调工具,
|
|
496
|
+
* 无则指引正文提问——不教不存在的工具)。同样单源推导。
|
|
497
|
+
*/
|
|
498
|
+
export function presetHasQuestionnaire(preset) {
|
|
499
|
+
return filterToolsByPreset([ASK_USER_QUESTION_TOOL_NAME], preset).includes(ASK_USER_QUESTION_TOOL_NAME);
|
|
500
|
+
}
|
|
284
501
|
/**
|
|
285
502
|
* 旧存档迁移:已有新字段直接归一化;否则按遗留三开关折算
|
|
286
503
|
* (语义与改动前一致:terminal/edit 未设/关 = 禁用对应组;问卷未设/开 = 启用)。
|
|
@@ -337,7 +554,15 @@ export function effectiveDisabledAgentTools(s) {
|
|
|
337
554
|
next.add(ASK_USER_QUESTION_TOOL_NAME);
|
|
338
555
|
return [...next];
|
|
339
556
|
}
|
|
340
|
-
/**
|
|
341
|
-
|
|
342
|
-
|
|
557
|
+
/**
|
|
558
|
+
* 终端使用引导是否注入:组内有任一工具「开关开着且预设下仍可用」才教 AI 用,
|
|
559
|
+
* 否则就是教不存在的工具(preset 缺省/standard = 只看开关,保持旧语义)。
|
|
560
|
+
*/
|
|
561
|
+
export function isTerminalGuidanceOn(disabled, preset) {
|
|
562
|
+
const on = TERMINAL_TOOL_NAMES.filter((n) => !disabled.includes(n));
|
|
563
|
+
if (on.length === 0)
|
|
564
|
+
return false;
|
|
565
|
+
if (!preset || preset === "standard")
|
|
566
|
+
return true;
|
|
567
|
+
return filterToolsByPreset(on, preset).length > 0;
|
|
343
568
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-overrides.ts —— pi-web-ui 对 SDK 内置工具的覆盖(read / write / edit)如何与
|
|
3
|
+
* **第三方 pi 扩展提供的同名工具共存**。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要这个模块(问题):SDK 的注册表合并链是
|
|
6
|
+
* `allCustomTools = [...扩展注册的工具, ...customTools]` 再逐个 `definitionRegistry.set(name, …)`
|
|
7
|
+
* —— 后写赢 ⇒ **customTools 恒顶掉扩展的同名工具,与加载顺序无关、也没有开关**(见
|
|
8
|
+
* `dist/core/agent-session.js` 的 `_refreshToolRegistry()`)。而官方
|
|
9
|
+
* `docs/extensions.md` §Overriding Built-in Tools 明写扩展**可以**覆盖 `read` / `bash` /
|
|
10
|
+
* `powershell` / `edit` / `write` / `grep` / `find` / `ls` 并逐个列出了 `read`。
|
|
11
|
+
* pi-web-ui 过去把这三个覆盖直接塞进创建时的 `customTools` ⇒ 那句话在 `read`/`write`/`edit`
|
|
12
|
+
* 上失效:第三方扩展的同名工具**永远轮不到**,而且症状会伪装(覆盖层转发内置实现 ⇒
|
|
13
|
+
* 「读文件/图片/目录都正常,只有扩展特有的能力没有」,例如 `pi-better-edit` 的 read 不出锚、
|
|
14
|
+
* 它的 edit 随后一律 `E_UNKNOWN_ANCHOR`)。
|
|
15
|
+
*
|
|
16
|
+
* 做法:这三个覆盖**不再于创建时注册**。会话建好后按名字从**扩展注册表**
|
|
17
|
+
* (`session.extensionRunner.getAllRegisteredTools()`,不受 customTools 顶替影响)取扩展的同名
|
|
18
|
+
* 实现当基底,没有则用 pi-web-ui 自己的完整实现,再交给装饰器(`composeWith`)在其上叠加
|
|
19
|
+
* pi-web-ui 的能力(目录列条目 / 权限沙箱)。装饰器必须把**行为**委托给基底,只加自己那一层;
|
|
20
|
+
* 基底的 schema、描述、prompt 指引、渲染全部原样保留,扩展独有的参数(如 better-edit 的
|
|
21
|
+
* `windows`)照旧可用。
|
|
22
|
+
*
|
|
23
|
+
* 注入手法与 `plugins.ts` 的 `syncPluginToolsIntoSession` 相同:改 `session._customTools`
|
|
24
|
+
* 后调 `_refreshToolRegistry()`(SDK 改私有字段名即返回 null,调用方按「覆盖没装上」降级;
|
|
25
|
+
* `extensionRunner` 缺失时退化成「有扩展也当内置」=改动前的行为,不会更差)。
|
|
26
|
+
*
|
|
27
|
+
* 顺序:注入项排在既有 `_customTools` **之前** ⇒ pi-web-ui 插件注册的同名工具(也是
|
|
28
|
+
* customTools,比覆盖层后写)仍然是最后赢家,与改动前的相对顺序一致。
|
|
29
|
+
*/
|
|
30
|
+
/** 解析某个工具名在**扩展注册表**里的实现;没有扩展提供时返回 undefined。 */
|
|
31
|
+
export function extensionToolDefinition(session, name) {
|
|
32
|
+
const tools = session.extensionRunner?.getAllRegisteredTools?.();
|
|
33
|
+
return tools?.find((tool) => tool.definition?.name === name)?.definition;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 把覆盖装进已建好的会话:逐个解析基底(扩展同名工具优先)→ 装饰 → 注入 → 刷新注册表。
|
|
37
|
+
* 返回注入的工具名;会话对象形状不符(SDK 改私有字段名)返回 null,调用方按「覆盖未生效」降级。
|
|
38
|
+
*/
|
|
39
|
+
export function installToolOverrides(session, specs) {
|
|
40
|
+
if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
|
|
41
|
+
return null;
|
|
42
|
+
const names = new Set(specs.map((s) => s.name));
|
|
43
|
+
const injected = [];
|
|
44
|
+
for (const spec of specs) {
|
|
45
|
+
const extensionTool = extensionToolDefinition(session, spec.name);
|
|
46
|
+
if (extensionTool) {
|
|
47
|
+
// 有扩展同名工具:只能叠(composeWith);没给装饰器就整个让给它 ——
|
|
48
|
+
// **绝不**用 fallback 顶掉它(那就又是本模块存在要防的那个问题)。
|
|
49
|
+
if (spec.composeWith)
|
|
50
|
+
injected.push(spec.composeWith(extensionTool));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
injected.push(spec.fallback());
|
|
54
|
+
}
|
|
55
|
+
// 同名项先剔除再前置:重复调用幂等(不会层层叠加),且插件工具仍然后写赢。
|
|
56
|
+
session._customTools = [...injected, ...session._customTools.filter((t) => !names.has(t.name))];
|
|
57
|
+
session._refreshToolRegistry();
|
|
58
|
+
return [...names];
|
|
59
|
+
}
|
|
@@ -13,6 +13,7 @@ import { readdirSync, readFileSync, realpathSync, existsSync } from "node:fs";
|
|
|
13
13
|
import { delimiter, dirname, join } from "node:path";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { pick } from "./i18n.js";
|
|
16
|
+
import { sdkCopies } from "./sdk-origin.js";
|
|
16
17
|
const PI_CORE_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
17
18
|
/** npm 官方源;用户在 <agentDir>/npm/.npmrc 里配了镜像/私有源时会被覆盖(issue #151)。 */
|
|
18
19
|
export const NPM_DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
@@ -424,6 +425,32 @@ export function defaultProbePiCore() {
|
|
|
424
425
|
piCoreProbe = { at: now, version };
|
|
425
426
|
return version;
|
|
426
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* issue #321 — detect "a newer pi is installed on this machine than the one
|
|
430
|
+
* this process actually loaded". The panel's pi-core row probes the GLOBAL pi
|
|
431
|
+
* CLI (what `npm i -g` updates), but the server loads its own bundled copy
|
|
432
|
+
* (nested beats ancestor in Node resolution, issue #260) — so after upgrading
|
|
433
|
+
* the global pi the panel reads "up to date" while conversations still run the
|
|
434
|
+
* old SDK. That invisible split is exactly what #321 reports; this turns it
|
|
435
|
+
* into data the UI can explain.
|
|
436
|
+
*
|
|
437
|
+
* Two detectors, unioned (newest wins): the CLI probe (standard global
|
|
438
|
+
* install) and the ancestor-chain copies (fallback when the probe finds
|
|
439
|
+
* nothing — different npm prefix, bun, …). Pure — tests inject both.
|
|
440
|
+
* Returns null when nothing newer than `runningVersion` is found.
|
|
441
|
+
*/
|
|
442
|
+
export function detectPiSdkSplit(runningVersion, probePiCore = defaultProbePiCore, copies = sdkCopies()) {
|
|
443
|
+
let installed = probePiCore();
|
|
444
|
+
for (const copy of copies) {
|
|
445
|
+
if (compareVersions(copy.version, runningVersion) > 0 &&
|
|
446
|
+
(!installed || compareVersions(copy.version, installed) > 0)) {
|
|
447
|
+
installed = copy.version;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!installed || compareVersions(installed, runningVersion) <= 0)
|
|
451
|
+
return null;
|
|
452
|
+
return { running: runningVersion, installed };
|
|
453
|
+
}
|
|
427
454
|
/**
|
|
428
455
|
* Fallback when the CLI probe yields nothing: the version of the vendored pi
|
|
429
456
|
* core copy in <agentDir>/npm/node_modules, or null if that is absent too.
|
|
@@ -646,7 +673,7 @@ gitCheck = defaultCheckGitExtension) {
|
|
|
646
673
|
* up-to-date ones, errors last. Stable within each bucket (Array.sort is
|
|
647
674
|
* stable) so registry order survives ties. */
|
|
648
675
|
export function sortUpdateItems(items) {
|
|
649
|
-
const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : 2);
|
|
676
|
+
const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : k === "plugin" ? 2 : 3);
|
|
650
677
|
return [...items].sort((a, b) => {
|
|
651
678
|
const ka = kindRank(a.kind);
|
|
652
679
|
const kb = kindRank(b.kind);
|
package/dist/server/uploads.js
CHANGED
|
@@ -23,11 +23,26 @@ export function uploadRetentionDays() {
|
|
|
23
23
|
const v = Number(process.env.PI_WEB_UPLOAD_RETENTION_DAYS);
|
|
24
24
|
return Number.isFinite(v) && v >= 0 ? v : 14;
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
26
|
+
/** clientId 白名单:clientId 来自浏览器自报(ws hello),却直接拼进上传目录
|
|
27
|
+
* 路径 —— 只允许安全的单段字符(字母数字、:、_、-,1-128 长),否则可
|
|
28
|
+
* "../" 遍历写任意目录。不匹配整条拒绝抛错:绝不能静默换 randomUUID,
|
|
29
|
+
* 上传目录必须与 ws 会话一一对应(编辑重问靠 uploadPath 按同一目录找回)。 */
|
|
30
|
+
export const CLIENT_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
|
|
31
|
+
/** Persist an uploaded buffer; returns the absolute path + sanitized display name.
|
|
32
|
+
* clientId 不匹配白名单、displayName 为空 / "." / ".." 时抛错,调用方负责报错。 */
|
|
27
33
|
export function saveUpload(clientId, name, buf, dataDir = resolveDataDir()) {
|
|
34
|
+
if (!CLIENT_ID_PATTERN.test(clientId)) {
|
|
35
|
+
throw new Error(`Invalid upload clientId: ${JSON.stringify(clientId.slice(0, 129))}`);
|
|
36
|
+
}
|
|
37
|
+
// displayName 只取 basename 并拒绝 "." / "..":防 ".." 与绝对路径拼接穿越
|
|
38
|
+
// (最终文件名带 <ts>- 前缀兜底,这里从源头收紧)。
|
|
39
|
+
const base = name.split(/[\\/]/).pop() ?? "";
|
|
40
|
+
if (!base || base === "." || base === "..") {
|
|
41
|
+
throw new Error(`Invalid upload name: ${JSON.stringify(name.slice(0, 129))}`);
|
|
42
|
+
}
|
|
43
|
+
const displayName = base.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").slice(0, 80) || "file";
|
|
28
44
|
const dir = join(uploadsRoot(dataDir), clientId);
|
|
29
45
|
mkdirSync(dir, { recursive: true });
|
|
30
|
-
const displayName = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").slice(0, 80) || "file";
|
|
31
46
|
const abs = join(dir, `${Date.now()}-${displayName}`);
|
|
32
47
|
writeFileSync(abs, buf);
|
|
33
48
|
return { abs, displayName };
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// background subagent run, so its runtime must be kept alive across project /
|
|
25
25
|
// conversation switches. See the Chinese block above for the persistence layout
|
|
26
26
|
// and the fail-open vs fail-closed rationale.
|
|
27
|
-
import { readdirSync, readFileSync
|
|
27
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
28
28
|
import { homedir, tmpdir, userInfo } from "node:os";
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -114,25 +114,32 @@ export function resolveAsyncRunsDir(env = process.env) {
|
|
|
114
114
|
return path.join(resolveTempRoot(env), "async-subagent-runs");
|
|
115
115
|
}
|
|
116
116
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
117
|
+
* 【已废弃】按 marker mtime 的 24h 孤儿守卫(原 DEFAULT_STALE_ACTIVE_MARKER_MS)
|
|
118
|
+
* 已移除:run 是否终结改由 status.json 的 state 判定(见 hasActiveSubagentRun)。
|
|
119
|
+
* mtime 只能说明「marker 很久没动」,对 >24h 的超长 run 会误杀活跃证据。
|
|
120
120
|
*/
|
|
121
|
-
export const DEFAULT_STALE_ACTIVE_MARKER_MS = 24 * 60 * 60 * 1000;
|
|
122
121
|
/** 活跃 marker 索引目录名,与 pi-subagents 的 ACTIVE_RUN_INDEX_DIR 一致。 */
|
|
123
122
|
const ACTIVE_RUN_INDEX_DIR = ".active-runs";
|
|
124
123
|
/**
|
|
125
124
|
* 磁盘扫描:该会话是否还有活跃(queued/running)的 pi-subagents 异步 run。
|
|
126
|
-
* 任何 I/O /
|
|
125
|
+
* 任何 I/O / 解析错误都按「无证据」处理(fail-open,见文件头说明)。
|
|
127
126
|
* ENOENT(目录/文件尚不存在、marker 刚被清掉)保持静默;其余 I/O 错误
|
|
128
127
|
* console.warn 一次(与 hasPendingWaitSubscription 对齐)。
|
|
128
|
+
*
|
|
129
|
+
* run 是否已终结的廉价判据是 status.json 的 state 字段——它本来就在同一循环
|
|
130
|
+
* 里逐 marker 读取,零额外 I/O。孤儿清理因此只作用于「已终结 run」的遗留
|
|
131
|
+
* marker(终结态在下方无条件判为无证据);曾按 marker mtime 超 24h 一律
|
|
132
|
+
* 跳过的旧守卫已移除:它同样命中「超长 run(>24h 仍在跑)」的活跃 marker,
|
|
133
|
+
* 会把正常运行误杀成孤儿、提前释放运行时,父会话永远等不到 wake。与上游
|
|
134
|
+
* pi-subagents 的 DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS 语义对齐——24h
|
|
135
|
+
* 自限只该管 terminal marker,不该管还声称存活的 run。代价:进程崩溃遗留
|
|
136
|
+
* 的 queued/running status 不再随 mtime 过期自动失效,其代价(多保留一个
|
|
137
|
+
* 空闲运行时)远小于误杀超长 run 的静默停滞。
|
|
129
138
|
*/
|
|
130
139
|
export function hasActiveSubagentRun(options) {
|
|
131
140
|
const sessionId = options.sessionId;
|
|
132
141
|
if (!sessionId)
|
|
133
142
|
return false;
|
|
134
|
-
const now = options.now ?? Date.now;
|
|
135
|
-
const staleMarkerMs = options.staleMarkerMs ?? DEFAULT_STALE_ACTIVE_MARKER_MS;
|
|
136
143
|
const warn = options.warn ?? ((message, error) => console.warn(message, error));
|
|
137
144
|
const isNotFound = (error) => typeof error === "object" &&
|
|
138
145
|
error !== null &&
|
|
@@ -152,18 +159,6 @@ export function hasActiveSubagentRun(options) {
|
|
|
152
159
|
for (const runId of markers) {
|
|
153
160
|
if (runId.startsWith("."))
|
|
154
161
|
continue; // 不把隐藏文件当 run 证据
|
|
155
|
-
const markerFile = path.join(indexDir, runId);
|
|
156
|
-
// 孤儿防护:marker 太久没被 touch(崩溃遗留,run 早已不在)→ 无证据。
|
|
157
|
-
try {
|
|
158
|
-
const ageMs = now() - statSync(markerFile).mtimeMs;
|
|
159
|
-
if (ageMs > staleMarkerMs)
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
catch (error) {
|
|
163
|
-
if (!isNotFound(error))
|
|
164
|
-
warn(`Failed to stat active-run marker '${markerFile}':`, error);
|
|
165
|
-
continue; // ENOENT(刚被清除的竞态)→ 无证据
|
|
166
|
-
}
|
|
167
162
|
let status;
|
|
168
163
|
try {
|
|
169
164
|
status = JSON.parse(readFileSync(path.join(runsDir, runId, "status.json"), "utf-8"));
|
|
@@ -178,8 +173,10 @@ export function hasActiveSubagentRun(options) {
|
|
|
178
173
|
const probe = status;
|
|
179
174
|
if (probe.sessionId !== sessionId)
|
|
180
175
|
continue; // 别的会话的 run
|
|
176
|
+
// 终结态(complete/failed/stopped/…)→ run 已结束:遗留 marker 按孤儿
|
|
177
|
+
// 清理对待,无条件不算证据;只有 queued/running 才是活跃证据。
|
|
181
178
|
if (probe.state !== "queued" && probe.state !== "running")
|
|
182
|
-
continue;
|
|
179
|
+
continue;
|
|
183
180
|
return true;
|
|
184
181
|
}
|
|
185
182
|
return false;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/workspace-snapshot.ts
|
|
3
|
+
*
|
|
4
|
+
* 工作区版本影子快照机制(Workspace Snapshot / Dual-State Rollback)。
|
|
5
|
+
*
|
|
6
|
+
* 每次用户发起新任务或运行关键操作前,为当前工作区记录轻量快照引用(Git commit SHA)。
|
|
7
|
+
* 机制:
|
|
8
|
+
* - 利用独立的临时 GIT_INDEX_FILE,通过 `git add -A` + `git write-tree` + `git commit-tree` 生成独立 commit;
|
|
9
|
+
* - 绝不改变用户现有的 HEAD、branch 指针、工作区文件或 .git/index;
|
|
10
|
+
* - 回滚时若启用 restoreWorkspace,通过 `git checkout <snapshotRef> -- .` 与 `git clean -fd` 还原物理文件。
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { rm } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { gitDirOf } from "./scm.js";
|
|
18
|
+
const exec = promisify(execFile);
|
|
19
|
+
/** 单次 git 快照/还原命令超时时间(毫秒)。 */
|
|
20
|
+
const GIT_SNAPSHOT_TIMEOUT_MS = 15_000;
|
|
21
|
+
/** 执行单条 git 命令,返回 stdout。 */
|
|
22
|
+
async function runGit(cwd, args, envExtra) {
|
|
23
|
+
const { stdout } = await exec("git", ["-c", "core.quotepath=false", ...args], {
|
|
24
|
+
cwd,
|
|
25
|
+
env: envExtra ? { ...process.env, ...envExtra } : process.env,
|
|
26
|
+
timeout: GIT_SNAPSHOT_TIMEOUT_MS,
|
|
27
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
});
|
|
30
|
+
return stdout.trim();
|
|
31
|
+
}
|
|
32
|
+
/** 检查工作区是否由 Git 管理。 */
|
|
33
|
+
export async function isGitWorkspace(cwd) {
|
|
34
|
+
return (await gitDirOf(cwd)) !== null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 为当前工作区创建轻量版本快照。
|
|
38
|
+
* 若当前目录不在 Git 仓库内,返回 null。
|
|
39
|
+
*
|
|
40
|
+
* 该操作纯粹在临时 index 文件中完成,绝不影响用户的实际 index 或 HEAD。
|
|
41
|
+
*/
|
|
42
|
+
export async function createWorkspaceSnapshot(cwd) {
|
|
43
|
+
const gitDir = await gitDirOf(cwd);
|
|
44
|
+
if (!gitDir)
|
|
45
|
+
return null;
|
|
46
|
+
const tempIndex = join(gitDir, `temp-index-snapshot-${randomUUID()}`);
|
|
47
|
+
const env = { GIT_INDEX_FILE: tempIndex };
|
|
48
|
+
try {
|
|
49
|
+
// 1. 将当前工作区所有改动(含未跟踪与新增)记录到临时 index
|
|
50
|
+
await runGit(cwd, ["add", "-A"], env);
|
|
51
|
+
// 2. 写入 tree 对象
|
|
52
|
+
const tree = await runGit(cwd, ["write-tree"], env);
|
|
53
|
+
if (!tree)
|
|
54
|
+
return null;
|
|
55
|
+
// 3. 检查是否有 HEAD commit 作为 parent
|
|
56
|
+
let hasHead = false;
|
|
57
|
+
try {
|
|
58
|
+
await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
|
|
59
|
+
hasHead = true;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
hasHead = false;
|
|
63
|
+
}
|
|
64
|
+
// 4. 创建轻量 commit 对象,不关联任何分支引用
|
|
65
|
+
const commitArgs = ["commit-tree", tree];
|
|
66
|
+
if (hasHead) {
|
|
67
|
+
commitArgs.push("-p", "HEAD");
|
|
68
|
+
}
|
|
69
|
+
commitArgs.push("-m", `pi-web-ui: workspace shadow snapshot ${new Date().toISOString()}`);
|
|
70
|
+
const commitHash = await runGit(cwd, commitArgs, env);
|
|
71
|
+
return commitHash || null;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.warn(`[workspace-snapshot] 创建快照失败 (${cwd}):`, err.message);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
// 务必清理临时 index 文件
|
|
79
|
+
try {
|
|
80
|
+
await rm(tempIndex, { force: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ignore cleanup error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 将工作区物理文件还原到指定的快照状态。
|
|
89
|
+
*
|
|
90
|
+
* 执行步骤:
|
|
91
|
+
* 1. `git read-tree -u --reset <snapshotRef>` 将 index 与工作区重置为快照树,并删除快照之后新增的文件;
|
|
92
|
+
* 2. `git clean -fd` 清理快照之后新增的未跟踪文件与空目录。
|
|
93
|
+
*/
|
|
94
|
+
export async function restoreWorkspaceSnapshot(cwd, snapshotRef) {
|
|
95
|
+
const gitDir = await gitDirOf(cwd);
|
|
96
|
+
if (!gitDir) {
|
|
97
|
+
return { success: false, error: "Workspace is not a Git repository" };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
// 验证 snapshotRef 合法性
|
|
101
|
+
await runGit(cwd, ["rev-parse", "--verify", snapshotRef]);
|
|
102
|
+
// 1. 将 index 和工作区彻底重置到快照树状态(自动清除快照中不存在的文件)
|
|
103
|
+
await runGit(cwd, ["read-tree", "-u", "--reset", snapshotRef]);
|
|
104
|
+
// 2. 清理新产生的未跟踪文件与目录
|
|
105
|
+
await runGit(cwd, ["clean", "-fd"]);
|
|
106
|
+
return { success: true };
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const msg = err.message;
|
|
110
|
+
console.error(`[workspace-snapshot] 还原快照失败 (${cwd}, ${snapshotRef}):`, msg);
|
|
111
|
+
return { success: false, error: msg };
|
|
112
|
+
}
|
|
113
|
+
}
|