pi-web-ui 0.94.0 → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -86,16 +86,22 @@ export async function snapshotProcessParents() {
86
86
  return undefined;
87
87
  }
88
88
  }
89
- /** Kill a pid and its whole process tree (cross-platform). */
90
- export function killPidTree(pid) {
89
+ /** Kill a pid and its whole process tree (cross-platform).
90
+ *
91
+ * The returned promise is only meaningful on Windows, where taskkill is itself
92
+ * asynchronous. Existing callers may continue to ignore it for best-effort
93
+ * background cleanup; callers that own a deadline can await completion. */
94
+ export async function killPidTree(pid) {
91
95
  try {
92
96
  if (process.platform === "win32") {
93
- void import("node:child_process").then(({ spawn }) => {
94
- spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
97
+ const { spawn } = await import("node:child_process");
98
+ await new Promise((resolve) => {
99
+ const killer = spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
95
100
  stdio: "ignore",
96
- detached: true,
97
101
  windowsHide: true,
98
- }).unref();
102
+ });
103
+ killer.once("error", () => resolve());
104
+ killer.once("close", () => resolve());
99
105
  });
100
106
  }
101
107
  else {
@@ -347,6 +347,12 @@ export class SchedulerStore {
347
347
  if (ok) {
348
348
  this.save();
349
349
  this.changed();
350
+ try {
351
+ this.opts.onTaskRemoved?.(id);
352
+ }
353
+ catch {
354
+ /* 回调失败不影响删除 */
355
+ }
350
356
  }
351
357
  return ok;
352
358
  }
@@ -31,21 +31,21 @@ function truncate(s, cap) {
31
31
  */
32
32
  function imageBlockToUi(b, cap = Number.POSITIVE_INFINITY) {
33
33
  const img = b;
34
+ const src = img.source;
35
+ if (typeof src?.url === "string" && src.url)
36
+ return { type: "image", dataUrl: src.url };
34
37
  if (typeof img.data === "string" && img.data.length > 0) {
35
38
  const dataUrl = `data:${img.mimeType ?? "image/png"};base64,${img.data}`;
36
39
  if (dataUrl.length > cap)
37
40
  return undefined;
38
41
  return { type: "image", dataUrl, mimeType: img.mimeType };
39
42
  }
40
- const src = img.source;
41
43
  if (src?.type === "base64" && src.data) {
42
44
  const dataUrl = `data:${src.mediaType ?? "image/png"};base64,${src.data}`;
43
45
  if (dataUrl.length > cap)
44
46
  return undefined;
45
47
  return { type: "image", dataUrl, mimeType: src.mediaType };
46
48
  }
47
- if (typeof src?.url === "string" && src.url)
48
- return { type: "image", dataUrl: src.url };
49
49
  return undefined;
50
50
  }
51
51
  function serializeUserContent(content) {
@@ -227,6 +227,16 @@ export function serializeMessage(m, seq) {
227
227
  tokensBefore: m.tokensBefore,
228
228
  };
229
229
  }
230
+ case "system": {
231
+ // SDK 的 system 消息是 prompt sections 的内部差量
232
+ // (content 空串 + sections 结构化内存)、compaction 的
233
+ // systemMessage 等——从来不面向用户。不过滤的话
234
+ // 会掉进 default 分支被序列化成 content: []
235
+ // 的空气泡、前端顶着 system 标题白显示一条
236
+ // (对话结束后底部冒出的空 SYSTEM 气泡就是它)。
237
+ // LLM 上下文不受影响——这里只决定浏览器看到什么。
238
+ return null;
239
+ }
230
240
  default:
231
241
  return {
232
242
  id: `x-${seq}`,
@@ -18,6 +18,7 @@ import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } fro
18
18
  export class SettingsService {
19
19
  host;
20
20
  templates;
21
+ approvalRules;
21
22
  settings;
22
23
  presets;
23
24
  knownSkills = new Map();
@@ -26,9 +27,12 @@ export class SettingsService {
26
27
  pendingReload = false;
27
28
  constructor(host,
28
29
  /** 全局子代理模板库(所有客户端共享;模板改动无需 reload runtime)。 */
29
- templates) {
30
+ templates,
31
+ /** 全局审批规则库(所有客户端共享;修改实时生效无需 reload runtime)。 */
32
+ approvalRules) {
30
33
  this.host = host;
31
34
  this.templates = templates;
35
+ this.approvalRules = approvalRules;
32
36
  this.settings = host.stateStore.getSettings(host.clientId);
33
37
  this.presets = host.stateStore.getPresets(host.clientId);
34
38
  }
@@ -289,8 +293,12 @@ export class SettingsService {
289
293
  terminalToolsEnabled: legacyTools.terminalToolsEnabled,
290
294
  terminalBash: this.settings.terminalBash,
291
295
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
296
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
292
297
  toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
293
298
  readDirEnabled: this.settings.readDirEnabled !== false,
299
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
300
+ approvalPolicy: this.host.getApprovalPolicy?.() ?? { allowAll: false, categories: [] },
301
+ approvalRules: this.approvalRules?.list() ?? [],
294
302
  editSoftEnabled: legacyTools.editSoftEnabled,
295
303
  questionnaireEnabled: legacyTools.questionnaireEnabled,
296
304
  parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
@@ -461,6 +469,9 @@ export class SettingsService {
461
469
  if (partial.terminalBashIdleMs !== undefined) {
462
470
  this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
463
471
  }
472
+ if (partial.terminalBashMaxForegroundMs !== undefined) {
473
+ this.settings.terminalBashMaxForegroundMs = Math.max(0, Math.floor(partial.terminalBashMaxForegroundMs) || 0);
474
+ }
464
475
  if (partial.toolWatchdogTimeoutMs !== undefined) {
465
476
  this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
466
477
  }
@@ -468,6 +479,10 @@ export class SettingsService {
468
479
  if (partial.readDirEnabled !== undefined) {
469
480
  this.settings.readDirEnabled = partial.readDirEnabled;
470
481
  }
482
+ // 工具执行审批总开关:审批入口每次实时读取(askApproval 顶部门禁),无需 reload。
483
+ if (partial.toolApprovalEnabled !== undefined) {
484
+ this.settings.toolApprovalEnabled = partial.toolApprovalEnabled;
485
+ }
471
486
  // 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
472
487
  if (partial.goalModeEnabled !== undefined) {
473
488
  this.settings.goalModeEnabled = partial.goalModeEnabled;
@@ -587,6 +602,7 @@ export class SettingsService {
587
602
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
588
603
  terminalBash: this.settings.terminalBash,
589
604
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
605
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
590
606
  editSoftEnabled: this.settings.editSoftEnabled,
591
607
  retryMaxAttempts: this.settings.retryMaxAttempts,
592
608
  softCapTokens: this.settings.softCapTokens,
@@ -636,8 +652,11 @@ export class SettingsService {
636
652
  // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
637
653
  terminalBash: p.terminalBash ?? this.settings.terminalBash,
638
654
  terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
655
+ terminalBashMaxForegroundMs: p.terminalBashMaxForegroundMs ?? this.settings.terminalBashMaxForegroundMs,
639
656
  // read 读目录是纯运行行为开关,不进预设——保留当前值。
640
657
  readDirEnabled: this.settings.readDirEnabled !== false,
658
+ // 工具审批总开关同样是纯运行开关,不进预设——保留当前值。
659
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
641
660
  // toolWatchdogTimeoutMs 是纯运行行为参数,不进预设——保留当前值。
642
661
  toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
643
662
  editSoftEnabled: presetLegacy.editSoftEnabled,
@@ -725,6 +744,94 @@ export class SettingsService {
725
744
  textEn: `Subagent template deleted: ${name}`,
726
745
  });
727
746
  }
747
+ /** Upsert 一条审批规则(全局共享)。 */
748
+ async saveApprovalRule(rule) {
749
+ if (!this.approvalRules)
750
+ return;
751
+ const err = this.approvalRules.upsert(rule);
752
+ if (err) {
753
+ this.host.emit({
754
+ type: "notice",
755
+ level: "error",
756
+ text: `审批规则保存失败:${err}`,
757
+ textEn: `Failed to save approval rule: ${err}`,
758
+ });
759
+ return;
760
+ }
761
+ this.push();
762
+ this.host.emit({
763
+ type: "notice",
764
+ level: "info",
765
+ text: `审批规则已保存:${rule.label}`,
766
+ textEn: `Approval rule saved: ${rule.labelEn || rule.label}`,
767
+ });
768
+ }
769
+ /** 批量更新审批规则列表(重排或批量保存,全局共享)。 */
770
+ async saveApprovalRules(rules) {
771
+ if (!this.approvalRules)
772
+ return;
773
+ const err = this.approvalRules.saveAll(rules);
774
+ if (err) {
775
+ this.host.emit({
776
+ type: "notice",
777
+ level: "error",
778
+ text: `审批规则列表保存失败:${err}`,
779
+ textEn: `Failed to save approval rules: ${err}`,
780
+ });
781
+ return;
782
+ }
783
+ this.push();
784
+ this.host.emit({
785
+ type: "notice",
786
+ level: "info",
787
+ text: "审批规则列表已更新",
788
+ textEn: "Approval rules updated",
789
+ });
790
+ }
791
+ /** 删除一条自定义审批规则。 */
792
+ async deleteApprovalRule(id) {
793
+ if (!this.approvalRules)
794
+ return;
795
+ const ok = this.approvalRules.remove(id);
796
+ if (!ok) {
797
+ this.host.emit({
798
+ type: "notice",
799
+ level: "error",
800
+ text: "审批规则删除失败(内置规则不可删除)",
801
+ textEn: "Failed to delete approval rule (built-in rules cannot be deleted)",
802
+ });
803
+ return;
804
+ }
805
+ this.push();
806
+ this.host.emit({
807
+ type: "notice",
808
+ level: "info",
809
+ text: "审批规则已删除",
810
+ textEn: "Approval rule deleted",
811
+ });
812
+ }
813
+ /** 恢复某条内置审批规则到系统默认设定。 */
814
+ async resetBuiltinApprovalRule(id) {
815
+ if (!this.approvalRules)
816
+ return;
817
+ const ok = this.approvalRules.resetBuiltin(id);
818
+ if (!ok) {
819
+ this.host.emit({
820
+ type: "notice",
821
+ level: "error",
822
+ text: "恢复默认失败:未找到对应内置规则",
823
+ textEn: "Failed to reset: built-in rule not found",
824
+ });
825
+ return;
826
+ }
827
+ this.push();
828
+ this.host.emit({
829
+ type: "notice",
830
+ level: "info",
831
+ text: "内置规则已恢复默认",
832
+ textEn: "Built-in rule reset to default",
833
+ });
834
+ }
728
835
  /**
729
836
  * Make settings changes effective in the running runtime. The resource-loader
730
837
  * overrides read this.settings at call time, so a reload re-applies them.
@@ -62,6 +62,7 @@ export function withSubagentOwner(host, ownerId) {
62
62
  return {
63
63
  ...host,
64
64
  spawnSubagent: (prompt, type, cwd, templateName, model, _parentId, persist) => host.spawnSubagent(prompt, type, cwd, templateName, model, ownerId, persist),
65
+ handoffSubagent: (fromRunId, toRunId, payload) => host.handoffSubagent(fromRunId || ownerId, toRunId, payload),
65
66
  };
66
67
  }
67
68
  /**
@@ -491,6 +492,46 @@ export function makeSubagentTools(host, lang, selfConvId) {
491
492
  return text(pick(getLang(), `可用的子代理模板(subagent_spawn 的 template 参数传名字,如 subagent_spawn(template="${firstTemplateName}")):\n${templateLines}`, `Available subagent templates (pass the name as subagent_spawn's template param, e.g. subagent_spawn(template="${firstTemplateName}")):\n${templateLines}`, "subagents.templates.list", { firstTemplateName: firstTemplateName, templateLines: templateLines }));
492
493
  },
493
494
  }),
495
+ defineTool({
496
+ name: "subagent_handoff",
497
+ label: "Hand off to peer subagent",
498
+ description: bilingual("Hand off task artifacts, context, or results directly to another peer subagent (peer-to-peer routing), " +
499
+ "without routing through the parent conversation. Enables direct multi-agent pipeline collaboration.", "将任务产物、上下文或分析结果直接交接给另一个同行子代理(对等消息路由)," +
500
+ "无需通过主会话中转,实现多智能体同行流水线协作。"),
501
+ promptSnippet: "hand off task artifacts or results directly to another peer subagent",
502
+ parameters: Type.Object({
503
+ toRunId: Type.String({
504
+ description: bilingual("Target peer subagent convId (from subagent_list).", "目标同行子代理 convId(通过 subagent_list 获取)。"),
505
+ }),
506
+ payload: Type.String({
507
+ description: bilingual("The artifact, analysis result, or instruction to hand off to the peer subagent.", "要交接给同行子代理的产物、分析结果或后续任务指令。"),
508
+ }),
509
+ fromRunId: Type.Optional(Type.String({
510
+ description: bilingual("Source subagent convId. Optional: defaults to the current calling subagent.", "来源子代理 convId。可选:默认自动使用当前调用的子代理。"),
511
+ })),
512
+ }),
513
+ execute: async (_id, p) => {
514
+ const fromId = p.fromRunId || selfConvId || "unknown";
515
+ if (fromId === p.toRunId) {
516
+ return text(pick(getLang(), `交接失败:不能交接给自身(${shortId(fromId)})。请指定其他同行子代理。`, `Handoff failed: cannot hand off to oneself (${shortId(fromId)}). Please specify a different peer subagent.`, "subagents.handoff.self", { id: shortId(fromId) }));
517
+ }
518
+ const target = host.getSubagent(p.toRunId);
519
+ if (!target) {
520
+ const missingId = shortId(p.toRunId);
521
+ return text(pick(getLang(), `未找到目标子代理 ${missingId}(可能已移出或不存在)。请用 subagent_list 确认可用的子代理。`, `Target subagent ${missingId} not found (may have been dismissed or non-existent). Use subagent_list to check available subagents.`, "subagents.handoff.not.found", { missingId }));
522
+ }
523
+ try {
524
+ await host.handoffSubagent(fromId, p.toRunId, p.payload);
525
+ const fromShort = shortId(fromId);
526
+ const toShort = shortId(p.toRunId);
527
+ return text(pick(getLang(), `已成功将产物交接给同行子代理 ${toShort}(${target.type}),协同路由已建立并开始执行。`, `Successfully handed off payload to peer subagent ${toShort} (${target.type}); peer routing established and executing.`, "subagents.handoff.success", { from: fromShort, to: toShort, type: target.type }), { fromRunId: fromId, toRunId: p.toRunId, timestamp: Date.now() });
528
+ }
529
+ catch (err) {
530
+ const msg = err instanceof Error ? err.message : String(err);
531
+ return text(pick(getLang(), `交接失败:${msg}`, `Handoff failed: ${msg}`, "subagents.handoff.failed", { error: msg }));
532
+ }
533
+ },
534
+ }),
494
535
  ];
495
536
  }
496
537
  /** 短 id 前缀(前端展示/日志用)。 */
@@ -1583,6 +1583,8 @@ export function makeTerminalBashTool(terminals, opts) {
1583
1583
  void terminals.inputChecked(termId, "exit\r");
1584
1584
  };
1585
1585
  const idleMs = Math.max(0, opts.idleMs());
1586
+ const maxForegroundMs = Math.max(0, opts.maxForegroundMs?.() ?? 0);
1587
+ const startTime = Date.now();
1586
1588
  const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
1587
1589
  // 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat`):
1588
1590
  // 这类管道在终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;②吞掉真实退出码——
@@ -1656,7 +1658,11 @@ export function makeTerminalBashTool(terminals, opts) {
1656
1658
  }
1657
1659
  // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1658
1660
  if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1659
- return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang);
1661
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang, termId, persist, "idle");
1662
+ }
1663
+ // 总时长解阻(持久与一次性终端):前台运行达到上限,转入后台继续执行,避免卡死。
1664
+ if (maxForegroundMs > 0 && Date.now() - startTime >= maxForegroundMs) {
1665
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - startTime) / 1000), lang, termId, persist, "elapsed");
1660
1666
  }
1661
1667
  }
1662
1668
  }
@@ -1666,36 +1672,85 @@ export function makeTerminalBashTool(terminals, opts) {
1666
1672
  },
1667
1673
  });
