pi-web-ui 0.94.1 → 0.95.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 +49 -1
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +2 -2
- 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 +20 -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/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- 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,15 +59,25 @@ 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 工具总目录(共
|
|
80
|
+
/** 可开关的 Agent 工具总目录(共 27 个;bash 本体与 SDK 内置 edit/read
|
|
70
81
|
* 不进目录——关了 agent 就残了,不给关)。 */
|
|
71
82
|
export const AGENT_TOOL_CATALOG = [
|
|
72
83
|
...TERMINAL_TOOL_NAMES.map((name) => ({
|
|
@@ -135,6 +146,31 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
135
146
|
descKey: "claimFilesEnabledDesc",
|
|
136
147
|
offHintKey: "claimFilesOffHint",
|
|
137
148
|
},
|
|
149
|
+
// 结构化任务计划更新(Plan Mode / Step State Machine),默认开。
|
|
150
|
+
{
|
|
151
|
+
name: PLAN_UPDATE_TOOL_NAME,
|
|
152
|
+
group: "other",
|
|
153
|
+
defaultOn: true,
|
|
154
|
+
dshVisible: true,
|
|
155
|
+
descKey: "planUpdateEnabledDesc",
|
|
156
|
+
offHintKey: "planUpdateOffHint",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: PATCH_TOOL_NAME,
|
|
160
|
+
group: "other",
|
|
161
|
+
defaultOn: true,
|
|
162
|
+
dshVisible: false,
|
|
163
|
+
descKey: "patchToolEnabledDesc",
|
|
164
|
+
offHintKey: "patchToolOffHint",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: LSP_TOOL_NAME,
|
|
168
|
+
group: "other",
|
|
169
|
+
defaultOn: false,
|
|
170
|
+
dshVisible: false,
|
|
171
|
+
descKey: "lspToolEnabledDesc",
|
|
172
|
+
offHintKey: "lspToolOffHint",
|
|
173
|
+
},
|
|
138
174
|
// 展示文件给用户(图片/视频内联、文本开预览弹窗、本地打开按钮):默认开,
|
|
139
175
|
// 不打开模型根本不知道能“给用户看”;DSH 引擎没有该 customTool(走 shipped preset)。
|
|
140
176
|
{
|
|
@@ -155,6 +191,16 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
155
191
|
descKey: "skillEnabledDesc",
|
|
156
192
|
offHintKey: "skillOffHint",
|
|
157
193
|
},
|
|
194
|
+
// 持久代码求值沙箱(Python / Node.js):默认关(opt-in,防工具挤占);
|
|
195
|
+
// DSH 引擎没有该 customTool。
|
|
196
|
+
{
|
|
197
|
+
name: EVAL_TOOL_NAME,
|
|
198
|
+
group: "other",
|
|
199
|
+
defaultOn: false,
|
|
200
|
+
dshVisible: false,
|
|
201
|
+
descKey: "evalEnabledDesc",
|
|
202
|
+
offHintKey: "evalOffHint",
|
|
203
|
+
},
|
|
158
204
|
// 定时/延时唤醒:默认开(不打开 AI 根本不知道能定时;60s 间隔底线+面板可随时取消),
|
|
159
205
|
// DSH 引擎没有该 customTool(走 goal-rpc,无 customTool 注册面)。
|
|
160
206
|
{
|
|
@@ -181,6 +227,16 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
181
227
|
descKey: "scheduleTaskEnabledDesc",
|
|
182
228
|
offHintKey: "scheduleTaskOffHint",
|
|
183
229
|
},
|
|
230
|
+
// 主动上下文压缩:默认开(让 AI 可以根据当前问题主动精简上下文)。
|
|
231
|
+
// DSH 引擎无 customTool 注册面,不接。
|
|
232
|
+
{
|
|
233
|
+
name: COMPACT_CONTEXT_TOOL_NAME,
|
|
234
|
+
group: "other",
|
|
235
|
+
defaultOn: true,
|
|
236
|
+
dshVisible: false,
|
|
237
|
+
descKey: "compactContextEnabledDesc",
|
|
238
|
+
offHintKey: "compactContextOffHint",
|
|
239
|
+
},
|
|
184
240
|
// todo_list 唯一例外:行不在「其他」组,固定在上面的 markers 分区(设置页循环
|
|
185
241
|
// 跳过它,见 OTHER_AGENT_TOOLS;文案 key 照给,万一哪天搬家不用补)。
|
|
186
242
|
{
|
|
@@ -263,9 +319,10 @@ export function setAgentToolsEnabled(session, names, on) {
|
|
|
263
319
|
/**
|
|
264
320
|
* 全量重放(创建会话 / reload 后 / 设置变更后调):按禁用名单把目录内工具
|
|
265
321
|
* 逐个加回或剔除;目录外的工具(bash/SDK 内置/插件工具)原样不动。
|
|
322
|
+
* 支持传入 preset(预设 id),按预设白名单做二次过滤。
|
|
266
323
|
* Session 未就绪时静默跳过(下次创建/reload 会再应用)。
|
|
267
324
|
*/
|
|
268
|
-
export function applyAgentToolsGating(session, disabled) {
|
|
325
|
+
export function applyAgentToolsGating(session, disabled, preset) {
|
|
269
326
|
try {
|
|
270
327
|
const off = new Set(disabled);
|
|
271
328
|
const names = new Set(session.getActiveToolNames());
|
|
@@ -275,12 +332,155 @@ export function applyAgentToolsGating(session, disabled) {
|
|
|
275
332
|
else
|
|
276
333
|
names.add(t.name);
|
|
277
334
|
}
|
|
278
|
-
|
|
335
|
+
const filtered = filterToolsByPreset(names, preset);
|
|
336
|
+
session.setActiveToolsByName(filtered);
|
|
279
337
|
}
|
|
280
338
|
catch {
|
|
281
339
|
// Session 未就绪——下次创建/reload 会再应用。
|
|
282
340
|
}
|
|
283
341
|
}
|
|
342
|
+
/** pi 引擎内置 Agent 预设名录(对齐 DSH 预设体系,会话级工具白名单)。 */
|
|
343
|
+
export const PI_AGENT_PRESETS = [
|
|
344
|
+
{
|
|
345
|
+
id: "standard",
|
|
346
|
+
trust: "system",
|
|
347
|
+
isDefault: true,
|
|
348
|
+
name: "全功能",
|
|
349
|
+
description: "提供全部可用工具与扩展能力(默认)",
|
|
350
|
+
order: 0,
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
id: "minimal",
|
|
354
|
+
trust: "system",
|
|
355
|
+
isDefault: false,
|
|
356
|
+
name: "极简模式",
|
|
357
|
+
description: "仅保留 bash 与 read;插件工具、技能名录与终端引导同步隐藏",
|
|
358
|
+
order: 1,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
id: "code",
|
|
362
|
+
trust: "system",
|
|
363
|
+
isDefault: false,
|
|
364
|
+
name: "代码开发",
|
|
365
|
+
description: "专注于代码读写与执行(bash, read, edit, write, edit_soft);插件工具与技能名录同步隐藏",
|
|
366
|
+
order: 2,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: "reader",
|
|
370
|
+
trust: "system",
|
|
371
|
+
isDefault: false,
|
|
372
|
+
name: "只读分析",
|
|
373
|
+
description: "仅保留只读工具,禁止写操作;插件工具同步隐藏(读写未知,保守处理)",
|
|
374
|
+
order: 3,
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
id: "ask",
|
|
378
|
+
trust: "system",
|
|
379
|
+
isDefault: false,
|
|
380
|
+
name: "纯对话",
|
|
381
|
+
description: "无工具问答模式,模型不调用任何工具;插件工具与技能名录同步隐藏",
|
|
382
|
+
order: 4,
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
/** pi 引擎权限预设选项(三档沙箱策略)。 */
|
|
386
|
+
export const PI_PERMISSION_OPTIONS = [
|
|
387
|
+
{
|
|
388
|
+
value: "read-only",
|
|
389
|
+
name: "只读模式",
|
|
390
|
+
description: "禁止所有文件修改(write/edit/edit_soft)及任何非只读操作",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
value: "workspace-write-never",
|
|
394
|
+
name: "工作区内修改",
|
|
395
|
+
description: "仅允许在当前工作区目录下修改文件,工作区外写操作一律拒绝",
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
value: "danger-full-access",
|
|
399
|
+
name: "完全权限",
|
|
400
|
+
description: "允许修改任意目录文件及执行全量操作(需要二次确认)",
|
|
401
|
+
},
|
|
402
|
+
];
|
|
403
|
+
/** 按预设过滤活跃工具名。 */
|
|
404
|
+
export function filterToolsByPreset(tools, preset) {
|
|
405
|
+
const all = Array.from(tools);
|
|
406
|
+
if (!preset || preset === "standard")
|
|
407
|
+
return all;
|
|
408
|
+
if (preset === "ask")
|
|
409
|
+
return [];
|
|
410
|
+
if (preset === "minimal") {
|
|
411
|
+
return all.filter((n) => n === "bash" || n === "read");
|
|
412
|
+
}
|
|
413
|
+
if (preset === "code") {
|
|
414
|
+
const codeSet = new Set(["bash", "read", "edit", "write", "edit_soft"]);
|
|
415
|
+
return all.filter((n) => codeSet.has(n));
|
|
416
|
+
}
|
|
417
|
+
if (preset === "reader") {
|
|
418
|
+
const writeTools = new Set([
|
|
419
|
+
"write",
|
|
420
|
+
"edit",
|
|
421
|
+
"edit_soft",
|
|
422
|
+
"bash",
|
|
423
|
+
"terminal_create",
|
|
424
|
+
"terminal_input",
|
|
425
|
+
"terminal_close",
|
|
426
|
+
"terminal_key",
|
|
427
|
+
]);
|
|
428
|
+
return all.filter((n) => !writeTools.has(n));
|
|
429
|
+
}
|
|
430
|
+
return all;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* pi 预设语义总表(唯一事实源,本文件是唯一定义处):
|
|
434
|
+
*
|
|
435
|
+
* 预设是「禁用名单之外」的第二层门控,按会话生效(conv.agentPreset),新对话
|
|
436
|
+
* 取默认预设、首轮发言后锁定。标准预设不过滤;其余预设同时约束四处:
|
|
437
|
+
*
|
|
438
|
+
* - 目录/内置工具(ActiveSet):filterToolsByPreset 直接过滤活跃集
|
|
439
|
+
* (standard 全留 / ask 全拔 / minimal 仅 bash+read /
|
|
440
|
+
* code 仅 bash+read+edit+write+edit_soft / reader 拔写类)。
|
|
441
|
+
* - 插件工具(registerAgentTool 动态注册):presetAllowsPluginTools —— 只有
|
|
442
|
+
* standard 保留,其余已知预设一律拔掉。插件工具读写性质未知,保守按最严处理
|
|
443
|
+
* (minimal/code/ask 是白名单语义本来就过不去;reader 是 deny 名单,
|
|
444
|
+
* 未知工具同样不放行;未知预设 id 按不过滤,与 filterToolsByPreset 同口径)。
|
|
445
|
+
* - 技能名录段({{skills}} 名录+全文注入):presetShowsSkillCatalog —— 与
|
|
446
|
+
* skill 加载工具同进退(minimal/code/ask 下 loader 不在,列出来只是噪音;
|
|
447
|
+
* reader 下 loader 可用,保留)。子代理模板的技能白名单是显式配置,
|
|
448
|
+
* 优先级高于预设,不参与此门控。
|
|
449
|
+
* - 终端引导(TERMINAL_TOOLS_GUIDANCE):isTerminalGuidanceOn(disabled, preset) ——
|
|
450
|
+
* 只教「开关开着且预设下仍可用」的终端工具,不教不存在的工具。
|
|
451
|
+
* - 并行提醒(parallel-work-reminder):信息层,不按预设开关(只看
|
|
452
|
+
* parallelReminderEnabled 开关);但文案里的问卷指引按
|
|
453
|
+
* presetHasQuestionnaire 切换措辞——有问卷工具时调工具,否则正文提问。
|
|
454
|
+
* 认领信息是事实层,照常展示(store 是按项目全局的)。
|
|
455
|
+
*
|
|
456
|
+
* 显式配置永远优先于预设:disabledAgentTools / disabledPluginTools /
|
|
457
|
+
* disabledSkills / 子代理模板白名单照常生效,预设只做减法不做加法
|
|
458
|
+
* (开关关掉的东西,standard 也不会加回来)。
|
|
459
|
+
*/
|
|
460
|
+
/**
|
|
461
|
+
* 预设是否允许插件工具(动态注册,读写未知):只有 standard 允许;
|
|
462
|
+
* 其余已知预设一律拒绝。未知 id 按不过滤(与 filterToolsByPreset 同口径,
|
|
463
|
+
* 防手写脏配置把插件工具全灭)。
|
|
464
|
+
*/
|
|
465
|
+
export function presetAllowsPluginTools(preset) {
|
|
466
|
+
if (!preset || preset === "standard")
|
|
467
|
+
return true;
|
|
468
|
+
return !PI_AGENT_PRESETS.some((p) => p.id === preset);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* 预设下是否展示技能名录段(与 skill 加载工具同进退,由 filterToolsByPreset
|
|
472
|
+
* 单源推导,不另维护名单:minimal/code/ask 藏,standard/reader 留)。
|
|
473
|
+
*/
|
|
474
|
+
export function presetShowsSkillCatalog(preset) {
|
|
475
|
+
return filterToolsByPreset([SKILL_TOOL_NAME], preset).includes(SKILL_TOOL_NAME);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* 预设下问卷工具是否可用(并行提醒等服务端文案用:有则指引调工具,
|
|
479
|
+
* 无则指引正文提问——不教不存在的工具)。同样单源推导。
|
|
480
|
+
*/
|
|
481
|
+
export function presetHasQuestionnaire(preset) {
|
|
482
|
+
return filterToolsByPreset([ASK_USER_QUESTION_TOOL_NAME], preset).includes(ASK_USER_QUESTION_TOOL_NAME);
|
|
483
|
+
}
|
|
284
484
|
/**
|
|
285
485
|
* 旧存档迁移:已有新字段直接归一化;否则按遗留三开关折算
|
|
286
486
|
* (语义与改动前一致:terminal/edit 未设/关 = 禁用对应组;问卷未设/开 = 启用)。
|
|
@@ -337,7 +537,15 @@ export function effectiveDisabledAgentTools(s) {
|
|
|
337
537
|
next.add(ASK_USER_QUESTION_TOOL_NAME);
|
|
338
538
|
return [...next];
|
|
339
539
|
}
|
|
340
|
-
/**
|
|
341
|
-
|
|
342
|
-
|
|
540
|
+
/**
|
|
541
|
+
* 终端使用引导是否注入:组内有任一工具「开关开着且预设下仍可用」才教 AI 用,
|
|
542
|
+
* 否则就是教不存在的工具(preset 缺省/standard = 只看开关,保持旧语义)。
|
|
543
|
+
*/
|
|
544
|
+
export function isTerminalGuidanceOn(disabled, preset) {
|
|
545
|
+
const on = TERMINAL_TOOL_NAMES.filter((n) => !disabled.includes(n));
|
|
546
|
+
if (on.length === 0)
|
|
547
|
+
return false;
|
|
548
|
+
if (!preset || preset === "standard")
|
|
549
|
+
return true;
|
|
550
|
+
return filterToolsByPreset(on, preset).length > 0;
|
|
343
551
|
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.0",
|
|
4
4
|
"description": "Just open your browser to get all your work done — browser cockpit for AI coding agents (chat, code, files, terminal, Git in one tab)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
|
|
86
86
|
"@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
|
|
87
|
-
"@earendil-works/pi-coding-agent": ">=0.85.1
|
|
87
|
+
"@earendil-works/pi-coding-agent": ">=0.85.1",
|
|
88
88
|
"@xterm/addon-fit": "^0.11.0",
|
|
89
89
|
"@xterm/xterm": "^6.0.0",
|
|
90
90
|
"compression": "^1.8.2",
|
package/plugin-sdk/README.md
CHANGED
|
@@ -125,4 +125,29 @@ describe("my-plugin", () => {
|
|
|
125
125
|
| `schedule` 持久版(`{id, persistent, catchUp, label}`,落盘+补跑+进后台面板) | ✅ | — |
|
|
126
126
|
| `host.requestPermission`(动态授权:net 补主机 / llm 限模型,用户逐条确认可记住) | ✅(基础族须已声明) | 确认框 + 设置面板「已授权能力」 |
|
|
127
127
|
|
|
128
|
+
## 反激活与清理:`host.effect(label, dispose)`
|
|
129
|
+
|
|
130
|
+
宿主的每个注册面(`ui.register` / `registerAgentTool` / `registerCommand` / `route` /
|
|
131
|
+
`registerProxy` / `fs.watch` / `schedule` / `registerBackgroundTask` / `events.on` /
|
|
132
|
+
`onStats` / `onStreaming` / `onSettingsChanged` …)都返回注销函数,**并且**已经在宿主内部
|
|
133
|
+
登记进该插件的 effect 栈:反激活(禁用、卸载、`plugins_reload`)时**逆序回卷**,插件忘了
|
|
134
|
+
调用返回的注销函数也不会留下孤儿订阅/定时器/路由。
|
|
135
|
+
|
|
136
|
+
你自己建的东西(`setInterval` / `addEventListener` / WebSocket / 自建缓存)挂在
|
|
137
|
+
`host.effect` 上同样享受这条保障:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
export default definePlugin({
|
|
141
|
+
async activate(host) {
|
|
142
|
+
const timer = setInterval(() => host.log("info", "tick"), 60_000);
|
|
143
|
+
host.effect("my-timer", () => clearInterval(timer));
|
|
144
|
+
// 也可以两者都写(幂等:dispose 写成本身幂等即可)
|
|
145
|
+
const off = host.effect("my-ws", () => ws.close());
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
清理抛错只记一条诊断、不阻断其它清理;`host.effect` 返回的注销函数与反激活都会调
|
|
151
|
+
`dispose`,所以 `dispose` 请写成**幂等**的。
|
|
152
|
+
|
|
128
153
|
完整契约见 `docs/architecture-plugins.md`。
|
package/plugin-sdk/index.d.ts
CHANGED
|
@@ -5,16 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export type PluginPermissionFamily =
|
|
8
|
-
| "fs"
|
|
9
|
-
| "fs:read"
|
|
10
|
-
| "fs:write"
|
|
11
|
-
| "ui"
|
|
12
|
-
| "tools"
|
|
13
|
-
| "http"
|
|
14
|
-
| "chat"
|
|
15
|
-
| "net"
|
|
16
|
-
| "dom"
|
|
17
|
-
| "dom:anchor";
|
|
8
|
+
"fs" | "fs:read" | "fs:write" | "ui" | "tools" | "http" | "chat" | "net" | "dom" | "dom:anchor";
|
|
18
9
|
|
|
19
10
|
export interface WsEntry {
|
|
20
11
|
name: string;
|
|
@@ -35,17 +26,7 @@ export interface UiSelectOption {
|
|
|
35
26
|
}
|
|
36
27
|
|
|
37
28
|
export type UiItemKind =
|
|
38
|
-
| "
|
|
39
|
-
| "action"
|
|
40
|
-
| "badge"
|
|
41
|
-
| "menu"
|
|
42
|
-
| "page"
|
|
43
|
-
| "organizer"
|
|
44
|
-
| "divider"
|
|
45
|
-
| "toggle"
|
|
46
|
-
| "input"
|
|
47
|
-
| "progress"
|
|
48
|
-
| "select";
|
|
29
|
+
"view" | "action" | "badge" | "menu" | "page" | "organizer" | "divider" | "toggle" | "input" | "progress" | "select";
|
|
49
30
|
|
|
50
31
|
export interface UiContribution {
|
|
51
32
|
id: string;
|
|
@@ -152,13 +133,7 @@ export interface PluginHostUi {
|
|
|
152
133
|
/** host.llm.complete:孤立无工具的一次性补全(不建对话、不进历史)。
|
|
153
134
|
* 需要 manifest.permissions 含 "llm"(花用户自己的模型额度)。 */
|
|
154
135
|
export interface PluginHostLlm {
|
|
155
|
-
complete(req: {
|
|
156
|
-
prompt: string;
|
|
157
|
-
system?: string;
|
|
158
|
-
model?: string;
|
|
159
|
-
maxChars?: number;
|
|
160
|
-
timeoutMs?: number;
|
|
161
|
-
}): Promise<{
|
|
136
|
+
complete(req: { prompt: string; system?: string; model?: string; maxChars?: number; timeoutMs?: number }): Promise<{
|
|
162
137
|
ok: boolean;
|
|
163
138
|
text?: string;
|
|
164
139
|
model?: string;
|
|
@@ -167,6 +142,19 @@ export interface PluginHostLlm {
|
|
|
167
142
|
}>;
|
|
168
143
|
}
|
|
169
144
|
|
|
145
|
+
/** 工具拦截守卫看到的 pre 请求(只对 bash/read 生效,全量见 server/plugin-tool-guard.ts)。 */
|
|
146
|
+
export interface ToolGuardPreRequest {
|
|
147
|
+
toolName: "bash" | "read";
|
|
148
|
+
params: unknown;
|
|
149
|
+
conversationId?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 工具 pre 决策:allow 放行 / deny 拒绝 / ask 待确认(暂按拒绝执行)。 */
|
|
153
|
+
export type ToolGuardPreDecision =
|
|
154
|
+
| { decision: "allow" }
|
|
155
|
+
| { decision: "deny"; reason?: string; reasonEn?: string }
|
|
156
|
+
| { decision: "ask"; reason?: string; reasonEn?: string };
|
|
157
|
+
|
|
170
158
|
/** 插件服务端入口拿到的宿主接口(精简:全量见 server/plugins.ts PluginHost)。 */
|
|
171
159
|
export interface PluginHost {
|
|
172
160
|
broadcast(payload: unknown): void;
|
|
@@ -175,6 +163,12 @@ export interface PluginHost {
|
|
|
175
163
|
sendTo(clientId: string, payload: unknown): void;
|
|
176
164
|
onAttach(handler: (clientId: string) => void): () => void;
|
|
177
165
|
onToolEvent(handler: (ev: unknown) => void): () => void;
|
|
166
|
+
/** 注册工具 pre 拦截守卫(只对 bash/read 生效;要 "tools" 能力)。 */
|
|
167
|
+
onToolPre(
|
|
168
|
+
handler: (req: ToolGuardPreRequest) => ToolGuardPreDecision | void | Promise<ToolGuardPreDecision | void>,
|
|
169
|
+
): () => void;
|
|
170
|
+
/** 注册工具 post 编辑守卫(只对 bash/read 生效;要 "tools" 能力)。 */
|
|
171
|
+
onToolPost(handler: (req: unknown) => unknown): () => void;
|
|
178
172
|
onRunEvent(handler: (ev: unknown) => void): () => void;
|
|
179
173
|
getActiveConversation(): unknown;
|
|
180
174
|
onConversationChanged(handler: () => void): () => void;
|
|
@@ -219,6 +213,11 @@ export interface PluginHost {
|
|
|
219
213
|
* 200 条,单条截断 500 字符,不落盘);error 级同时走 console.error。
|
|
220
214
|
* 用户在设置面板“界面插件”页点某插件的“日志”按需查看(级别过滤 + 清空)。 */
|
|
221
215
|
log(level?: "debug" | "info" | "warn" | "error", ...args: unknown[]): void;
|
|
216
|
+
/** 登记一条**自建**的可逆副作用(自建 setInterval / event 监听 / WebSocket…):
|
|
217
|
+
* 返回的注销函数与插件反激活**都会**调 dispose。宿主自己的每个注册面已在内部
|
|
218
|
+
* 走同一个 effect 栈,这里只用于「宿主管不到的那些」—— 挂进来就不怕漏注销
|
|
219
|
+
* (热重载后定时器叠加、监听器堆积都是这个漏法的症状)。dispose 请写成幂等的。 */
|
|
220
|
+
effect(label: string, dispose: () => void): () => void;
|
|
222
221
|
/** 无头调用:把外部通道文本投给 agent(要 "chat" 能力;宿主未接 chatProvider
|
|
223
222
|
* 时 reject(由 chatWait 包成 {ok:false},插件侧用 chatWait 更省心)。 */
|
|
224
223
|
chat(req: { text: string; accountId?: string }): Promise<{
|
|
@@ -259,12 +258,7 @@ export interface PluginHost {
|
|
|
259
258
|
}>;
|
|
260
259
|
};
|
|
261
260
|
/** 常驻后台任务(顶栏「后台任务」面板;返回 update/unregister)。 */
|
|
262
|
-
registerBackgroundTask(task: {
|
|
263
|
-
id: string;
|
|
264
|
-
label: string;
|
|
265
|
-
stop?: () => void;
|
|
266
|
-
status?: string;
|
|
267
|
-
}): {
|
|
261
|
+
registerBackgroundTask(task: { id: string; label: string; stop?: () => void; status?: string }): {
|
|
268
262
|
update(next: Partial<{ label: string; status: string; stop: () => void }>): void;
|
|
269
263
|
unregister(): void;
|
|
270
264
|
};
|
|
@@ -340,10 +334,9 @@ export declare function defineView(view: PluginViewModule): PluginViewModule;
|
|
|
340
334
|
export declare function defineRenderer(
|
|
341
335
|
renderers: Record<string, (code: string, ctx: PluginViewContext) => HTMLElement | null>,
|
|
342
336
|
): Pick<PluginViewModule, "renderers">;
|
|
343
|
-
export declare function actionHandler(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
) => void;
|
|
337
|
+
export declare function actionHandler(
|
|
338
|
+
map: Record<string, (value?: string) => void>,
|
|
339
|
+
): (itemId: string, value?: string) => void;
|
|
347
340
|
export declare function onUiAction(action: string, handler: (itemId: string, value?: string) => void): () => void;
|
|
348
341
|
export declare function getSetting<T>(host: PluginHost, key: string, fallback: T): T;
|
|
349
342
|
export declare function selectOptions(list: Array<string | UiSelectOption>): UiSelectOption[];
|
package/plugin-sdk/index.mjs
CHANGED
|
@@ -211,18 +211,20 @@ export function createMockHost(overrides) {
|
|
|
211
211
|
// 订阅类:存 handler + 返回注销函数(调用本身由外层统一记进 calls)。
|
|
212
212
|
// handler 缺省取首参;fs.watch(path, handler) / events.on(topic, handler) /
|
|
213
213
|
// shortcuts.register(key, handler) 这类回调在第二参,用 sub(method, 1)。
|
|
214
|
-
const sub =
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
214
|
+
const sub =
|
|
215
|
+
(method, argIndex = 0) =>
|
|
216
|
+
(...args) => {
|
|
217
|
+
const handler = args[argIndex];
|
|
218
|
+
if (typeof handler === "function") handlersOf(method).push(handler);
|
|
219
|
+
let done = false;
|
|
220
|
+
return () => {
|
|
221
|
+
if (done) return;
|
|
222
|
+
done = true;
|
|
223
|
+
const arr = handlersOf(method);
|
|
224
|
+
const i = arr.indexOf(handler);
|
|
225
|
+
if (i >= 0) arr.splice(i, 1);
|
|
226
|
+
};
|
|
224
227
|
};
|
|
225
|
-
};
|
|
226
228
|
const logImpl = (levelOrArg, ...args) => {
|
|
227
229
|
const levels = ["debug", "info", "warn", "error"];
|
|
228
230
|
const level = levels.includes(levelOrArg) ? levelOrArg : "info";
|
|
@@ -243,12 +245,16 @@ export function createMockHost(overrides) {
|
|
|
243
245
|
try {
|
|
244
246
|
console.error("[mock-host]", text);
|
|
245
247
|
} catch {
|
|
246
|
-
|
|
248
|
+
/* 打印失败不影响单测 */
|
|
247
249
|
}
|
|
248
250
|
}
|
|
249
251
|
};
|
|
250
252
|
const statImpl = (p) => ({
|
|
251
|
-
name:
|
|
253
|
+
name:
|
|
254
|
+
String(p ?? "")
|
|
255
|
+
.split(/[\\/]/)
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.pop() ?? "",
|
|
252
258
|
type: "file",
|
|
253
259
|
size: 0,
|
|
254
260
|
mtime: 0,
|
|
@@ -264,6 +270,7 @@ export function createMockHost(overrides) {
|
|
|
264
270
|
const routeStore = [];
|
|
265
271
|
const proxyStore = [];
|
|
266
272
|
const scheduleStore = [];
|
|
273
|
+
const effectStore = [];
|
|
267
274
|
// 注册类:存定义体 + 返回只摘除本次注册的注销函数(与宿主同语义)。
|
|
268
275
|
const remember = (store, entry) => {
|
|
269
276
|
if (entry !== undefined) store.push(entry);
|
|
@@ -283,6 +290,8 @@ export function createMockHost(overrides) {
|
|
|
283
290
|
sendTo: noop,
|
|
284
291
|
onAttach: sub("onAttach"),
|
|
285
292
|
onToolEvent: sub("onToolEvent"),
|
|
293
|
+
onToolPre: sub("onToolPre"),
|
|
294
|
+
onToolPost: sub("onToolPost"),
|
|
286
295
|
onRunEvent: sub("onRunEvent"),
|
|
287
296
|
getActiveConversation: () => null,
|
|
288
297
|
chat: async () => okFalse("chatProvider(无头调用未接入)"),
|
|
@@ -313,8 +322,7 @@ export function createMockHost(overrides) {
|
|
|
313
322
|
const list = Array.isArray(items) ? items : [items];
|
|
314
323
|
const added = [];
|
|
315
324
|
for (const raw of list.slice(0, 32)) {
|
|
316
|
-
const id =
|
|
317
|
-
raw && typeof raw.id === "string" && raw.id ? raw.id : `mock-${++uiAutoId}`;
|
|
325
|
+
const id = raw && typeof raw.id === "string" && raw.id ? raw.id : `mock-${++uiAutoId}`;
|
|
318
326
|
uiItems.set(id, isPlainObject(raw) ? { ...raw, id } : { id });
|
|
319
327
|
added.push(id);
|
|
320
328
|
}
|
|
@@ -421,6 +429,12 @@ export function createMockHost(overrides) {
|
|
|
421
429
|
on: sub("events.on", 1),
|
|
422
430
|
},
|
|
423
431
|
log: logImpl,
|
|
432
|
+
// effect 栈:mock 里只记调用 + 返回可撤函数(真实宿主在反激活时逆序回卷)。
|
|
433
|
+
// 单测里测试 dispose 真跑的写法:拿到 off() 后调它,或断言 host.calls 里有 effect。
|
|
434
|
+
effect: (label, dispose) => {
|
|
435
|
+
const entry = { label, dispose };
|
|
436
|
+
return remember(effectStore, entry);
|
|
437
|
+
},
|
|
424
438
|
// 浏览器桥兼容(client 侧逻辑单测也能用同一个 mock):无注入回退语义。
|
|
425
439
|
dialogs: {
|
|
426
440
|
select: async () => ({ ok: false }),
|
|
@@ -527,10 +541,12 @@ export function createMockHost(overrides) {
|
|
|
527
541
|
}
|
|
528
542
|
}
|
|
529
543
|
// 记录层:所有 host 方法(mock/reset 除外)先记 calls 再委托。
|
|
530
|
-
const record =
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
544
|
+
const record =
|
|
545
|
+
(method, fn) =>
|
|
546
|
+
(...args) => {
|
|
547
|
+
calls.push({ method, args, seq: calls.length });
|
|
548
|
+
return fn(...args);
|
|
549
|
+
};
|
|
534
550
|
for (const [key, value] of Object.entries(host)) {
|
|
535
551
|
if (key === "calls" || key === "logs" || key === "mock" || key === "reset") continue;
|
|
536
552
|
if (typeof value === "function") {
|