Merge branch 'my-pi-worktree1'

# Conflicts:
#	AGENTS.md
#	pi-tool-search/CHANGELOG.md
#	pi-tool-search/docs/dynamic-tool-loading.md
This commit is contained in:
云服务部-叶林立
2026-08-25 15:18:58 +08:00
23 changed files with 1333 additions and 9 deletions
+18 -2
View File
@@ -25,6 +25,7 @@
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless`KEENABLE_API_KEY` 仅用于提高速率限制。
- `search_config.sh``extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`Hashline 自带 `grep` 强制关闭。
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。
@@ -34,6 +35,10 @@
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13``v0.3.6`)。
- 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。
- `pi-ask-user/`:把 Pi 上游 `question.ts``questionnaire.ts` 示例整合为模型主动调用的 `ask_user_question` 工具,统一支持单题、多题、选择、自由文本、自定义回答与复核;不加载 `/qna`
- 上游来源:<https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions>
- 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`2026-08-24)。
- 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio``jdtls`
- `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。
- 上游来源:<https://github.com/kitfunso/hippo-memory>
@@ -53,7 +58,7 @@
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845``@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
- 根包还固定安装 `@tavily/pi-extension@0.1.2``@keenable/pi-search@0.1.2``pi-hashline-edit@0.8.3``typescript-language-server@5.3.0``typescript@6.0.3``pi-context-view``@firstpick/pi-extension-codex-fast-mode``@gotgenes/pi-permission-system``@smoose/pi-notify``pi-minimal-footer``pi-ssh``pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
- 根包还固定安装 `@tavily/pi-extension@0.1.2``@keenable/pi-search@0.1.2``pi-hashline-edit@0.8.3``typescript-language-server@5.3.0``typescript@6.0.3``pi-context-view``@firstpick/pi-extension-codex-fast-mode``@gotgenes/pi-permission-system``@smoose/pi-notify``pi-ask-user``pi-minimal-footer``pi-ssh``pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
@@ -68,11 +73,12 @@
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit``grep``replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find``codegraph_explore``lsp_diagnostics``tool_search`;其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent``typebox``ModelRegistry.complete()``setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
- `.pi-debug/``/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
- CodeGraph 扩展只配置 Pi 到外部 `codegraph` 命令的 MCP 连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。仓库不执行 `codegraph init`,不创建或管理 `.codegraph/`,也不改动索引、更新或遥测设置。
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db``.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config``/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search``exa_web_search``keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
@@ -196,6 +202,16 @@
- 根加载顺序必须保持 `pi-permission-auto-review``pi-permission-system``pi-ssh``pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。
## `pi-ask-user` 开发约定
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI 与工具注册位于 `pi-ask-user/src/`,纯 helper 测试保留在 `pi-ask-user/test/`
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor。
- 成功结果的 `details` 必须保持 `version``cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-auto-review` transcript 解析。
- 同步上游示例时从明确 commit 移植并保留 `LICENSE``UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
## `pi-tool-search` 开发约定
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置部署位于 `pi-tool-search/extensions/config.ts`,测试保留在 `pi-tool-search/test/`
+21 -7
View File
@@ -16,6 +16,7 @@
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
@@ -110,18 +111,23 @@ Hippo 官方 Pi Extension 存在于上游 Git 仓库的 `extensions/pi-extension
`hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` /
`e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载;
匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行
`npm install -g hippo-memory@<固定版本>`,但不会选择项目或执行 `hippo init`。用户应进入需要记忆的
项目目录自行运行 `hippo init`
`npm install -g hippo-memory@<固定版本>`,但安装脚本不会选择或初始化项目。进入目标项目后可在 Pi 中执行
`/plugin_init`:命令会显示当前目录并要求确认,以 `.codegraph/codegraph.db` `.hippo/hippo.db`
初始化标志,只为缺失状态运行 `codegraph init``hippo init`;全部成功后通过 Pi 官方热重载重新连接 CodeGraph MCP 并重新触发 Hippo
session start。也可以继续在终端手动执行两个 CLI 的 `init` 命令。
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启
`RTK command rewriting` 时,才需要另外安装 `rtk` 可执行文件。
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph`
启动 Pi 的 `PATH`,并在目标项目执行过 `codegraph init`。组合包不会创建或维护
`.codegraph/`,也不会修改 CodeGraph 的索引、更新或遥测设置。
启动 Pi 的 `PATH`。组合包只有在用户显式调用 `/plugin_init` 并确认当前项目后才会创建
`.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 CodeGraph 的更新或遥测设置。
## 组合行为
### 模型主动提问
`pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具仅在交互式 TUI 中运行,最多一次提交 8 个问题,支持选择、自由文本、显式跳过、返回修改和最终复核;取消结果不会作为 AutoReview 的授权证据。
### 工具与搜索路由
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
@@ -138,7 +144,7 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox``ModelRegistry.complete()``setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
新 session 默认常驻 Pi 核心 `read``write``edit``bash``grep``find`,以及 `codegraph_explore``lsp_diagnostics``tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用的可选工具会自动从组中滤除,固定工具也不占动态组额度。
新 session 默认常驻 Pi 核心 `read``write``edit``bash``grep``find`,以及 `codegraph_explore``lsp_diagnostics``tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用的可选工具会自动从组中滤除,固定工具也不占动态组额度。
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具时,才先为新增工具提供确定性分组,并可在第一次 `tool_search` 时使用当前已认证模型补充目录;模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。
@@ -239,6 +245,14 @@ codegraph --version
codegraph status
```
在当前项目需要同时初始化 CodeGraph 和 Hippo 时,可直接执行:
```text
/plugin_init
```
该命令拒绝文件系统根目录和用户主目录,先确认两个 CLI 均可用,再依次初始化缺失数据库状态。任一步失败都不会热重载,也不会回滚此前已成功完成的初始化;两者已初始化时仍可用该命令快速热重载扩展。
### 权限基线自动部署
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。
+164
View File
@@ -0,0 +1,164 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { join, parse, resolve } from "node:path";
interface ExecResult {
code: number;
stdout: string;
stderr: string;
}
export type PluginInitExecutor = (
command: string,
args: string[],
options: { cwd: string },
) => Promise<ExecResult>;
interface InitTarget {
name: "CodeGraph" | "Hippo";
command: "codegraph" | "hippo";
database: string;
initArgs(cwd: string): string[];
}
const INIT_TARGETS: InitTarget[] = [
{
name: "CodeGraph",
command: "codegraph",
database: join(".codegraph", "codegraph.db"),
initArgs: (cwd) => ["init", cwd],
},
{
name: "Hippo",
command: "hippo",
database: join(".hippo", "hippo.db"),
initArgs: () => ["init"],
},
];
async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false;
throw error;
}
}
export function validatePluginInitCwd(cwd: string): string {
const projectRoot = resolve(cwd);
if (projectRoot === parse(projectRoot).root) {
throw new Error("拒绝在文件系统根目录初始化项目插件。");
}
if (projectRoot === resolve(homedir())) {
throw new Error("拒绝直接在用户主目录初始化项目插件;请先进入具体项目目录。");
}
return projectRoot;
}
export async function findMissingPluginInitializers(cwd: string): Promise<InitTarget[]> {
const projectRoot = validatePluginInitCwd(cwd);
const states = await Promise.all(
INIT_TARGETS.map(async (target) => ({
target,
initialized: await isFile(join(projectRoot, target.database)),
})),
);
return states.filter(({ initialized }) => !initialized).map(({ target }) => target);
}
function compactFailure(result: ExecResult): string {
const output = (result.stderr.trim() || result.stdout.trim()).replace(/\s+/gu, " ");
return output ? output.slice(0, 400) : `退出码 ${result.code}`;
}
async function executeChecked(
exec: PluginInitExecutor,
target: InitTarget,
args: string[],
cwd: string,
phase: "检测" | "初始化",
): Promise<void> {
let result: ExecResult;
try {
result = await exec(target.command, args, { cwd });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${target.name} ${phase}失败:${detail}`, { cause: error });
}
if (result.code !== 0) {
throw new Error(`${target.name} ${phase}失败:${compactFailure(result)}`);
}
}
export async function initializeMissingPlugins(
cwd: string,
targets: InitTarget[],
exec: PluginInitExecutor,
onStart?: (target: InitTarget) => void,
): Promise<void> {
const projectRoot = validatePluginInitCwd(cwd);
// Preflight both CLIs before creating either project's state.
for (const target of INIT_TARGETS) {
await executeChecked(exec, target, ["--version"], projectRoot, "检测");
}
const completed: InitTarget[] = [];
for (const target of targets) {
onStart?.(target);
try {
await executeChecked(exec, target, target.initArgs(projectRoot), projectRoot, "初始化");
completed.push(target);
} catch (error) {
if (completed.length === 0) throw error;
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`${detail};已完成的 ${completed.map(({ name }) => name).join("、")} 初始化会保留,不会自动回滚。`,
{ cause: error },
);
}
}
}
export default function pluginInitExtension(pi: ExtensionAPI): void {
pi.registerCommand("plugin_init", {
description: "Initialize CodeGraph and Hippo for the current project, then hot-reload Pi",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
let projectRoot: string;
let missing: InitTarget[];
try {
projectRoot = validatePluginInitCwd(ctx.cwd);
missing = await findMissingPluginInitializers(projectRoot);
} catch (error) {
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
return;
}
const pending = missing.length > 0 ? missing.map(({ name }) => name).join("、") : "无(均已初始化)";
const confirmed = await ctx.ui.confirm(
"初始化项目插件",
`项目:${projectRoot}\n待初始化:${pending}\n\n完成后将热重载 Pi 扩展。是否继续?`,
);
if (!confirmed) return;
try {
await initializeMissingPlugins(
projectRoot,
missing,
(command, args, options) => pi.exec(command, args, options),
(target) => ctx.ui.notify(`正在初始化 ${target.name}`, "info"),
);
ctx.ui.notify(
missing.length > 0 ? "项目插件初始化完成,正在热重载 Pi…" : "项目插件均已初始化,正在热重载 Pi…",
"info",
);
await ctx.reload();
} catch (error) {
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
}
},
});
}
+28
View File
@@ -18,6 +18,7 @@
"@tavily/pi-extension": "0.1.2",
"context-mode": "1.0.169",
"jiti": "2.7.0",
"pi-ask-user": "file:./pi-ask-user",
"pi-condense": "2.9.1",
"pi-context-view": "0.4.2",
"pi-hashline-edit": "0.8.3",
@@ -5664,6 +5665,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/pi-ask-user": {
"resolved": "pi-ask-user",
"link": true
},
"node_modules/pi-condense": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz",
@@ -7109,6 +7114,29 @@
"zod": "^3.25.28 || ^4"
}
},
"pi-ask-user": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
}
},
"pi-extension-codex-fast-mode": {
"name": "@firstpick/pi-extension-codex-fast-mode",
"version": "0.1.1",
+4
View File
@@ -11,6 +11,7 @@
"files": [
"extensions",
"hippo-memory-pi",
"pi-ask-user",
"pi-extension-codex-fast-mode",
"pi-rtk-optimizer",
"pi-minimal-footer",
@@ -48,6 +49,7 @@
"./extensions/tavily-override.ts",
"./node_modules/@keenable/pi-search/src/index.ts",
"./extensions/mcp.ts",
"./extensions/plugin-init.ts",
"./pi-rtk-optimizer/index.ts",
"./extensions/hashline.ts",
"./node_modules/context-mode/build/adapters/pi/extension.js",
@@ -62,6 +64,7 @@
"./pi-permission-auto-review/index.ts",
"./extensions/permission-system.ts",
"./pi-ssh/index.ts",
"./pi-ask-user/index.ts",
"./extensions/tool-routing.ts",
"./pi-tool-search/extensions/index.ts"
],
@@ -86,6 +89,7 @@
"pi-hashline-edit": "0.8.3",
"pi-lsp": "0.1.7",
"pi-mcp-adapter": "2.26.0",
"pi-ask-user": "file:./pi-ask-user",
"pi-ssh": "file:./pi-ssh",
"pi-tool-search": "file:./pi-tool-search",
"ssh2": "1.17.0",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
# pi-ask-user
A locally maintained Pi extension that consolidates the upstream `question.ts` and `questionnaire.ts` examples into one model-initiated tool. It intentionally does not include the `/qna` command.
## Tool
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice;
- `text`: a free-form Editor answer;
- `required: false`: lets the user explicitly skip the question;
- multiple questions: progress tabs plus a final review page.
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
## Controls
- `↑` / `↓`: move through choices;
- `Enter`: select, edit, or submit;
- `Tab`, `Shift+Tab`, `←`, `→`: move between questions and review;
- `Esc`: leave text entry, or cancel from a question/review page.
The outer component propagates focus to the embedded Editor for IME cursor positioning.
## Result contract
Successful results include readable text and structured `details`:
```json
{
"version": 1,
"cancelled": false,
"questions": [],
"answers": [
{
"id": "language",
"question": "Which language should be used?",
"type": "select",
"answer": "TypeScript",
"value": "typescript",
"label": "TypeScript",
"custom": false,
"skipped": false
}
]
}
```
The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
The tool requires Pi's interactive TUI mode. RPC, JSON, and print modes receive a tool error rather than an invented answer.
## Development
```sh
npm test
npm run check
```
See `UPSTREAM.md` for the imported reference snapshot and local differences.
+12
View File
@@ -0,0 +1,12 @@
# Upstream sources
`pi-ask-user` is maintained directly in this repository. Its initial UI and tool design was adapted from the following MIT-licensed Pi examples:
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/question.ts>
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/questionnaire.ts>
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
The copied MIT license and original copyright notice are retained in `LICENSE`.
+17
View File
@@ -0,0 +1,17 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerAskUserTool } from "./src/tool.ts";
export default function askUserExtension(pi: ExtensionAPI): void {
registerAskUserTool(pi);
}
export { formatAnswers, normalizeQuestions, orderedAnswers } from "./src/normalize.ts";
export type {
AskUserResult,
NormalizedQuestion,
QuestionInput,
QuestionOptionInput,
QuestionType,
UserAnswer,
} from "./src/types.ts";
+47
View File
@@ -0,0 +1,47 @@
{
"name": "pi-ask-user",
"version": "0.1.0",
"description": "A unified interactive question tool for Pi agents.",
"private": true,
"type": "module",
"license": "MIT",
"main": "./index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"src",
"README.md",
"LICENSE",
"UPSTREAM.md"
],
"pi": {
"extensions": [
"./index.ts"
]
},
"scripts": {
"test": "node --test test/*.test.ts",
"check": "node --experimental-strip-types --check index.ts && node --test test/*.test.ts"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
},
"engines": {
"node": ">=22.19.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { NormalizedQuestion, QuestionInput, QuestionOptionInput, UserAnswer } from "./types.ts";
export const MAX_QUESTIONS = 8;
export const MAX_OPTIONS = 8;
function requiredText(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`${field} must be a string`);
const normalized = value.trim();
if (!normalized) throw new Error(`${field} must not be empty`);
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
return normalized;
}
function normalizeOption(option: QuestionOptionInput, questionId: string, index: number): QuestionOptionInput {
return {
value: requiredText(option?.value, `questions[${questionId}].options[${index}].value`, 128),
label: requiredText(option?.label, `questions[${questionId}].options[${index}].label`, 160),
...(option?.description === undefined
? {}
: { description: requiredText(option.description, `questions[${questionId}].options[${index}].description`, 500) }),
};
}
export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQuestion[] {
if (!Array.isArray(input) || input.length === 0) throw new Error("questions must contain at least one question");
if (input.length > MAX_QUESTIONS) throw new Error(`questions must contain at most ${MAX_QUESTIONS} questions`);
const ids = new Set<string>();
return input.map((question, index) => {
if (question === null || typeof question !== "object" || Array.isArray(question)) {
throw new Error(`questions[${index}] must be an object`);
}
const id = requiredText(question?.id, `questions[${index}].id`, 64);
if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(id)) {
throw new Error(`questions[${index}].id must start with a letter and contain only letters, numbers, _ or -`);
}
if (ids.has(id)) throw new Error(`duplicate question id: ${id}`);
ids.add(id);
const prompt = requiredText(question?.prompt, `questions[${index}].prompt`, 1_000);
const label = question.label === undefined
? `Q${index + 1}`
: requiredText(question.label, `questions[${index}].label`, 48);
if (question.type !== "select" && question.type !== "text") {
throw new Error(`questions[${index}].type must be select or text`);
}
if (question.allowCustom !== undefined && typeof question.allowCustom !== "boolean") {
throw new Error(`questions[${index}].allowCustom must be a boolean`);
}
if (question.required !== undefined && typeof question.required !== "boolean") {
throw new Error(`questions[${index}].required must be a boolean`);
}
const rawOptions = question.options ?? [];
if (!Array.isArray(rawOptions)) throw new Error(`questions[${index}].options must be an array`);
if (rawOptions.length > MAX_OPTIONS) {
throw new Error(`questions[${index}].options must contain at most ${MAX_OPTIONS} options`);
}
if (question.type === "text" && rawOptions.length > 0) {
throw new Error(`text question ${id} must not define options`);
}
if (question.type === "select" && rawOptions.length === 0) {
throw new Error(`select question ${id} must define at least one option`);
}
const options = rawOptions.map((option, optionIndex) => normalizeOption(option, id, optionIndex));
const values = new Set<string>();
for (const option of options) {
if (values.has(option.value)) throw new Error(`duplicate option value ${option.value} in question ${id}`);
values.add(option.value);
}
return {
id,
label,
prompt,
type: question.type,
options,
allowCustom: question.type === "select" && question.allowCustom !== false,
required: question.required !== false,
};
});
}
export function orderedAnswers(questions: readonly NormalizedQuestion[], answers: ReadonlyMap<string, UserAnswer>): UserAnswer[] {
return questions.flatMap((question) => {
const answer = answers.get(question.id);
return answer ? [answer] : [];
});
}
export function formatAnswers(answers: readonly UserAnswer[]): string {
return answers
.map((answer) => {
if (answer.skipped) return `${answer.id}: skipped`;
if (answer.type === "text" || answer.custom) return `${answer.id}: user wrote: ${answer.answer}`;
return `${answer.id}: user selected: ${answer.label} (value: ${answer.value})`;
})
.join("\n");
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from "typebox";
import { MAX_OPTIONS, MAX_QUESTIONS } from "./normalize.ts";
const OptionSchema = Type.Object({
value: Type.String({ minLength: 1, maxLength: 128, description: "Stable machine-readable value returned when selected" }),
label: Type.String({ minLength: 1, maxLength: 160, description: "User-visible option label" }),
description: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Optional explanation shown below the label" })),
});
const QuestionSchema = Type.Object({
id: Type.String({
minLength: 1,
maxLength: 64,
pattern: "^[A-Za-z][A-Za-z0-9_-]*$",
description: "Unique machine-readable question identifier",
}),
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short progress label; defaults to Q1, Q2, ..." })),
prompt: Type.String({ minLength: 1, maxLength: 1_000, description: "Full question shown to the user" }),
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
options: Type.Optional(Type.Array(OptionSchema, {
maxItems: MAX_OPTIONS,
description: "Required and non-empty for select questions; omit for text questions",
})),
allowCustom: Type.Optional(Type.Boolean({ description: "For select questions, append a free-form answer choice (default: true)" })),
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
});
export const AskUserParamsSchema = Type.Object({
questions: Type.Array(QuestionSchema, {
minItems: 1,
maxItems: MAX_QUESTIONS,
description: "One or more questions to present in a single sequential interaction",
}),
});
+72
View File
@@ -0,0 +1,72 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
import { AskUserParamsSchema } from "./schema.ts";
import type { AskUserResult, QuestionInput } from "./types.ts";
import { AskUserView } from "./view.ts";
function fallbackText(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content.find((item) => item.type === "text")?.text ?? "";
}
export function registerAskUserTool(pi: ExtensionAPI): void {
pi.registerTool({
name: "ask_user_question",
label: "Ask User",
description:
"Ask the user one or more structured questions in an interactive TUI when their input is genuinely needed to continue. Supports choices, free-form text, optional custom answers, review, and cancellation. Do not ask for information already stated by the user.",
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
parameters: AskUserParamsSchema,
executionMode: "sequential",
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const questions = normalizeQuestions(params.questions as QuestionInput[]);
if (ctx.mode !== "tui") throw new Error("ask_user_question requires interactive TUI mode");
if (signal?.aborted) {
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
}
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
new AskUserView(tui, theme, questions, done, signal),
);
if (result.cancelled) {
return {
content: [{ type: "text", text: "User cancelled the questions" }],
details: result,
};
}
return {
content: [{ type: "text", text: formatAnswers(result.answers) }],
details: result,
};
},
renderCall(args, theme) {
const questions = Array.isArray(args.questions) ? args.questions as Array<{ id?: unknown; label?: unknown }> : [];
const labels = questions
.map((question) => typeof question.label === "string" ? question.label : typeof question.id === "string" ? question.id : "question")
.join(", ");
let text = theme.fg("toolTitle", theme.bold("ask user "));
text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`);
if (labels) text += theme.fg("dim", ` (${labels})`);
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as AskUserResult | undefined;
if (!details) return new Text(fallbackText(result), 0, 0);
if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
const lines = details.answers.map((answer) => {
const value = answer.skipped
? theme.fg("muted", "Skipped")
: `${answer.custom || answer.type === "text" ? theme.fg("muted", "(wrote) ") : ""}${theme.fg("accent", answer.label)}`;
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.id)}: ${value}`;
});
return new Text(lines.join("\n"), 0, 0);
},
});
}
+49
View File
@@ -0,0 +1,49 @@
export type QuestionType = "select" | "text";
export interface QuestionOptionInput {
value: string;
label: string;
description?: string;
}
export interface QuestionInput {
id: string;
label?: string;
prompt: string;
type: QuestionType;
options?: QuestionOptionInput[];
allowCustom?: boolean;
required?: boolean;
}
export interface NormalizedQuestion {
id: string;
label: string;
prompt: string;
type: QuestionType;
options: QuestionOptionInput[];
allowCustom: boolean;
required: boolean;
}
/**
* `question` and `answer` intentionally match pi-permission-auto-review's
* trusted user-interaction envelope for ask_user_question.
*/
export interface UserAnswer {
id: string;
question: string;
type: QuestionType;
answer: string;
value: string;
label: string;
custom: boolean;
skipped: boolean;
}
export interface AskUserResult {
version: 1;
cancelled: boolean;
questions: NormalizedQuestion[];
answers: UserAnswer[];
}
+363
View File
@@ -0,0 +1,363 @@
import type { Theme } from "@earendil-works/pi-coding-agent";
import {
Editor,
type Component,
type EditorTheme,
type Focusable,
Key,
matchesKey,
type TUI,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { orderedAnswers } from "./normalize.ts";
import type { AskUserResult, NormalizedQuestion, QuestionOptionInput, UserAnswer } from "./types.ts";
type InputKind = "text" | "custom";
type Done = (result: AskUserResult) => void;
type RenderOption = QuestionOptionInput & { kind: "option" | "custom" | "skip" };
export class AskUserView implements Component, Focusable {
private readonly editor: Editor;
private readonly answers = new Map<string, UserAnswer>();
private readonly abortHandler: () => void;
private currentPage = 0;
private optionIndex = 0;
private inputKind: InputKind | null = null;
private validationMessage: string | undefined;
private cachedWidth: number | undefined;
private cachedLines: string[] | undefined;
private completed = false;
private _focused = false;
constructor(
private readonly tui: TUI,
private readonly theme: Theme,
private readonly questions: NormalizedQuestion[],
private readonly done: Done,
private readonly signal?: AbortSignal,
) {
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
this.editor = new Editor(tui, editorTheme);
this.editor.onSubmit = (value) => this.submitEditor(value);
this.abortHandler = () => this.finish(true);
signal?.addEventListener("abort", this.abortHandler, { once: true });
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.syncEditorFocus();
}
private syncEditorFocus(): void {
this.editor.focused = this._focused && this.inputKind !== null;
}
private refresh(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
this.syncEditorFocus();
this.tui.requestRender();
}
private currentQuestion(): NormalizedQuestion | undefined {
return this.questions[this.currentPage];
}
private currentOptions(): RenderOption[] {
const question = this.currentQuestion();
if (!question || question.type !== "select") return [];
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" });
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
return options;
}
private allAnswered(): boolean {
return this.questions.every((question) => this.answers.has(question.id));
}
private setCurrentPage(page: number): void {
this.currentPage = page;
this.inputKind = null;
this.validationMessage = undefined;
this.editor.setText("");
this.optionIndex = 0;
const question = this.currentQuestion();
const answer = question ? this.answers.get(question.id) : undefined;
if (question?.type === "select" && answer) {
const options = this.currentOptions();
const selected = options.findIndex((option) =>
answer.skipped ? option.kind === "skip" : answer.custom ? option.kind === "custom" : option.kind === "option" && option.value === answer.value,
);
this.optionIndex = Math.max(0, selected);
}
this.refresh();
}
private movePage(delta: number): void {
const pageCount = this.questions.length + 1;
this.setCurrentPage((this.currentPage + delta + pageCount) % pageCount);
}
private enterEditor(kind: InputKind): void {
const question = this.currentQuestion();
if (!question) return;
const existing = this.answers.get(question.id);
this.inputKind = kind;
this.validationMessage = undefined;
this.editor.setText(existing && (question.type === "text" || existing.custom) ? existing.answer : "");
this.refresh();
}
private saveAnswer(question: NormalizedQuestion, answer: Omit<UserAnswer, "id" | "question" | "type">): void {
this.answers.set(question.id, {
id: question.id,
question: question.prompt,
type: question.type,
...answer,
});
}
private submitEditor(value: string): void {
const question = this.currentQuestion();
if (!question || !this.inputKind) return;
const trimmed = value.trim();
if (!trimmed && (question.required || this.inputKind === "custom")) {
this.validationMessage = "An answer is required.";
this.refresh();
return;
}
this.saveAnswer(question, {
answer: trimmed,
value: trimmed,
label: trimmed,
custom: this.inputKind === "custom",
skipped: !trimmed,
});
this.inputKind = null;
this.editor.setText("");
this.advanceAfterAnswer();
}
private selectCurrentOption(): void {
const question = this.currentQuestion();
const option = this.currentOptions()[this.optionIndex];
if (!question || !option) return;
if (option.kind === "custom") {
this.enterEditor("custom");
return;
}
if (option.kind === "skip") {
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
} else {
this.saveAnswer(question, {
answer: option.label,
value: option.value,
label: option.label,
custom: false,
skipped: false,
});
}
this.advanceAfterAnswer();
}
private advanceAfterAnswer(): void {
if (this.questions.length === 1) {
this.finish(false);
return;
}
this.setCurrentPage(this.currentPage < this.questions.length - 1 ? this.currentPage + 1 : this.questions.length);
}
private finish(cancelled: boolean): void {
if (this.completed) return;
this.completed = true;
this.signal?.removeEventListener("abort", this.abortHandler);
this.done({
version: 1,
cancelled,
questions: this.questions,
answers: orderedAnswers(this.questions, this.answers),
});
}
handleInput(data: string): void {
if (this.inputKind) {
if (matchesKey(data, Key.escape)) {
this.inputKind = null;
this.validationMessage = undefined;
this.editor.setText("");
this.refresh();
return;
}
this.editor.handleInput(data);
this.refresh();
return;
}
if (matchesKey(data, Key.escape)) {
this.finish(true);
return;
}
if (this.questions.length > 1) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
this.movePage(1);
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
this.movePage(-1);
return;
}
}
if (this.currentPage === this.questions.length) {
if (matchesKey(data, Key.enter) && this.allAnswered()) this.finish(false);
return;
}
const question = this.currentQuestion();
if (!question) return;
if (question.type === "text") {
if (!question.required && matchesKey(data, "s")) {
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
this.advanceAfterAnswer();
} else if (matchesKey(data, Key.enter)) {
this.enterEditor("text");
}
return;
}
const options = this.currentOptions();
if (matchesKey(data, Key.up)) {
this.optionIndex = Math.max(0, this.optionIndex - 1);
this.refresh();
return;
}
if (matchesKey(data, Key.down)) {
this.optionIndex = Math.min(options.length - 1, this.optionIndex + 1);
this.refresh();
return;
}
if (matchesKey(data, Key.enter)) this.selectCurrentOption();
}
render(width: number): string[] {
const renderWidth = Math.max(1, width);
if (this.cachedLines && this.cachedWidth === renderWidth) return this.cachedLines;
const lines: string[] = [];
const question = this.currentQuestion();
const addWrapped = (text: string): void => {
lines.push(...wrapTextWithAnsi(text, renderWidth));
};
const addWrappedWithPrefix = (prefix: string, text: string): void => {
const prefixWidth = visibleWidth(prefix);
if (prefixWidth >= renderWidth) {
addWrapped(prefix + text);
return;
}
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
const continuation = " ".repeat(prefixWidth);
wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`));
};
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
if (this.questions.length > 1) {
const tabs = this.questions.map((item, index) => {
const answered = this.answers.has(item.id);
const text = ` ${answered ? "■" : "□"} ${item.label} `;
return index === this.currentPage
? this.theme.bg("selectedBg", this.theme.fg("text", text))
: this.theme.fg(answered ? "success" : "muted", text);
});
const reviewText = " ✓ Review ";
tabs.push(this.currentPage === this.questions.length
? this.theme.bg("selectedBg", this.theme.fg("text", reviewText))
: this.theme.fg(this.allAnswered() ? "success" : "dim", reviewText));
addWrappedWithPrefix(" ", tabs.join(" "));
lines.push("");
}
if (this.currentPage === this.questions.length) {
addWrappedWithPrefix(" ", this.theme.fg("accent", this.theme.bold("Review answers")));
lines.push("");
for (const item of this.questions) {
const answer = this.answers.get(item.id);
const value = !answer ? this.theme.fg("warning", "Unanswered") : answer.skipped ? this.theme.fg("muted", "Skipped") : this.theme.fg("text", answer.label);
addWrappedWithPrefix(" ", `${this.theme.fg("muted", `${item.label}: `)}${value}`);
}
lines.push("");
addWrappedWithPrefix(" ", this.allAnswered()
? this.theme.fg("success", "Press Enter to submit")
: this.theme.fg("warning", "Answer or explicitly skip every question before submitting"));
} else if (question) {
addWrappedWithPrefix(" ", this.theme.fg("text", question.prompt));
lines.push("");
if (question.type === "select") {
for (const [index, option] of this.currentOptions().entries()) {
const selected = index === this.optionIndex;
const prefix = selected ? this.theme.fg("accent", "> ") : " ";
addWrappedWithPrefix(prefix, this.theme.fg(selected ? "accent" : option.kind === "skip" ? "muted" : "text", `${index + 1}. ${option.label}`));
if (option.description) addWrappedWithPrefix(" ", this.theme.fg("muted", option.description));
}
} else if (!this.inputKind) {
const existing = this.answers.get(question.id);
if (existing) addWrappedWithPrefix(" ", `${this.theme.fg("muted", "Current answer: ")}${existing.skipped ? "Skipped" : existing.label}`);
const action = `${existing ? "Press Enter to edit" : "Press Enter to answer"}${question.required ? "" : " • s to skip"}`;
addWrappedWithPrefix(" ", this.theme.fg("accent", action));
}
if (this.inputKind) {
if (this.inputKind === "custom") lines.push("");
addWrappedWithPrefix(" ", this.theme.fg("muted", "Your answer:"));
for (const line of this.editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
if (this.validationMessage) addWrappedWithPrefix(" ", this.theme.fg("warning", this.validationMessage));
}
}
lines.push("");
const help = this.inputKind
? "Enter submit • Esc go back"
: this.currentPage === this.questions.length
? "Tab/←→ navigate • Enter submit • Esc cancel"
: question?.type === "text"
? `${question.required ? "Enter write" : "Enter write • s skip"} • Tab/←→ navigate • Esc cancel`
: "↑↓ select • Enter confirm • Tab/←→ navigate • Esc cancel";
addWrappedWithPrefix(" ", this.theme.fg("dim", this.questions.length === 1 ? help.replace(" • Tab/←→ navigate", "") : help));
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
this.cachedWidth = renderWidth;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
this.editor.invalidate();
}
dispose(): void {
this.signal?.removeEventListener("abort", this.abortHandler);
}
}
+86
View File
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
import type { UserAnswer } from "../src/types.ts";
test("normalizes select and text questions with bounded defaults", () => {
const questions = normalizeQuestions([
{
id: "language",
label: " Language ",
prompt: " Choose a language ",
type: "select",
options: [{ value: "ts", label: " TypeScript " }],
},
{
id: "notes",
prompt: "Anything else?",
type: "text",
required: false,
},
]);
assert.deepEqual(questions[0], {
id: "language",
label: "Language",
prompt: "Choose a language",
type: "select",
options: [{ value: "ts", label: "TypeScript" }],
allowCustom: true,
required: true,
});
assert.equal(questions[1]?.label, "Q2");
assert.equal(questions[1]?.allowCustom, false);
assert.equal(questions[1]?.required, false);
});
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
assert.throws(() => normalizeQuestions([{ id: "bad_flag", prompt: "Bad", type: "text", required: "yes" as never }]), /must be a boolean/);
assert.throws(() => normalizeQuestions([
{ id: "same", prompt: "One", type: "text" },
{ id: "same", prompt: "Two", type: "text" },
]), /duplicate question id/);
assert.throws(() => normalizeQuestions([{ id: "pick", prompt: "Pick", type: "select" }]), /at least one option/);
assert.throws(() => normalizeQuestions([{ id: "text", prompt: "Write", type: "text", options: [{ value: "x", label: "X" }] }]), /must not define options/);
assert.throws(() => normalizeQuestions([{
id: "pick",
prompt: "Pick",
type: "select",
options: [{ value: "x", label: "X" }, { value: "x", label: "Again" }],
}]), /duplicate option value/);
});
test("orders and formats answers by question order", () => {
const questions = normalizeQuestions([
{ id: "first", prompt: "First?", type: "text" },
{ id: "second", prompt: "Second?", type: "select", options: [{ value: "yes", label: "Yes" }] },
{ id: "third", prompt: "Third?", type: "text", required: false },
]);
const answer = (id: string, overrides: Partial<UserAnswer>): UserAnswer => ({
id,
question: `${id}?`,
type: "text",
answer: id,
value: id,
label: id,
custom: false,
skipped: false,
...overrides,
});
const answers = new Map<string, UserAnswer>([
["third", answer("third", { answer: "", value: "", label: "Skipped", skipped: true })],
["second", answer("second", { type: "select", answer: "Yes", value: "yes", label: "Yes" })],
["first", answer("first", { answer: "hello", value: "hello", label: "hello", custom: true })],
]);
const ordered = orderedAnswers(questions, answers);
assert.deepEqual(ordered.map((item) => item.id), ["first", "second", "third"]);
assert.equal(formatAnswers(ordered), [
"first: user wrote: hello",
"second: user selected: Yes (value: yes)",
"third: skipped",
].join("\n"));
});
+1
View File
@@ -13,6 +13,7 @@
- Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows.
- Added authoritative `ssh-connection`, `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping reviewed connection, bounded discovery, structured file operations, and reviewed shell execution separate; the connection workflow now requires sequential `ssh_connect` to succeed before dependent tools.
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool.
## [0.3.6] - 2026-04-24
+1
View File
@@ -41,6 +41,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
| `context-administration` | Context upgrade and destructive purge |
| `memory-recall` | Official Hippo recall/context/status and condensed-output recovery |
| `memory-management` | Official Hippo remember/outcome tools |
| `user-interaction` | `ask_user_question` model-initiated TUI questions |
| `mcp-management` | Shared MCP adapter management |
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
@@ -14,6 +14,7 @@
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
The maintained `ask_user_question` tool has its own `user-interaction` group so the model can load the complete interactive schema only when a missing decision or preference must come from the user.
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
@@ -157,6 +157,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
keywords: ["remember", "outcome", "lesson", "error", "记住", "反馈", "项目经验"],
tools: ["hippo_remember", "hippo_outcome"],
},
{
id: "user-interaction",
title: "Interactive user questions",
summary: "Pause the agent and collect one or more structured decisions from the user in the TUI.",
useWhen: ["A missing requirement, preference, confirmation, or choice must come directly from the user before continuing"],
avoidWhen: ["The user's direct messages already provide the answer, or the task can proceed without another decision"],
keywords: ["ask", "question", "clarify", "choice", "preference", "提问", "澄清", "选择", "确认"],
tools: ["ask_user_question"],
},
{
id: "mcp-management",
title: "MCP management",
@@ -31,6 +31,7 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_remember")?.primaryGroup, "memory-management");
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_find")?.primaryGroup, "ssh-remote-search");
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_grep")?.primaryGroup, "ssh-remote-search");
assert.equal(result.catalog.tools.find((card) => card.name === "ask_user_question")?.primaryGroup, "user-interaction");
});
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
+12
View File
@@ -15,6 +15,10 @@ test("all package extensions load together without global registration conflicts
files: string[];
pi: { extensions: string[] };
};
assert.ok(
packageJson.pi.extensions.includes("./extensions/plugin-init.ts"),
"the bundle must expose the project plugin initializer command",
);
assert.ok(
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
"the bundle must load the vendored official Hippo Memory extension",
@@ -33,6 +37,9 @@ test("all package extensions load together without global registration conflicts
assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi");
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper");
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
assert.equal(packageJson.dependencies["pi-ask-user"], "file:./pi-ask-user");
assert.ok(packageJson.files.includes("pi-ask-user"), "the packed bundle must include pi-ask-user source");
const permissionConfig = JSON.parse(
await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"),
) as { authorizerChain: string[]; permission: Record<string, unknown> };
@@ -49,6 +56,11 @@ test("all package extensions load together without global registration conflicts
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
"pi-ssh tools must register before Tool Search builds its catalog",
);
assert.ok(
packageJson.pi.extensions.indexOf("./pi-ask-user/index.ts") <
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
"ask_user_question must register before Tool Search builds its catalog",
);
assert.equal(
packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"],
"file:./pi-extension-codex-fast-mode",
+210
View File
@@ -0,0 +1,210 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join, parse } from "node:path";
import test from "node:test";
import pluginInitExtension, {
findMissingPluginInitializers,
initializeMissingPlugins,
validatePluginInitCwd,
} from "../extensions/plugin-init.ts";
test("initializes missing CodeGraph and Hippo state after preflighting both CLIs", async () => {
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
const calls: Array<{ command: string; args: string[]; cwd: string }> = [];
try {
const targets = await findMissingPluginInitializers(cwd);
assert.deepEqual(targets.map(({ name }) => name), ["CodeGraph", "Hippo"]);
await initializeMissingPlugins(cwd, targets, async (command, args, options) => {
calls.push({ command, args, cwd: options.cwd });
return { code: 0, stdout: "ok", stderr: "" };
});
assert.deepEqual(calls, [
{ command: "codegraph", args: ["--version"], cwd },
{ command: "hippo", args: ["--version"], cwd },
{ command: "codegraph", args: ["init", cwd], cwd },
{ command: "hippo", args: ["init"], cwd },
]);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("uses the CLIs' database files as authoritative initialization markers", async () => {
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
const calls: string[] = [];
try {
await mkdir(join(cwd, ".codegraph"));
await mkdir(join(cwd, ".hippo"));
assert.deepEqual(
(await findMissingPluginInitializers(cwd)).map(({ name }) => name),
["CodeGraph", "Hippo"],
"bare state directories must not count as initialized",
);
await writeFile(join(cwd, ".codegraph", "codegraph.db"), "");
assert.deepEqual((await findMissingPluginInitializers(cwd)).map(({ name }) => name), ["Hippo"]);
await writeFile(join(cwd, ".hippo", "hippo.db"), "");
const targets = await findMissingPluginInitializers(cwd);
assert.deepEqual(targets, []);
await initializeMissingPlugins(cwd, targets, async (command, args) => {
calls.push(`${command} ${args.join(" ")}`);
return { code: 0, stdout: "ok", stderr: "" };
});
assert.deepEqual(calls, ["codegraph --version", "hippo --version"]);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("rejects unsafe project roots", () => {
assert.throws(() => validatePluginInitCwd(parse(homedir()).root), /文件系统根目录/);
assert.throws(() => validatePluginInitCwd(homedir()), /用户主目录/);
});
test("preflights both CLIs before creating project state", async () => {
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
const calls: string[] = [];
try {
const targets = await findMissingPluginInitializers(cwd);
await assert.rejects(
initializeMissingPlugins(cwd, targets, async (command, args) => {
calls.push(`${command} ${args.join(" ")}`);
return command === "hippo"
? { code: 1, stdout: "", stderr: "not installed" }
: { code: 0, stdout: "ok", stderr: "" };
}),
/Hippo 检测失败:not installed/,
);
assert.deepEqual(calls, ["codegraph --version", "hippo --version"]);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("plugin_init confirms the target and hot-reloads after initialization", async () => {
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
const calls: string[] = [];
const pi = {
registerCommand(name: string, definition: typeof command) {
assert.equal(name, "plugin_init");
command = definition;
},
exec: async (program: string, args: string[]) => {
calls.push(`${program} ${args.join(" ")}`);
return { code: 0, stdout: "ok", stderr: "" };
},
} as unknown as ExtensionAPI;
pluginInitExtension(pi);
assert.ok(command);
const registeredCommand = command!;
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
let reloaded = false;
let confirmation = "";
try {
await registeredCommand.handler("", {
cwd,
hasUI: true,
ui: {
confirm: async (_title: string, message: string) => {
confirmation = message;
return true;
},
notify() {},
},
reload: async () => {
reloaded = true;
},
});
assert.match(confirmation, new RegExp(cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.deepEqual(calls, [
"codegraph --version",
"hippo --version",
`codegraph init ${cwd}`,
"hippo init",
]);
assert.equal(reloaded, true);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("plugin_init reports retained partial state and does not reload when a later initializer fails", async () => {
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
const pi = {
registerCommand(_name: string, definition: typeof command) {
command = definition;
},
exec: async (program: string, args: string[]) => ({
code: program === "hippo" && args[0] === "init" ? 1 : 0,
stdout: "",
stderr: "mock failure",
}),
} as unknown as ExtensionAPI;
pluginInitExtension(pi);
assert.ok(command);
const registeredCommand = command!;
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
let reloaded = false;
let error = "";
try {
await registeredCommand.handler("", {
cwd,
hasUI: true,
ui: {
confirm: async () => true,
notify(message: string, level: string) {
if (level === "error") error = message;
},
},
reload: async () => {
reloaded = true;
},
});
assert.match(error, /Hippo 初始化失败:mock failure/);
assert.match(error, /已完成的 CodeGraph 初始化会保留,不会自动回滚/);
assert.equal(reloaded, false);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test("plugin_init stops without executing or reloading when confirmation is cancelled", async () => {
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
let executed = false;
const pi = {
registerCommand(_name: string, definition: typeof command) {
command = definition;
},
exec: async () => {
executed = true;
return { code: 0, stdout: "", stderr: "" };
},
} as unknown as ExtensionAPI;
pluginInitExtension(pi);
const registeredCommand = command!;
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
let reloaded = false;
try {
await registeredCommand.handler("", {
cwd,
hasUI: true,
ui: { confirm: async () => false, notify() {} },
reload: async () => {
reloaded = true;
},
});
assert.equal(executed, false);
assert.equal(reloaded, false);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});