1668
1674
  }
1669
- /** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1670
- function backgroundResult(terminals, opts, command, partialText, silentSeconds, lang) {
1671
- terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
1675
+ /** 静默 / 总时长解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1676
+ function backgroundResult(terminals, opts, command, partialText, durationSeconds, lang, termId = "ai-bash", persist = true, reason = "idle") {
1677
+ terminals.watchOutput(termId, BASH_SENTINEL_RE, (m) => {
1672
1678
  // 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
1673
- terminals.setSentinelPending("ai-bash", false);
1679
+ terminals.setSentinelPending(termId, false);
1674
1680
  opts.notifyBackgroundDone({
1675
- terminalId: "ai-bash",
1681
+ terminalId: termId,
1676
1682
  command,
1677
1683
  exitCode: m ? Number(m[1]) : null,
1678
1684
  });
1685
+ if (!persist) {
1686
+ void terminals.inputChecked(termId, "exit\r");
1687
+ }
1679
1688
  });
1680
1689
  // partialText 已在调用方做过 cleanBashOutput + applyTail。
1681
1690
  const partial = truncateMiddle(partialText, 6000);
1682
1691
  // 空输出占位按语言预渲染(issue #91 v2:vars 只收干净标识)。
1683
1692
  const partialZh = partial || "(暂无输出)";
1684
1693
  const partialEn = partial || "(no output yet)";
1694
+ let descZh = "";
1695
+ let descEn = "";
1696
+ if (reason === "elapsed") {
1697
+ descZh =
1698
+ `命令仍在终端 ${termId} 中运行(前台执行已达 ${durationSeconds} 秒上限,自动转入后台)。` +
1699
+ `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1700
+ `已有输出:\n${partialZh}\n`;
1701
+ descEn =
1702
+ `Command still running in terminal ${termId} (foreground execution reached ${durationSeconds}s limit, moved to background). ` +
1703
+ `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1704
+ `Partial output:\n${partialEn}\n`;
1705
+ }
1706
+ else {
1707
+ descZh =
1708
+ `命令仍在持久终端 ${termId} 中运行(已连续 ${durationSeconds} 秒无输出,未结束)。` +
1709
+ `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1710
+ `已有输出:\n${partialZh}\n`;
1711
+ descEn =
1712
+ `Command still running in the persistent terminal ${termId} (no output for ${durationSeconds}s, not finished). ` +
1713
+ `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1714
+ `Partial output:\n${partialEn}\n`;
1715
+ }
1716
+ if (persist) {
1717
+ descZh += `要重新阻塞等它结束就用 terminal_wait(terminalId="${termId}")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`;
1718
+ descEn += `To block until it finishes, use terminal_wait(terminalId="${termId}") (no polling needed); use terminal_input / terminal_key to interact (Ctrl+C aborts).`;
1719
+ }
1720
+ else {
1721
+ descZh += `命令完成后该终端将自动退出并发送通知;如需查看输出可用 terminal_read(terminalId="${termId}")。`;
1722
+ descEn += `When completed, this terminal will exit automatically and notify; to inspect output, use terminal_read(terminalId="${termId}").`;
1723
+ }
1724
+ if (reason === "elapsed") {
1725
+ return {
1726
+ content: [
1727
+ {
1728
+ type: "text",
1729
+ text: pick(lang, descZh, descEn, "terminals.bash.background.elapsed", {
1730
+ durationSeconds,
1731
+ partialZh,
1732
+ partialEn,
1733
+ termId,
1734
+ }),
1735
+ },
1736
+ ],
1737
+ details: { running: true, terminalId: termId, persist, durationSeconds, reason },
1738
+ };
1739
+ }
1685
1740
  return {
1686
1741
  content: [
1687
1742
  {
1688
1743
  type: "text",
1689
- text: pick(lang, `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1690
- `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1691
- `已有输出:\n${partialZh}\n` +
1692
- `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`, `Command still running in the persistent terminal ai-bash (no output for ${silentSeconds}s, not finished). ` +
1693
- `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1694
- `Partial output:\n${partialEn}\n` +
1695
- `To block until it finishes, use terminal_wait(terminalId="ai-bash") (no polling needed); use terminal_input / terminal_key to interact (Ctrl+C aborts).`, "terminals.bash.background.running", { silentSeconds, partialZh, partialEn }),
1744
+ text: pick(lang, descZh, descEn, "terminals.bash.background.running", {
1745
+ silentSeconds: durationSeconds,
1746
+ durationSeconds,
1747
+ partialZh,
1748
+ partialEn,
1749
+ termId,
1750
+ }),
1696
1751
  },
1697
1752
  ],
1698
- details: { running: true, terminalId: "ai-bash", silentSeconds },
1753
+ details: { running: true, terminalId: termId, persist, durationSeconds, reason },
1699
1754
  };
1700
1755
  }
1701
1756
  /** Agent 持久终端工具名(唯一登记见 tool-manager.ts;此处 re-export 保兼容)。 */
