pi-web-ui 0.95.0 → 0.96.1

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 (75) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +331 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-installer.js +30 -0
  38. package/dist/server/plugin-llm.js +71 -65
  39. package/dist/server/plugin-project.js +70 -2
  40. package/dist/server/plugin-updater.js +115 -12
  41. package/dist/server/plugins.js +205 -44
  42. package/dist/server/present-files-tool.js +9 -12
  43. package/dist/server/process-utils.js +4 -0
  44. package/dist/server/prompt-composer.js +9 -0
  45. package/dist/server/protocol-version.js +1 -1
  46. package/dist/server/read-tool.js +69 -30
  47. package/dist/server/resolve-global-sdk.js +30 -16
  48. package/dist/server/schedule-agent-tool.js +12 -15
  49. package/dist/server/sdk-origin.js +18 -2
  50. package/dist/server/serialize.js +108 -21
  51. package/dist/server/settings-service.js +4 -2
  52. package/dist/server/skill-tool.js +5 -7
  53. package/dist/server/subagent-templates.js +51 -0
  54. package/dist/server/subagents.js +48 -72
  55. package/dist/server/terminals.js +181 -69
  56. package/dist/server/tool-manager.js +24 -7
  57. package/dist/server/tool-overrides.js +59 -0
  58. package/dist/server/update-check.js +28 -1
  59. package/dist/server/uploads.js +17 -2
  60. package/dist/server/wait-subscription-scan.js +18 -21
  61. package/dist/server/ws-client-id.js +29 -0
  62. package/dist/server/ws-pending-queue.js +60 -0
  63. package/extensions/webui.ts +60 -2
  64. package/package.json +4 -2
  65. package/plugins/catalog.json +20 -0
  66. package/themes/aetheris.css +457 -0
  67. package/themes/zhupi-dark.css +718 -0
  68. package/themes/zhupi.css +771 -0
  69. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
  70. package/web/dist/assets/index-B7Zp23Vc.css +1 -0
  71. package/web/dist/assets/index-ByfoaDwp.js +366 -0
  72. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  73. package/web/dist/index.html +3 -3
  74. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  75. package/web/dist/assets/index-RpIGFyi_.css +0 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,61 @@
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.96.1] — 2026-09-26
14
+
15
+ ### Fixed
16
+
17
+ - **插件安装审批弹窗层级提升与进度反馈补齐(#382)** —— 将能力与目录访问审批弹窗提升至高于设置弹窗的顶层浮层(`z-index: 350`),无需关闭设置页;修复「记住并允许」未持久化问题,授权写入 `plugin-permissions.json` 并在再次安装时直接放行;点击安装即刻反馈等待确认/执行中状态,已安装列表补挂进度条,关闭弹窗后主界面提供全局进度指示。
18
+ - **顶栏全部居中对齐时偏右修复** —— 修复当顶栏条目全部设为居中时,因右侧缺失占位弹簧(`tb-spacer`)导致条目被左侧单个弹簧推向最右侧的问题,确保中间条目精准水平居中。
19
+ - **设置保存回执时序修复** —— 确保 `set_settings` 在触发 `needsReload` 重启前先行持久化并推送状态回执。
20
+
21
+ ### Changed
22
+
23
+ - **朱批与朱批·夜主题界面精细化** —— 顶栏控件全面素色化(透明底、无边框,悬停现 hairline);消息折叠条内边距清零,折叠指示三角光学对齐左侧墨线;输入框边框微调,发送/停止按钮微调为圆形;输入框底部模型选择、思考强度、预设等按钮去边框透明底,悬停显示细边框。
24
+ - **输入框与顶栏控件排版微调** —— 顶栏 chip 统一幽灵化;输入框底部模型与思考强度 chip 尺寸微调收紧;思考强度 Chip 文案精简为 `{level}`,保留 tooltip 说明;预设选择器图标优化为 `FiSliders`。
25
+
26
+ <!-- auto-i18n:start -->
27
+ ### i18n
28
+
29
+ - 前端中文变更(1):`thinkingChip`
30
+ - 前端英文变更(1):`thinkingChip`
31
+ <!-- auto-i18n:end -->
32
+
33
+ ## [0.96.0] — 2026-09-26
34
+
35
+ ### Added
36
+
37
+ - **内置 LSP 工具新增四个语义动作(#331 Phase 1)** —— `documentSymbol`(分层符号大纲,带行跨度与 300 条防洪截断)、`read_symbol`(按符号名/点分路径精准读取实现体,双遍扫描精确匹配优先、400 行截断、未命中时自愈提示可用符号)、`workspaceSymbol`(工作区全局符号搜索,100 条上限,可不传 `path` 自动探测主文件路由语言服务)、`cascade`(编辑影响级联:查引用方文件并聚合其编译诊断,改坏签名当轮即暴露)。提示词开销保持 ~350 tokens;设置页工具说明中英文同步。
38
+ - **系统提示词与工具 schema token 占用实时估算** —— 设置面板「系统提示词」支持直接查看当前会话实际生效的完整系统提示词与工具 schema 定义,并展示粗略 token 估算值与上下文总占用;工作区与活跃会话切换时主动同步最新设置快照,避免提示词上下文陈旧。
39
+ - **核心内置工具独立开关与运行时门控** —— 设置页新增「核心工具」配置区,支持独立开启/禁用 `bash`、`read`、`edit`、`write` 原生核心工具;工具禁用状态随会话即时生效,并在系统提示词与 tools schema 中同步过滤与剔除,杜绝无效工具调用。
40
+ - **新增内置主题** —— 新增三款内置现代与古典主题:赛博科幻暗色 HUD 风格「以太座舱 (Aetheris HUD)」、古籍朱印浅色风「朱批 (Vermilion Manuscript)」与深色夜读风「朱批·夜 (Vermilion Night)」。
41
+ - **消息气泡复制菜单与直接重问增强** —— 消息底栏复制按钮升级为下拉菜单,支持一键复制 Markdown、纯文本或生成长图 PNG;新增「直接重问」快捷按钮,无需重新聚焦编辑框即可立即复用上一条指令;消息气泡增加右键上下文菜单,支持快速复制、编辑/直接重问、派生分支、回滚到此与语音朗读。
42
+ - **TTS 语音朗读与流式生成提示音** —— 助手消息气泡支持单条 TTS 语音朗读与停止;新增流式开始与完成提示音,后台生成完毕及时提醒。
43
+
44
+ ### Fixed
45
+
46
+ - **非全功能预设(如代码开发/极简/纯对话)下提示词泄露已禁用工具指南修复** —— `before_agent_start` 提示词组装现在严格按会话当前真正处于活跃状态的工具集合(`activeTools`)过滤 Guidelines 与 Snippets,防止未启用的工具(如 `plan_update`、`ask_user_question`、`subagent_*` 等)的提示词指南被无差别注入到给模型的系统提示词中;彻底解决在「代码开发」预设下 AI 因收到动代码前调用 `plan_update` 的硬性指令而误调用未激活工具导致报错无效的问题;会话创建(`makeRuntimeFactory`)与重载(`reloadSession`)全生命周期对齐当前会话预设门控。
47
+ - **助手气泡 Fork / 回滚无法解析修复(#381)** —— 统一 `server/serialize.ts` 的 `uiMessageId` 与 `findEntryByUiId` 算法为单一事实源,彻底修复从助手气泡触发分支派生(Fork)或回滚到此消息时因 ID 生成算法漂移导致的解析失败问题。
48
+ - **悬空工具调用修复收紧分支与上线检查(#332)** —— `healDanglingToolCallFile` 与 `findDanglingToolCalls` 现在严格过滤 `stopReason` 为 `error` 或 `aborted` 的 assistant(不上线幽灵调用,避免在文件尾补合成结果构造出孤儿 `role: "tool"` 导致 DeepSeek/OpenAI 400 报错),且文件落盘修复仅沿活跃分支 `lastId` 向上回溯当前尾部生效 assistant,彻底跳过老分支遗留的悬空调用,防止跨分支污染。
49
+ - **微信通道插件问题修复(#345)** —— 微信回包自动剥离内部控制标记(如 `[[plan:...]]`、`[[todo:...]]`、`[[conv:...]]`、`[[notify:...]]` 等),仅向微信发送用户可见正文;按微信用户 ID 隔离 `accountId`(`wx_${hash}`),为每个用户分配独立的伪客户端与会话,防止上下文串扰与关闭冲突;增加 `earlyRuns` 机制妥善承接极快完成的运行事件,消除回包竞态。
50
+ - **SoL-Pi 节能看板弹窗展示优化** —— 扩展运行与配置区域默认采用折叠组件收拢,避免未折叠时挤占弹窗主视区。
51
+
52
+ ### Changed
53
+
54
+ - **输入框 `@` 提及支持技能(skills)自动补全** —— 在消息文本任意位置键入 `@` 或 `@skill:` 即可弹出技能候选列表(名称匹配优先于描述匹配,支持中英文双语描述检索与 `@page` 页面置顶防挤占);点选后自动在光标处插入 `@skill:<name>` 词元,与 Pi 运行时的技能提升扩展无缝联动。
55
+ - **收敛型澄清提问与决策就绪型计划规范(#330)** —— `ask_user_question` 现在单次严格限制 1~3 个问题(优先 1 个,超过 3 个直接报错阻断,防止问卷轰炸),选项 schema 收紧为 2~4 个互斥选项且推荐方案置顶,选项 description 要求一句话说明影响与权衡;`plan_update` 提示词升级为「决策就绪型」规划:动代码前先在步骤中落实排查发现(Discovery)、受影响文件清单(File Touch List)与风险回滚预案(Rollback),并随执行实时流转步骤状态。目标向导(`goal_ask` / wizardPrompt)与 DSH 澄清提示词同步对齐收敛型交互。
56
+
57
+ <!-- auto-i18n:start -->
58
+ ### i18n
59
+
60
+ - 前端新增 key(49):`reaskDirectly`、`reaskDirectlyTip`、`kindPlugin`、`pluginCheckUpdates`、`pluginCheckUpdatesHint`、`pluginUpdateAvailableBadge`、`pluginUpdateAvailableDetail`、`pluginAllUpToDate`、`piCoreSplitRun`、`piSdkSplitNote`、`piSdkBundledNote`、`installGlobalEngineBtn`、`installGlobalEngineTabTitle`、`saveResultUnknown`、`questionNoneAvailable`、`notifyApprovalTitle`、`notifyApprovalBody`、`notifyApprovalBodyTool`、`sound.approval`、`sound.approval.desc`、`settingsSoundVoice`、`ttsHeader`、`ttsEnable`、`ttsEnableDesc`、`ttsAnnounce`、`ttsAnnounceDesc`、`ttsReadReplies`、`ttsReadRepliesDesc`、`ttsRate`、`ttsVoice`、`ttsVoiceAuto`、`ttsVoiceOnline`、`ttsUnavailable`、`ttsPreviewLine`、`ttsAnnounceDone`、`ttsAnnounceQuestion`、`ttsAnnounceError`、`ttsAnnounceApproval`、`speakMsg`、`stopSpeakingMsg`、`apiKeySavedHint`、`settingsViewPromptTokens`、`settingsPromptContextTotal`、`toolsSectionCore`、`toolsCoreHint`、`toolCoreBashDesc`、`toolCoreReadDesc`、`toolCoreEditDesc`、`toolCoreWriteDesc`
61
+ - 前端中文变更(2):`settingsViewToolsSchema`、`lspToolEnabledDesc`
62
+ - 前端英文变更(2):`settingsViewToolsSchema`、`lspToolEnabledDesc`
63
+ - 服务端新增 key(3):`plugincatalog.sync.doc.invalid`、`terminals.bash.nosentinel.note`、`terminals.command.blocked`
64
+ - 服务端文案变更(1):`terminals.bash.timeout`
65
+ - 服务端删除 key(2):`plugincatalog.sync.source.invalid`、`plugincatalog.sync.read.failed`
66
+ <!-- auto-i18n:end -->
67
+
13
68
  ## [0.95.0] — 2026-09-24
14
69
 
15
70
  ### Added
@@ -30,9 +85,11 @@
30
85
  - **受控的持久代码求值沙箱工具 `eval`(opt-in,默认关)** — 新第一方 customTool:在隔离子进程中执行 Python(`py`)或 JavaScript/TypeScript(`js`/`ts`),变量与导入跨调用保持,顶层表达式自动求值回显(省掉以往 `write` 临时脚本 → `bash` 跑 → 删文件的三步流程)。设计要点:① 默认关(`AGENT_TOOL_CATALOG` 里 `defaultOn: false`),关掉 AI 不知道有它,杜绝「什么问题都塞进内核」的工具挤占;② 每个会话一个独立内核进程 + 独立临时目录(cwd 不落在项目里,项目路径经 `PROJECT_DIR` 变量显式引用),关对话 / 停服务即回收进程树(Windows `taskkill /F /T`,Unix `SIGKILL` 进程组),不留孤儿;③ 单请求默认 15s、上限 120s 硬超时,超时杀进程树后内核自动重启,不会把会话拖死;④ 驱动协议串行排队,并行 `eval` 调用不会互相覆盖 resolver;⑤ stderr 持续排空,避免原生扩展写满管道缓冲造成假超时。DSH 引擎无 customTool 注册面,不接。
31
86
 
32
87
  ### Changed
88
+
33
89
  - **插件 manifest 校验失败即拒(P1-6)** — 坏 manifest 不再带病启动:`id` 非法/与目录名不一致、`apiVersion` 非法、未知能力拼写、`engines`/`permissions`/`ui` 坏形状、v2 不声明 `permissions`、有 `ui` 声明却无 `ui` 能力,都会拒绝激活(scan 置红 + 诊断随清单下发,activate 重判)。以前其中两类(v2 无能力声明、只有别的能力却写 `ui`)是"激活成功但 ui 静默忽略",现在是明确拒绝;纯警告(坏文本字段/坏数组字段/截断)仍不阻断。未来版本(`apiVersion` 大于宿主)仍走版本门出"请升级",校验层不抢错。
34
90
 
35
91
  ### Fixed
92
+
36
93
  - **目标调研向导:切会话不再丢弃调研成果,且消息流里有「原始目标草案」卡片(#292)** — 两处修复:① `setGoal` 新增 `targetConvId`,调研收敛后把目标写到**发起调研的那个对话**(原来是硬读 `activeConv` 再一刀切丢:「已切换对话,目标调研结果已丢弃」——用户在向导问答期间切去看代码/文档是常态,多轮问答瞬间白做);发起会话已关闭时响亮拒绝而不是静默丢。完成通知随之改成点名会话(`🎯 会话「title」目标调研完成,目标已设为…`),取消/无结果时也告诉用户草案还在哪儿。② 调研开始先往发起对话推一张只读「🎯 原始目标草案」卡片,排在所有提问之前——调研被超时/取消打断后,用户至少能把自己最初写的那段需求读回来、复制重试,而不是面对一片空白。
37
94
  - **子代理(in-memory 会话)的扩展错误不再按轮数刷屏(#298)** — `SessionManager.inMemory(cwd)` 建出来的子代理会话取不到会话目录(`getSessionDir()` 返回空串),而 SDK 的 `ExtensionRunner.emitContext()` 在**每次 provider 请求**前都会跑一遍扩展的 `context` hook,于是「会话目录依赖型」扩展(如 SoL-Pi 的 `runtimeRoot()`)每轮都抛同一个错。原 `bindExtensions` 的 `onError` 把错误原样广播成 notice:不去重、不带会话归属、不落服务端日志,一个子代理跑 N 轮就弹 N 条,且看不出是哪个会话出的问题。现改为共享的 `makeExtensionErrorReporter()`:① 同一会话内「扩展 + 事件 + 错误文本」只提示一次;② 子代理的 notice 带 `子代理 <conversationId>:` 前缀(与同函数内其它子代理通知口径一致);③ 全量错误(含 extensionPath / event / stack)始终 `console.error` 落服务端日志。主对话(持久会话)行为不变,只是多了去重与日志。
38
95
  - **插件市场仅同步列表时保留已激活插件实例(#296,感谢 @StarryJia)** — 启动预同步和手动目录同步不再重载插件,避免重新激活时重复广播当前工作目录。
@@ -45,6 +102,7 @@
45
102
  - **任务执行看板与对话列等宽** — Plan Mode 的任务执行看板(`PlanBoard`)此前写死左右各 16px 外边距,没走 `.main` 的列 token(`--chat-inset`):桌面 / 宽屏聊天列下比消息列与输入框宽一截、手机上又比它们窄一点,左右边缘始终对不齐。现改为同一条列 token(`.plan-board` 落进 `styles.css`,与 `.goalbar` / 问卷面板同口径),任何视口宽度与「宽屏聊天列」开关下都与输入框严格齐平。回归:`tests/chat-column-align-test.mjs` 新增看板条目。
46
103
 
47
104
  <!-- auto-i18n:start -->
105
+
48
106
  ### i18n
49
107
 
50
108
  - 前端新增 key(104):`newChatEphemeral`、`newChatEphemeralTip`、`ephemeralBadge`、`ephemeralBannerText`、`elsewhereActions`、`takeoverConfirm`、`saveEphemeral`、`forkSession`、`forkSessionTip`、`rollbackSession`、`rollbackSessionTip`、`rollbackConfirm`、`rollbackRestoreWorkspace`、`rollbackRestoreWorkspaceTip`、`toolApprovalTitle`、`toolApprovalApprove`、`toolApprovalDeny`、`toolApprovalEditAndRun`、`toolApprovalRiskAlert`、`toolApprovalCommand`、`toolApprovalParams`、`toolApprovalEditPlaceholder`、`toolApprovalReason`、`toolApprovalCategory`、`toolApprovalAllowCategory`、`toolApprovalAllowCategoryHint`、`toolApprovalAllowConversation`、`toolApprovalAllowConversationHint`、`toolApprovalEnabled`、`toolApprovalEnabledDesc`、`toolApprovalPolicyTitle`、`toolApprovalPolicyAllowAll`、`toolApprovalPolicyRevoke`、`toolApprovalPolicyHint`、`settingsApprovalRules`、`settingsApprovalRulesDesc`、`manageApprovalRules`、`approvalRuleNew`、`approvalRuleEdit`、`approvalRuleDelete`、`approvalRuleReset`、`approvalRuleResetConfirm`、`approvalRuleDeleteConfirm`、`approvalRuleActionAsk`、`approvalRuleActionDeny`、`approvalRuleActionAllow`、`approvalRuleTools`、`approvalRuleToolsTip`、`approvalRuleField`、`approvalRuleFieldCommand`、`approvalRuleFieldPath`、`approvalRuleFieldParams`、`approvalRuleMatch`、`approvalRuleMatchRegex`、`approvalRuleMatchGlob`、`approvalRuleMatchContains`、`approvalRuleMatchPrefix`、`approvalRuleMatchOutsideWs`、`approvalRuleValue`、`approvalRuleValueTip`、`approvalRuleLabel`、`approvalRuleLabelEn`、`approvalRuleReason`、`approvalRuleReasonEn`、`approvalRuleEnabled`、`approvalRuleBuiltin`、`approvalRuleEmpty`、`approvalRuleMoveUp`、`approvalRuleMoveDown`、`planBoardTitle`、`planBoardSteps`、`planBoardProgress`、`planBoardNoPlan`、`planBoardCompleted`、`planBoardInProgress`、`planBoardPending`、`planBoardFailed`、`clear`、`confirm`、`forkBadge`、`forkBadgeTip`、`goalBarBlocked`、`toolsPresetBanner`、`toolsBackToStandard`、`toolsBlockedByPreset`、`skillsHiddenByPreset`、`terminalBashMaxForegroundMs`、`terminalBashMaxForegroundMsDesc`、`pluginInspectAlreadyInstalled`、`pluginInspectInvalid`、`pluginInspectNotPlugin`、`pluginInspectNetwork`、`uiLayoutDiagTitle`、`uiLayoutDiagHint`、`planUpdateEnabledDesc`、`planUpdateOffHint`、`compactContextEnabledDesc`、`compactContextOffHint`、`evalEnabledDesc`、`evalOffHint`、`patchToolEnabledDesc`、`patchToolOffHint`、`lspToolEnabledDesc`、`lspToolOffHint`
@@ -54,6 +112,7 @@
54
112
  - 服务端新增 key(14):`editsoft.fragment.not.supported`、`goal.wizard.draft.card`、`goal.autonomous.pass`、`goal.review.blocked`、`goal.autonomous.continue`、`goal.review.blocked_msg`、`plugins.requires.cycle`、`plugins.manifest.invalid`、`plugins.requires.cascade`、`subagents.handoff.self`、`subagents.handoff.not.found`、`subagents.handoff.success`、`subagents.handoff.failed`、`terminals.bash.background.elapsed`
55
113
  - 服务端文案变更(1):`terminals.bash.background.running`
56
114
  - 服务端删除 key(2):`dsh.attach.file.large`、`dsh.attach.file.ref.fallback`
115
+
57
116
  <!-- auto-i18n:end -->
58
117
 
59
118
  ## [0.94.1] — 2026-09-22
@@ -1254,7 +1313,10 @@ when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主
1254
1313
  - 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
1255
1314
  - 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
1256
1315
 
1257
- [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.93.0...main
1316
+ [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.96.1...main
1317
+ [0.96.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.96.1
1318
+ [0.96.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.96.0
1319
+ [0.95.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.95.0
1258
1320
  [0.94.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.94.1
1259
1321
  [0.94.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.94.0
1260
1322
  [0.93.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.93.0
package/bin/pi-web-ui.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  import { spawn, spawnSync } from "node:child_process";
28
28
  import { createConnection } from "node:net";
29
29
  import { get as httpGet } from "node:http";
30
+ import { createInterface } from "node:readline/promises";
30
31
  import {
31
32
  ensureBackup as ensurePluginBackup,
32
33
  restoreBackup as restorePluginBackup,
@@ -55,9 +56,9 @@ import { fileURLToPath } from "node:url";
55
56
  const BIN_DIR = dirname(fileURLToPath(import.meta.url));
56
57
  /** <pkg>/dist/server/index.js — the actual server entry. */
57
58
  const SERVER_ENTRY = join(BIN_DIR, "..", "dist", "server", "index.js");
58
- /** 可选的「优先用全局/祖先那份 pi SDK」解析钩子(issue #260,`PI_WEB_SDK=global` 才生效)。
59
+ /** 「机器上有更新的 pi 副本就跟随它」解析钩子(issue #260;#321 起默认启用)。
59
60
  * 必须早于任何 SDK 静态 import 加载 —— 所以每条启动路径都把它当 `--import` 传进去;
60
- * 默认不启用时它自己什么都不做(见 server/resolve-global-sdk.ts)。 */
61
+ * PI_WEB_SDK=bundled 显式钉死自带副本(见 server/resolve-global-sdk.ts)。 */
61
62
  const SDK_HOOK = join(BIN_DIR, "..", "dist", "server", "resolve-global-sdk.js");
62
63
  /** dist 可能是旧构建(没有这个钩子文件)—— 只有文件在才注入:`--import <missing>` 会让
63
64
  * CLI/服务直接起不来,那比少个开关严重得多。 */
@@ -108,8 +109,9 @@ server 选项:
108
109
  --engine <pi|dsh> 智能体引擎(默认 $PI_WEB_ENGINE 或 pi)
109
110
  --host <addr> 监听地址(默认 $PI_WEB_HOST 或 127.0.0.1;0.0.0.0 供局域网/容器)
110
111
  --agent-dir <dir> pi 配置目录(默认 $PI_CODING_AGENT_DIR 或 ~/.pi/agent)
111
- --name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
112
- 为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
112
+ --name <name> 服务名(默认 pi-web-ui;仅限字母/数字/-/_,字母或数字开头,
113
+ 最长 64 字符。macOS 的 launchd label 为 com.xingshuyin.pi-web-ui,
114
+ 自定义名时为 com.<name>.server)
113
115
  --print 只打印将生成的配置文件,不实际安装
114
116
 
115
117
  平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 登录自启 Run 键
@@ -128,7 +130,8 @@ server 选项:
128
130
  install 选项: --name <id> 自定义插件目录名(默认取仓库名)
129
131
  --data-dir <dir> 数据目录(默认 ~/.pi-web)
130
132
  --force 目标已存在时覆盖
131
- --build 强制源码构建(隔离目录编译,见下)
133
+ --build 强制源码构建(隔离目录编译,见下;不加 --build 时若插件只有
134
+ 源码,交互终端会询问是否构建并需输入 y 确认,非交互环境跳过)
132
135
  --no-build 即使只有源码也不构建(产物缺失的插件装上后不加载)
133
136
  --catalog <url> 目录同步模式:读目录文档 → 写可安装列表 → 逐条安装
134
137
  --replace 配合 --catalog:整体替换列表(默认按 id 合并)
@@ -161,7 +164,8 @@ Server options:
161
164
  --engine <pi|dsh> Agent engine (default $PI_WEB_ENGINE or pi)
162
165
  --host <addr> Listen address (default $PI_WEB_HOST or 127.0.0.1; 0.0.0.0 for LAN/containers)
163
166
  --agent-dir <dir> pi config directory (default $PI_CODING_AGENT_DIR or ~/.pi/agent)
164
- --name <name> Service name (default pi-web-ui; macOS launchd label is
167
+ --name <name> Service name (default pi-web-ui; alphanumeric, "-" and "_" only,
168
+ starting with a letter or digit, max 64 chars. macOS launchd label is
165
169
  com.xingshuyin.pi-web-ui, or com.<name>.server for custom names)
166
170
  --print Only print generated config files (no actual install)
167
171
 
@@ -181,7 +185,9 @@ UI plugins (installed into <data-dir>/plugins/; refresh browser to activate whil
181
185
  install options: --name <id> Custom plugin directory name (default: repo name)
182
186
  --data-dir <dir> Data directory (default: ~/.pi-web)
183
187
  --force Overwrite if target already exists
184
- --build Force a source build (isolated build, see below)
188
+ --build Force a source build (isolated build, see below). Without it,
189
+ a source-only plugin asks for confirmation on a TTY (answer y to
190
+ build) and skips the build in non-interactive environments
185
191
  --no-build Never build, even for source-only plugins
186
192
  --catalog <url> Catalog mode: read a catalog document, write the
187
193
  installable list, then install every entry
@@ -519,8 +525,16 @@ function winIcoPath() {
519
525
  return join(winServiceDir(), APP_ICO_NAME);
520
526
  }
521
527
 
522
- /** Full path to Windows PowerShell 5.1. */
528
+ /** Full path to Windows PowerShell. Prefers pwsh.exe (PowerShell 7) when it is
529
+ * resolvable on PATH: some machines ship Windows PowerShell 5.1 as a broken
530
+ * stub whose launch fails silently, so probe for a working shell first and
531
+ * fall back to the built-in path. */
523
532
  function winPowershell() {
533
+ const which = spawnSync("where.exe", ["pwsh.exe"], { encoding: "utf8" });
534
+ if (which.status === 0) {
535
+ const first = (which.stdout ?? "").trim().split(/\r?\n/)[0];
536
+ if (first) return first;
537
+ }
524
538
  return join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
525
539
  }
526
540
 
@@ -535,12 +549,13 @@ function winWscript() {
535
549
  * Resolve the real node binary. fnm/volta/nvm shims (e.g. fnm_multishells)
536
550
  * point into temp dirs that vanish when the installing shell exits — the
537
551
  * baked-in launcher scripts must use the stable real path instead.
552
+ * Also applies resolveNode() so Bun-hosted pi never bakes in pi.exe.
538
553
  */
539
554
  function realNode() {
540
555
  try {
541
- return realpathSync(process.execPath);
556
+ return realpathSync(resolveNode());
542
557
  } catch {
543
- return process.execPath;
558
+ return resolveNode();
544
559
  }
545
560
  }
546
561
 
@@ -682,7 +697,11 @@ function buildWinHiddenVbs(ps1Path) {
682
697
  function installWinShortcut(opts) {
683
698
  const { name, port, cwd, dataDir, engine, host, agentDir } = serviceOptions(opts);
684
699
  const env = serviceEnv(port, cwd, dataDir, engine, host, agentDir);
685
- const url = `http://localhost:${port}`;
700
+ // The server binds 127.0.0.1 by default; PowerShell 7's Invoke-WebRequest
701
+ // resolves `localhost` to ::1 first and hangs until TimeoutSec when nothing
702
+ // listens on IPv6, making the health probe misfire ("server not running").
703
+ // Pin the probe (and the opened URL) to the IPv4 loopback the server uses.
704
+ const url = `http://127.0.0.1:${port}`;
686
705
  const ps1Path = winShortcutPs1Path(name);
687
706
  const ps1 = buildWinShortcutPs1(env, cwd, name, url, winLogPath(name), winPidFilePath(name));
688
707
  if (opts.print) {
@@ -986,19 +1005,53 @@ function launchWinService(vbsPath) {
986
1005
  child.unref();
987
1006
  }
988
1007
 
1008
+ /**
1009
+ * PID 文件记录的是看门狗 PowerShell 的 $PID(winPowershell() 可能选 pwsh.exe),
1010
+ * node 是它的子进程 —— 这三者是本服务实例的合理映像名。taskkill /T /F 是树级强杀,
1011
+ * PID 若已退出被系统复用就会误杀无辜进程,所以杀前必须核对映像名。
1012
+ */
1013
+ const WIN_SERVICE_IMAGE_RE = /^(powershell|pwsh|node)\.exe$/i;
1014
+
1015
+ /** tasklist 查询 PID 对应的进程映像名;查询失败/无此进程返回 null(无法核实)。 */
1016
+ function winPidImageName(pid) {
1017
+ const res = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8" });
1018
+ if (res.status !== 0) return null;
1019
+ const line = (res.stdout ?? "").split(/\r?\n/).find((l) => l.trim());
1020
+ if (!line) return null; // tasklist 的 "INFO: 无任务" 输出没有 CSV 行
1021
+ return (line.match(/^"([^"]+)"/) ?? [])[1] ?? null;
1022
+ }
1023
+
989
1024
  /** Kill a running Windows instance via its PID file (whole tree). */
990
1025
  function stopWinInstance(name) {
991
1026
  const pid = winReadPid(name);
992
1027
  if (pid) {
993
1028
  if (pidAlive(pid)) {
994
- run("taskkill", ["/PID", String(pid), "/T", "/F"], {
995
- ignoreError: true,
996
- silent: true,
997
- });
998
- // taskkill /F 异步生效:等到进程树真正退出(restart 需要避免端口竞争)
999
- const deadline = Date.now() + 5000;
1000
- while (Date.now() < deadline && pidAlive(pid)) {
1001
- spawnSync("ping", ["-n", "2", "127.0.0.1"], { stdio: "ignore" });
1029
+ // PID 可能已退出并被系统复用给别的进程:杀前核对映像名,不符则警告并跳过,
1030
+ // 绝不 taskkill /T /F 盲杀。macOS/Linux 无按 PID 杀的路径(launchctl/systemctl
1031
+ // 按服务单元管理),只有 Windows 需要这道核对。
1032
+ const image = winPidImageName(pid);
1033
+ if (image === null) {
1034
+ console.warn(
1035
+ ZH
1036
+ ? `⚠ 无法核实 PID ${pid} 的进程映像名(tasklist 不可用或进程已消失),跳过强制结束以防误杀`
1037
+ : `⚠ Cannot verify the image name of PID ${pid} (tasklist unavailable or process gone), skipping force-kill to avoid killing an unrelated process`,
1038
+ );
1039
+ } else if (!WIN_SERVICE_IMAGE_RE.test(image)) {
1040
+ console.warn(
1041
+ ZH
1042
+ ? `⚠ PID ${pid} 现在是 ${image},不是本服务的进程(PID 可能已被复用),跳过强制结束`
1043
+ : `⚠ PID ${pid} is now ${image}, not a pi-web-ui process (PID may have been reused), skipping force-kill`,
1044
+ );
1045
+ } else {
1046
+ run("taskkill", ["/PID", String(pid), "/T", "/F"], {
1047
+ ignoreError: true,
1048
+ silent: true,
1049
+ });
1050
+ // taskkill /F 异步生效:等到进程树真正退出(restart 需要避免端口竞争)
1051
+ const deadline = Date.now() + 5000;
1052
+ while (Date.now() < deadline && pidAlive(pid)) {
1053
+ spawnSync("ping", ["-n", "2", "127.0.0.1"], { stdio: "ignore" });
1054
+ }
1002
1055
  }
1003
1056
  }
1004
1057
  rmSync(winPidFilePath(name), { force: true });
@@ -1110,9 +1163,27 @@ function effectivePort(opts) {
1110
1163
  return String(opts.port ?? process.env.PI_WEB_PORT ?? "8787");
1111
1164
  }
1112
1165
 
1166
+ /**
1167
+ * 服务名(--name)统一校验。name 会被原样拼进 systemd unit 文件名
1168
+ * (/etc/systemd/system/<name>.service)、Windows 启动脚本/PID 文件路径
1169
+ * (%APPDATA%\pi-web-ui\<name>.ps1) 与 HKCU Run 键值名 —— 不限字符集时,
1170
+ * 路径分隔符/引号/空格都能进这些拼接点(路径穿越或 unit 内容注入)。
1171
+ * 收紧为:字母或数字开头,仅字母/数字/-/_,最长 64 字符。
1172
+ */
1173
+ function serviceName(opts) {
1174
+ const name = opts.name ?? "pi-web-ui";
1175
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
1176
+ fail(
1177
+ ZH
1178
+ ? `无效服务名 "${name}"(仅限字母/数字/-/_,字母或数字开头,最长 64 字符)`
1179
+ : `Invalid service name "${name}" (alphanumeric, "-" and "_" only; must start with a letter or digit; max 64 chars)`,
1180
+ );
1181
+ return name;
1182
+ }
1183
+
1113
1184
  /** Shared option normalization for install. */
1114
1185
  function serviceOptions(opts) {
1115
- const name = opts.name ?? "pi-web-ui";
1186
+ const name = serviceName(opts);
1116
1187
  const port = effectivePort(opts);
1117
1188
  if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
1118
1189
  fail(ZH ? `无效端口: ${port}` : `Invalid port: ${port}`);
@@ -1267,7 +1338,7 @@ function installSystemd(opts) {
1267
1338
  }
1268
1339
 
1269
1340
  function uninstallLaunchd(opts) {
1270
- const name = opts.name ?? "pi-web-ui";
1341
+ const name = serviceName(opts);
1271
1342
  const label = serviceLabel(name);
1272
1343
  const plist = launchAgentPlist(name);
1273
1344
  run("launchctl", ["bootout", `gui/${uid()}/${label}`], {
@@ -1285,7 +1356,7 @@ function uninstallLaunchd(opts) {
1285
1356
  }
1286
1357
 
1287
1358
  function uninstallSystemd(opts) {
1288
- const name = opts.name ?? "pi-web-ui";
1359
+ const name = serviceName(opts);
1289
1360
  ensureRootForSystemctl();
1290
1361
  run("systemctl", ["disable", "--now", `${name}.service`], {
1291
1362
  ignoreError: true,
@@ -1368,7 +1439,7 @@ function installWindows(opts) {
1368
1439
  }
1369
1440
 
1370
1441
  function uninstallWindows(opts) {
1371
- const name = opts.name ?? "pi-web-ui";
1442
+ const name = serviceName(opts);
1372
1443
  winRunKeyDelete(name);
1373
1444
  // 历史计划任务(旧版本 install 可能注册过)
1374
1445
  if (winTaskExists(name)) {
@@ -1503,7 +1574,7 @@ async function setQuiesce(opts, on) {
1503
1574
  }
1504
1575
 
1505
1576
  function controlService(action, opts) {
1506
- const name = opts.name ?? "pi-web-ui";
1577
+ const name = serviceName(opts);
1507
1578
 
1508
1579
  if (isMac) {
1509
1580
  const label = serviceLabel(name);
@@ -2072,11 +2143,13 @@ function buildPluginSource(pluginRoot, tmpDir, manifest) {
2072
2143
  * 构建决策(issue #165:--build 自动推断)。
2073
2144
  *
2074
2145
  * 只有源码、没有产物(index.mjs 与 client/entry.mjs 都缺)且存在可解析的构建声明 =
2075
- * “不构建这次安装必死”,此时默认直接构建( previously 只打印一行提示,等用户滚回去重加
2076
- * --build)。产物已提交的仓库不受影响(mode=none,什么都不跑)。
2146
+ * “不构建这次安装必死”,此前会直接自动构建;但构建命令来自远端 manifest/package.json,
2147
+ * 经 shell:true 执行属于任意命令执行面,非交互环境下静默跑等于替用户放行。
2148
+ * 收紧后(审计修复):mode=auto 只表示“有构建声明且必要”,是否真正执行由
2149
+ * installOnePlugin 二次把关 —— 交互 TTY 且用户输入 y 才跑,否则跳过并警告。
2077
2150
  * --no-build 保留旧的“装个空目录”行为(脚本化镜像/检查用),并明确打印跳过原因。
2078
2151
  */
2079
- function decideBuildAction({ pluginRoot, manifest, build, noBuild }) {
2152
+ function decideBuildAction({ pluginRoot, manifest, build, noBuild, isCatalog = false }) {
2080
2153
  if (build && noBuild)
2081
2154
  throw new Error(
2082
2155
  ZH ? "--build 与 --no-build 不能同时用(二选一)" : "--build and --no-build cannot be used together (pick one)",
@@ -2085,12 +2158,14 @@ function decideBuildAction({ pluginRoot, manifest, build, noBuild }) {
2085
2158
  const artifactsMissing =
2086
2159
  !existsSync(join(pluginRoot, "index.mjs")) && !existsSync(join(pluginRoot, "client", "entry.mjs"));
2087
2160
  if (build) {
2088
- if (!plan)
2161
+ if (!plan) {
2162
+ if (isCatalog) return { mode: "none", plan };
2089
2163
  throw new Error(
2090
2164
  ZH
2091
2165
  ? "插件没有声明构建方式:请在 manifest.json 里加 build.command(或 package.json 的 scripts.build),或去掉 --build"
2092
2166
  : "Plugin has no build declaration: add build.command to manifest.json (or scripts.build in package.json), or remove --build",
2093
2167
  );
2168
+ }
2094
2169
  return { mode: "explicit", plan };
2095
2170
  }
2096
2171
  if (plan && artifactsMissing) {
@@ -2100,12 +2175,31 @@ function decideBuildAction({ pluginRoot, manifest, build, noBuild }) {
2100
2175
  return { mode: "none", plan };
2101
2176
  }
2102
2177
 
2178
+ /**
2179
+ * 交互确认:插件声明的构建命令将经 shell 在本机执行(install + command 都来自
2180
+ * 远端 manifest/package.json),必须用户显式输入 y 才跑。仅 TTY 下会被调用。
2181
+ */
2182
+ async function confirmSourceBuild(plan) {
2183
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2184
+ try {
2185
+ const answer = await rl.question(
2186
+ ZH
2187
+ ? `⚠ 插件声明了以下构建命令,将在本机以 shell 执行:\n 1) ${plan.install}\n 2) ${plan.command}\n 确认执行?输入 y 继续,其他任意键跳过构建: `
2188
+ : `⚠ The plugin declares the following build commands, which will run through a shell on this machine:\n 1) ${plan.install}\n 2) ${plan.command}\n Proceed? Enter y to run, anything else to skip the build: `,
2189
+ );
2190
+ const normalized = answer.trim().toLowerCase();
2191
+ return normalized === "y" || normalized === "yes";
2192
+ } finally {
2193
+ rl.close();
2194
+ }
2195
+ }
2196
+
2103
2197
  /**
2104
2198
  * 装一个插件(单源模式与目录模式共用):拉取/定位 → 读 manifest → 构建决策 →
2105
2199
  * 覆盖(备份+保留 config.json)→ 落盘 → 记录来源/sha。
2106
2200
  * 失败抛 Error(目录模式逐条 try/catch 继续下一条,单源模式由调用方转 fail)。
2107
2201
  */
2108
- async function installOnePlugin({ rawSpec, name, force, build, noBuild, dataDir }) {
2202
+ async function installOnePlugin({ rawSpec, name, force, build, noBuild, dataDir, isCatalog = false }) {
2109
2203
  const pluginsDir = join(dataDir, "plugins");
2110
2204
  const localCandidate = resolve(rawSpec.replace(/^file:\/\//, ""));
2111
2205
  const isLocal = existsSync(localCandidate);
@@ -2132,9 +2226,36 @@ async function installOnePlugin({ rawSpec, name, force, build, noBuild, dataDir
2132
2226
  }
2133
2227
  // 构建决策(issue #150 的 --build + issue #165 的自动推断):构建在临时目录里完成,
2134
2228
  // 成功后才进入覆盖流程——构建失败 = 目标目录完全没被动过(上一版插件照常可用)。
2135
- const decision = decideBuildAction({ pluginRoot, manifest, build: build === true, noBuild: noBuild === true });
2229
+ const decision = decideBuildAction({
2230
+ pluginRoot,
2231
+ manifest,
2232
+ build: build === true,
2233
+ noBuild: noBuild === true,
2234
+ isCatalog,
2235
+ });
2236
+ // 授权把关(审计修复):--build 显式传参行为不变;mode=auto 的构建命令来自
2237
+ // 远端 manifest/package.json 且经 shell:true 执行 —— 非交互环境绝不自动跑
2238
+ // (跳过并警告),交互 TTY 也要用户显式输入 y 才执行。
2239
+ let authorized = decision.mode === "explicit";
2240
+ if (decision.mode === "auto") {
2241
+ if (!process.stdin.isTTY) {
2242
+ console.warn(
2243
+ ZH
2244
+ ? "⚠ 插件声明了构建命令但未授权执行,已跳过构建 —— 传 --build 或手动构建后再安装"
2245
+ : "⚠ Plugin declares build commands but they were not authorized; skipping the build — pass --build or build manually before installing",
2246
+ );
2247
+ } else {
2248
+ authorized = await confirmSourceBuild(decision.plan);
2249
+ if (!authorized)
2250
+ console.warn(
2251
+ ZH
2252
+ ? "⚠ 未确认执行构建命令,已跳过构建 —— 需要时传 --build 或手动构建后再安装"
2253
+ : "⚠ Build commands not confirmed; skipping the build — pass --build or build manually if needed",
2254
+ );
2255
+ }
2256
+ }
2136
2257
  let installRoot = pluginRoot;
2137
- if (decision.mode === "explicit" || decision.mode === "auto") {
2258
+ if (authorized) {
2138
2259
  console.log(
2139
2260
  ZH
2140
2261
  ? `· 源码构建(${decision.mode === "auto" ? "自动推断:有构建声明但无产物" : "--build"}):先 ${decision.plan.install},再 ${decision.plan.command}`
@@ -2151,6 +2272,12 @@ async function installOnePlugin({ rawSpec, name, force, build, noBuild, dataDir
2151
2272
  ? "· 跳过构建(--no-build):目录里没有产物,装上后该插件不会被加载"
2152
2273
  : "· Skipping build (--no-build): no artifacts in directory, plugin will not load after install",
2153
2274
  );
2275
+ } else if (decision.mode === "auto") {
2276
+ console.log(
2277
+ ZH
2278
+ ? "· 已跳过构建:目录里没有产物,装上后该插件不会被加载(授权后重装可补产物)"
2279
+ : "· Build skipped: no artifacts in directory, plugin will not load after install (re-install with authorization to build)",
2280
+ );
2154
2281
  }
2155
2282
  // 默认 id:子目录名 > 仓库名 > 本地目录名
2156
2283
  const sourceName = src?.subpath ? src.subpath.split("/").pop() : (src?.repo ?? localCandidate.split(/[\\/]/).pop());
@@ -2430,6 +2557,7 @@ async function installCatalogCmd(opts) {
2430
2557
  build: opts.build === true,
2431
2558
  noBuild: opts.noBuild === true,
2432
2559
  dataDir,
2560
+ isCatalog: true,
2433
2561
  });
2434
2562
  console.log(ZH ? `✔ ${e.id} 安装成功` : `✔ ${e.id} installed`);
2435
2563
  okCount++;