@@ -0,0 +1,84 @@
1
+ /**
2
+ * server/tool-approval.ts
3
+ *
4
+ * 人机协同拦截与「改写执行」(Human-in-the-Loop: Edit & Run)。
5
+ *
6
+ * 功能:
7
+ * 1. 高危操作识别与规则裁决(Dangerous Tool Call & Rule Evaluation):
8
+ * - 委托 server/approval-rules.ts 的规则引擎评估
9
+ * - 支持三种动作:ask(弹窗审批)、deny(直接拒绝报错)、allow(白名单放行)
10
+ * 2. 审批事件挂起与答复处理(askApproval / resolveApproval):
11
+ * - 用户可「批准」(Approve)
12
+ * - 用户可「拒绝」(Deny)
13
+ * - 用户可就地修改参数后「修改并放行」(Edit & Run)
14
+ * 3. 三档放行策略(省去反复弹窗):
15
+ * - 全局关(设置 →「工具」页的「工具执行审批」总开关)→ 一律不弹;
16
+ * - 本对话「全部允许」→ 该对话内一律不弹;
17
+ * - 本对话「允许同类」→ 该对话内同一规则档位(category)不再弹。
18
+ * 策略只活在内存里(挂在 Conversation 上,过户随对话搬走),重启/新对话即恢复询问。
19
+ */
20
+ import { DEFAULT_APPROVAL_RULES, evaluateApprovalRules } from "./approval-rules.js";
21
+ /** 策略为空(既没全部允许也没记同类):调用方据此不挂空策略、面板据此不显示撤销区。 */
22
+ export function isApprovalPolicyEmpty(policy) {
23
+ return !policy || (!policy.allowAll && policy.categories.size === 0);
24
+ }
25
+ /**
26
+ * 三档放行的纯判定:返回 null = 需要弹审批,否则返回放行的原因。
27
+ * - `disabled`:全局开关关(设置 →「工具」页)→ 一切审批都不弹(插件显式 ask 也放行);
28
+ * - `allow-all`:本对话已「全部允许」;
29
+ * - `category`:本对话已记住该同类档位。
30
+ */
31
+ export function approvalSuppressionReason(policy, enabled, categoryId) {
32
+ if (!enabled)
33
+ return "disabled";
34
+ if (!policy)
35
+ return null;
36
+ if (policy.allowAll)
37
+ return "allow-all";
38
+ if (categoryId && policy.categories.has(categoryId))
39
+ return "category";
40
+ return null;
41
+ }
42
+ /** 插件显式要求确认(pre guard 的 ask)对应的档位:按插件 id 分档,可「允许同类」。 */
43
+ export function pluginApprovalCategory(pluginId) {
44
+ return {
45
+ id: `plugin:${pluginId}`,
46
+ label: `插件 ${pluginId} 要求确认`,
47
+ labelEn: `Plugin ${pluginId} requested confirmation`,
48
+ };
49
+ }
50
+ /**
51
+ * 检测某个工具调用是否属于高危操作,或命中用户自定义规则。
52
+ * 可选接收 rules 规则集;缺省时以内置默认高危规则集判定。
53
+ */
54
+ export function checkDangerousToolCall(toolName, params, cwd, workspaceRoots = [], rules) {
55
+ if (!params || typeof params !== "object") {
56
+ return { dangerous: false };
57
+ }
58
+ const res = evaluateApprovalRules(rules ?? DEFAULT_APPROVAL_RULES, toolName, params, cwd, workspaceRoots);
59
+ if (res.action === "deny") {
60
+ return {
61
+ dangerous: true,
62
+ denied: true,
63
+ reason: res.reason,
64
+ reasonEn: res.reasonEn,
65
+ category: res.category,
66
+ };
67
+ }
68
+ if (res.action === "allow") {
69
+ return {
70
+ dangerous: false,
71
+ allowed: true,
72
+ };
73
+ }
74
+ if (res.action === "ask") {
75
+ return {
76
+ dangerous: true,
77
+ denied: false,
78
+ reason: res.reason,
79
+ reasonEn: res.reasonEn,
80
+ category: res.category,
81
+ };
82
+ }
83
+ return { dangerous: false };
84
+ }