Compare commits

...
3 Commits
Author SHA1 Message Date
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0c45b1a438 docs: 拆分 AGENTS.md,根文件只保留全局规则
根 AGENTS.md 从约 78KB 压缩到约 10KB,避免作为常驻规则加载时被截断:
只保留仓库定位、扩展清单、跨扩展硬规则与通用验证命令。各扩展的维护
约定与验证命令移入对应目录的 AGENTS.md(新增 extensions/AGENTS.md 承载
系统提示词与根目录 TS/LSP 约定),上游来源记录统一放进各目录的中文
UPSTREAM.md,并补齐 pi-permission-system 与 pi-rtk-optimizer 的来源记录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 19:43:22 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 360d8cffc8 feat(pi-subagents): 重新启用子 Agent 并使用独立的子 Agent 提示词
- 根包恢复 file:./pi-subagents 依赖与 extensions/subagents.ts 加载
  (排在 permission-system 之后、Tool Search 之前),并刷新根锁文件;
  Agent / get_subagent_result / steer_subagent 权限设为 allow,
  子 Agent 内的实际操作仍逐个经权限系统并转发主会话。
- 新增 config/prompt/subagent/:执行者角色 preamble 与组合包自带的
  general-purpose / Explore / Plan 定义(替换上游内置,去掉 bash find
  冲突规则与写死的 Haiku 模型)。
- pi-subagents 新增 basePrompt / bundledAgentsDir / excludedExtensionPaths
  选项:子 Agent 不再嵌入父会话提示词,身份标记置于开头;子会话排除
  pi-ask-user。未传选项时保持上游行为。
- system-prompt.ts:customPrompt 自带开场白时不再覆盖(修复子 Agent 角色
  与 <active_agent> 身份被抹掉);仅凭 customPrompt 上的 <active_agent>
  识别子会话,routing 规则支持 scope,sections 支持 subagentExclude
  (子会话去掉 memory_policy 与 chrome_profile_bridge)。
- pi-hermes-memory:子会话只读,记忆/skill 写入工具返回错误,后台整理、
  纠错检测、会话 flush 与索引写入全部跳过。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 19:43:22 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b817d66a11 chore: Pi 开发依赖跟随最新版并去掉测试里的固定版本号
- pi-hermes-memory / pi-notify / pi-permission-system / pi-subagents 的
  @earendil-works/* devDependencies 改为 latest,pi-rtk-optimizer 放开
  peer 范围;各子包锁文件刷新到 Pi 1.3.0,测试改为验证当前宿主 API。
- 新增 scripts/update-pi-devdeps.sh,统一刷新根与子包锁文件中的 Pi 版本。
- pi-subagents 适配 Pi 1.3:mention clone 改走 systemPromptOverride、
  BOM frontmatter、e2e 读取规范化后的 system 消息与工具列表。
- 根测试不再写死 ssh2 / jiti / better-sqlite3 / context-mode 版本号,
  改为精确版本格式与锁文件一致性断言;新增 latest 约束测试。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 19:38:54 -07:00
74 changed files with 8133 additions and 12508 deletions
+44 -307
View File
@@ -1,319 +1,56 @@
# 仓库说明 # 仓库说明
本仓库用于维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一的组合包安装入口;根 `install.sh`、`update.sh` 和 `uninstall.sh` 只是对 `pi install` / `pi update` / `pi remove` 的便捷包装,不形成第二套部署机制。依赖、默认行为和必要配置必须由组合包自身声明或部署,不把安装后的手工配置作为默认流程。每个自维护扩展使用独立的顶层目录,扩展源码、测试和说明应保留在各自目录内。 本仓库维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一的组合包安装入口;`install.sh` / `update.sh` / `uninstall.sh` 只是 `pi install` / `pi update` / `pi remove` 的便捷包装,不形成第二套部署机制。仓库 `config/` 是组合包唯一配置来源,扩展运行时直接读取已安装组合包内的文件,不复制、不播种、不迁移;仅外部 npm 扩展固定读取 agent 目录路径时(当前 `hashline.json`、`lsp.json`)才允许加载时从 `config/` 覆盖写入。每个自维护扩展使用独立顶层目录;各扩展的维护约定见各自目录的 `AGENTS.md`(`extensions/` 下 wrapper 见 `extensions/AGENTS.md`),上游来源记录见各自 `UPSTREAM.md`(中文维护)。
## 当前扩展 ## 扩展清单
- `pi-rtk-optimizer/`:默认只负责非搜索工具输出压缩;RTK 命令改写为可选能力。 | 目录 / 文件 | 职责 |
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer> | --- | --- |
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。 | `pi-rtk-optimizer/` | 默认只压缩非搜索工具输出;RTK 命令改写为可选能力 |
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。 | `pi-ssh/` | 纯 `ssh2` 持久远程连接、远端工作区、SFTP 与有界自适应搜索 |
- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,以 `ssh_cd` 显式维护远端工作区,并以 SFTP 与有界自适应搜索提供独立的远端工具;运行时不调用 OpenSSH 或 `sshpass`。 | `pi-permission-system/` | 工具/路径/MCP/Skill/子代理转发权限基线,内置 AutoReview 与人工 `defer` 兜底 |
- 上游来源:<https://github.com/pansapiens/pi-ssh> | `pi-subagents/` | Claude Code 风格隔离子 Agent:后台执行、steering、resume、嵌套与自定义 Agent |
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`(2026-06-23,`0.7.0`)。 | `pi-web-search/` | 统一 `web_search` / `web_fetch`,内部路由 Tavily/Exa/Keenable,对模型隐藏厂商 |
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`。 | `pi-chrome/` | 显式授权接入已登录 Chrome Profile,授权后提供 21 个 `chrome_*` 工具 |
- `pi-permission-system/`:统一提供工具、路径、MCP、Skill、子代理转发、独立 JSONC 硬防御、普通策略、内置 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底。 | `pi-tool-search/` | 校验过的工具目录 + 固定工具 + 有界动态组 LRU,按需加载原始 schema |
- 权限系统上游来源:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>;初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。 | `pi-ask-user/` | `ask_user_question`:模型主动发起的单题/多题/选择/文本提问(TUI + RPC) |
- AutoReview 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>;初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。 | `pi-worktree/` | `/wt`(`/worktree`)git worktree 斜杠命令,不注册模型工具 |
- 原 AutoReview 源码已整合进 `pi-permission-system/src/auto-review/`,不再保留或加载第二个顶层扩展;其 MIT 许可与来源记录保留在 `pi-permission-system/LICENSES/` 和 `pi-permission-system/docs/auto-review/`。该目录从明确 tag/commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 | `pi-hermes-memory/` | Hermes 风格持久记忆:policy-only 注入、六记忆工具、`skill_manage` |
- `pi-subagents/`:提供 Claude Code 风格的隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent。 | `pi-minimal-footer/` | 紧凑 footer:上下文仪表、订阅用量条、扩展状态 |
- 上游来源:<https://github.com/tintinweb/pi-subagents> | `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 |
- 初始导入快照:`3f9d35cd078d18a141eb5a6d8f4fc5010d756280`(`v0.18.0`)。 | `pi-compaction/` | 增量 Session segment、确定性压缩、后台语义摘要、effective token、native compaction |
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留上游 `.pi` 状态;源码、测试、wrapper 与权限集成实现继续保留,但当前不属于根组合包运行时依赖,`extensions/subagents.ts` 也不在默认扩展加载列表中。 | `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep`/`multi_grep` 与 `@` 补全 |
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 | `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
- `pi-web-search/`:完全自维护的统一在线搜索扩展。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名;`web_search` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择并经统一参数翻译调用,`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存,路由失败或被选厂商报错按可用性顺序降级;`web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily。`clients/` 为纯 `fetch` 直连:Tavily 走 `api.tavily.com`(必须有 key),Keenable 复刻上游 client 保留 keyless `/public` 端点与 SSRF 防护,Exa 直接对 `mcp.exa.ai/mcp` 发 JSON-RPC(缓存 `mcp-session-id`,404 自动重建会话,key 只走 `x-api-key` 头),不使用 `pi-mcp-adapter` 或任何上游扩展包。 | `extensions/context-mode.ts` | 经 `prompt-guard.ts` 加载 context-mode adapter 与 skills(`context-mode-filter.ts` 过滤冲突锚点) |
- `configure.sh search` 与 `scripts/search-auth.mjs`:以交互或参数方式把 `tavily` / `exa` / `keenable` 三条 `api_key` 凭据写入 Pi 的 `auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`);加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 导入。`pi-web-search/auth.ts` 每次调用读取并复刻上游解析语义(`!command` 执行、`$VAR`/`${VAR}` 插值、credential `env` 优先于进程 env、`$$`/`$!` 转义);显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 | `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp -p <root>` 子进程,经 `extensions/codegraph-client.ts` 的组合包自有 stdio 逐行 JSON-RPC 客户端直连(不依赖 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭,调用中子进程死亡则换新客户端透明重试一次;未初始化项目先经 `ctx.ui.confirm` 确认再 `codegraph init` 并自动重试,拒绝记入会话级 declined 不再重复询问;所有失败(未授权、init 失败、spawn ENOENT、调用失败)都以文本打回让模型改用 `find`/`grep`/`read`/LSP,绝不抛出。`codegraph serve` 可能启动的上游 daemon(`.codegraph/daemon.sock`)在子进程退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。 | `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
- `extensions/plugin-init.ts`:已移除;原 `/plugin_init` 的 CodeGraph 初始化职责由 `extensions/codegraph.ts` 在首次 `codegraph_explore` 调用内按需确认完成。 | `extensions/lsp.ts` | 物化包内 typescript-language-server、部署 LSP 配置并加载 pi-lsp(`lsp-config.ts`) |
- `extensions/context-mode.ts`:经 `extensions/prompt-guard.ts` 代理加载上游 `context-mode` Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性;`extensions/context-mode-filter.ts` 过滤其 context 注入中与本组合包路由重复且冲突的锚点段。 | `extensions/prompt-guard.ts` | 第三方扩展代理 API:丢弃 `systemPrompt` 返回、应用 `tools.jsonc` description 覆盖、context 过滤器 |
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 | `extensions/subagents.ts` | pi-subagents 加载 wrapper,注入权威 `permission-system.ts` 为 mandatory extension |
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。扩展运行时直接读取已安装组合包内的 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `<agentDir>/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。 | `extensions/context-view.ts` | 只读 `/context` observer,消费 pi-compaction projection metrics |
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。 | `extensions/system-prompt.ts` | 唯一系统提示词出口(`system-prompt-config.ts` 为纯 helper),排在最后 |
- `extensions/prompt-guard.ts`:第三方扩展的代理 `ExtensionAPI`——丢弃 `before_agent_start` handler 返回的 `systemPrompt`(保留其他字段),在 `registerTool` 时应用 `config/prompt/tools.jsonc` 的 `description` 覆盖,并可对 `context` handler 的结果套用过滤器;`extensions/hashline.ts`、`extensions/fff-override.ts`、`extensions/lsp.ts` 与 `extensions/context-mode.ts` 均通过它加载上游扩展。 | `configure.sh` + `scripts/` | 交互式写入搜索 key(`auth.json`)与 SSH 主机导入 |
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后。每轮读取 `config/prompt/`(`preamble.md` 开场白、`tools.jsonc` 工具简介与规则、`routing.jsonc` 渲染 `<tool_routing>` 分区、`sections.jsonc` 分区顺序),在现代 Pi 上通过修改 `systemPromptOptions` 保留分区结构,旧宿主自动降级为文本拼接,最终返回 `systemPrompt`;检测到 `forceSystemPrompt` 已被设置或存在未登记工具时各提示一次,配置读取失败时警告一次并保持 Pi 默认提示词。提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。 | `install.sh` / `update.sh` / `uninstall.sh` | `pi install`/`update`/`remove` 包装 + 逐项询问的机器级依赖与终端配置 |
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
- 上游来源:<https://github.com/tianrendong/pi-chrome>
- 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。
- 该目录从明确 commit 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物。
- `pi-tool-search/`:从上游 `v0.3.6` 源码导入并由本仓库直接维护;从完整工具定义生成并缓存经过校验的 Tool Cards/工作流分组,通过固定工具 + 有界动态组 LRU 加载当前 Pi 的原始 schema。
- 上游来源:<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-worktree/`:完全自维护(无上游)的斜杠命令 git worktree 管理扩展,提供 `/wt`(别名 `/worktree`)的 `new` / `merge` / `switch` / `list` / `remove` / `prune` 子命令,实现「一键拆分」与「一键合并」;不注册模型可调用工具。
- 状态登记位于 `<agentDir>/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入),不放 `<agentDir>/my-pi/` 下;Pi 不能修改进程 cwd,会话切换通过 `SessionManager.forkFrom`(或为空会话写入最小 header)生成 cwd 指向目标目录的新会话文件再 `ctx.switchSession` 重建运行时,切换后的收尾只能在新 `ctx` 的 `withSession` 回调中执行。
- 合并流程固定为先确认、再 rebase 到来源分支、最后默认 `--ff-only` 合并(`--squash` 可选压缩),冲突时可选择中止或保留后用 `/wt merge --continue` / `--abort` 处理,来源改动用 stash 兜底,永远不会 push;登记文件写入走 `state.json.lock` 锁文件串行化;worktree 根目录与分支前缀由组合包 `config/pi-worktree.json` 的 `root` / `branchPrefix` 配置。
- 来源 checkout 的 `.pi/worktree.json`(`version: 1`)声明环境复刻:`copy` 只复制未跟踪文件、`link` 只软链 gitignore 目标、`env` 渲染为 `600` 权限环境文件、`postCreate` 仅在 `ctx.isProjectTrusted()` 时以 argv 形式 `execFile`(绝不走 shell),任一失败即停并可用 `/wt reopen` 增量重跑;未被 `.gitignore` 覆盖的软链/环境文件写入 `.git/info/exclude`(仓库各 worktree 共享),保证 `/wt merge` 不会提交它们。
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
- `pi-hermes-memory/`:从官方 `pi-hermes-memory` 仓库导入并由本仓库直接维护的 Hermes 风格持久记忆扩展;提供全局/项目/用户/失败四类记忆、SQLite FTS5 trigram 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测、自动 consolidation、`STANDING.md` 常驻指令和 6 个记忆工具。
- 上游来源:<https://github.com/chandra447/pi-hermes-memory>
- 初始导入快照:`df624056a2e8c68bc89ba0a81e1c120856ec9665`(`v0.9.10`)。
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、上游 `.github` 工作流或构建产物。
- `pi-minimal-footer/`:基于上游紧凑 footer,保留上下文仪表、订阅用量条和扩展状态。
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
- `pi-notify/`:基于 `@smoose/pi-notify` 的 Kitty 优先通知扩展,使用 OSC 99 精确聚焦来源 window/pane,在 Pi 完全 settled 后发送完成通知,并在 `permissions:ui_prompt` 表示即将请求人工权限确认时立即通知。
- 上游来源:<https://github.com/smoosex/pi-notify>
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
- `pi-compaction/`:统一管理增量 Session segment、确定性工具结果压缩、后台语义摘要、effective token 口径、`context_tree_query`、portable checkpoint 与 Codex native compaction;普通 Provider、native compaction、footer 和本地 context observer 必须消费同一个不可变 projection snapshot。
- 上游参考一:<https://github.com/jjuraszek/pi-condense>,快照 `59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。
- 上游来源二:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-codex-compaction>,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。
- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和组合包 `config/pi-compaction.json` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。
- 根包还固定安装 `pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-compaction`、`pi-hermes-memory`、`pi-minimal-footer`、`pi-ssh`、`pi-tool-search`、`pi-web-search` 与 `pi-worktree` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。
- `install.sh`:先安装根组合包(组合包配置即随包内的 `config/` 就位,不做额外部署),然后交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
- `update.sh`:先通过 `pi update` 升级根组合包,成功升级后把默认 git 来源的已安装组合包 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset,不做 `git clean` 以保留 `node_modules`),再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;`pi update` 失败时 fail-fast 停止——Pi 1.3.0 会在下一次 `pi update` 时依据其 `.pi-update-incomplete` 标记自行重跑 `git clean -fdx` 与依赖安装完成自愈,本脚本不做人工恢复。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
- `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。
## 当前职责与默认行为 ## 全局硬规则
- FFF 独占本地字面搜索;远端服务器由独立的 `ssh_find` / `ssh_grep` 结构化工具在已授权调用内自适应使用现有命令。RTK 不得处理本地或远端搜索工具的调用/结果,也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。 - 系统提示词单一所有权:`extensions/system-prompt.ts` 排在 `pi.extensions` 最后,是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展;其他扩展只能经 `systemPromptOptions.sections.<name>` 贡献分区,第三方扩展必须经 `extensions/` wrapper + `prompt-guard.ts` 加载,新增工具必须在 `config/prompt/tools.jsonc` 登记。宿主传入 `customPrompt`(子 Agent / SYSTEM.md)时不覆盖其开场白;子 Agent 有独立提示词画像 `config/prompt/subagent/`(preamble + 内置 Agent),判定规则与路由 scope/`subagentExclude` 细则见 `extensions/AGENTS.md`。
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合和兜底截断;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。 - 加载顺序:`permission-system` → `subagents` → `pi-ssh` → `pi-chrome` → `pi-tool-search` → `system-prompt`(最后);`pi-hermes-memory` 必须排在 `pi-compaction` 之前(`session_before_compact` 取消语义),`pi-compaction` 在 `pi-minimal-footer` 与 `context-view` 之前。
- `commandRewritingEnabled` 默认 `false`。默认安装不依赖系统 `rtk` CLI;只有用户主动开启命令改写时才需要 `rtk rewrite`。 - 依赖:外部依赖在根 `package.json` 用精确版本,自维护扩展一律 `file:` 本地依赖并更新锁文件;Pi 核心包只作 optional host peer,根 runtime deps 不含 Pi runtime,任何目录不得再装一套 Pi runtime;各子包用于测试的 `@earendil-works/*` devDependencies 一律写 `"latest"`,经 `scripts/update-pi-devdeps.sh` 统一刷新;原生构建依赖只按锁定版本进根 `allowScripts`(当前仅 `better-sqlite3@12.11.1`;pi-hermes-memory 的 `better-sqlite3@13` 用包内 N-API 预编译,不新增放行)。不要用仓库级 `.pi/settings.json` 代替组合包依赖。
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。 - 配置:`config/` 下全部文件是权威源、必须提交;不得读写 `settings.json` 自定义段、agent 目录独立配置文件或以环境变量作持久默认值;`config/` 只放机器无关值,路径类默认值在代码中运行时计算。`<agentDir>/my-pi/` 只放可丢弃运行时状态(日志、缓存),连同旧 `extensions/pi-permission-system/` 残留、构建产物与依赖目录都不提交;用户数据(记忆、会话、`auth.json` 等)不属于组合包配置,也不得放入该目录。
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。 - 组合包不加载任何 MCP adapter/SDK:Exa 与 CodeGraph 均用自有直连客户端。
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。 - 导入/同步上游:只从官方 Git 仓库的明确 tag/commit 取源码快照,禁止 npm tarball/缓存/`node_modules`/`dist/` 等预编译产物作导入源;保留上游 LICENSE、作者、测试与来源说明,移除嵌套 `.git`、`.pi` 状态、`node_modules` 与构建产物;同步前先核对本地改动再按明确范围移植,不得整体覆盖。各目录的维护约定见 `AGENTS.md`,每个自维护目录必须有中文 `UPSTREAM.md` 记录来源,上游原文文件不翻译不改写。
- `chrome_snapshot`、`chrome_find` 以及带 `includeSnapshot=true` 的交互工具必须把完整结构化快照写入当前项目 `.pi/chrome-context/` 下的 Session 临时文件,工具结果只返回相对路径与有界摘要;模型必须立即用 `ctx_execute_file` 提取下一步所需信息,不得用 `read` / `cat` 把原始 JSON 重新塞入上下文。每个 Session/目标只保留最新快照,页面交互后旧快照视为失效,Session shutdown、撤销或授权过期时清理。 - 脚本:`install.sh`、`update.sh`、`uninstall.sh`、`configure.sh` 与 `scripts/lib.sh` 保持 POSIX `sh` 兼容并留在 `files` 中,行为变化同步 README;安装默认来源 `git:git@bitbucket.org:siakitem/my-pi.git`,可由 `PI_PACKAGE_SOURCE` 覆盖(update/uninstall 同规则);组合包安装失败立即停止,机器级依赖失败继续检查其余项并最终返回非零。
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。 - 安装/升级纪律:机器级依赖与终端配置逐项询问、默认拒绝;`update.sh` 只升级已安装项、缺失项跳过,组件升级先比较本地与远端版本、一致不重装,成功升级后把 git 来源的受管 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset、不 `git clean`);`pi update` 失败 fail-fast——Pi 1.3.0 会据 `.pi-update-incomplete` 标记在下次 update 自愈,不做预清理或人工恢复。`pi update` 只在有新版本时整体覆盖已安装副本(含 `config/`),不逐字段迁移本机改动;需长期保留的自定义必须改仓库 `config/` 源文件。`.zshrc` 只改 `# >>> my-pi:<id> >>>` 受管块并先建时间戳备份,不覆盖用户 `~/.p10k.zsh`;卸载不得删除用户记忆数据(`.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/`)或共享机器级工具。
- 工具路由规则由 `config/prompt/routing.jsonc` 管理、`extensions/system-prompt.ts` 渲染为 `<tool_routing>` 分区,不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索由 `pi-web-search` 内部路由,模型不感知厂商:路由规则为广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable;路由决策由当前主模型做出,确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。 - 未经明确要求不执行发布、提交、推送、运行安装/卸载脚本、安装到用户 Pi 目录等外部写操作;凭据不进入命令参数、日志、会话或仓库(`auth.json` 权限 `600`、不提交)。
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具支持交互式 TUI 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。 - 优先在目标扩展目录内完成改动;保留原项目 LICENSE 与版权;不让一个扩展依赖另一个扩展的未公开内部实现。
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
- 标准组合包工具全部命中预置目录时 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 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
- `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
- CodeGraph 是机器级依赖:`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后,扩展才可以 `codegraph init` 当前目录,拒绝结果在本会话内记住不再询问。init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。运行时每个索引根惰性常驻一个 `codegraph serve --mcp` 子进程(闲置 10 分钟关闭、会话结束统一关闭),其启动的上游 daemon 退出后可多驻留 ≤5 分钟属上游行为。
- `codegraph_explore` 为原生 Pi 工具而非 MCP 工具:每个索引根由 `codegraph serve --mcp -p <root>` 子进程服务,经组合包自有 stdio JSON-RPC 客户端收发(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 的文件监听自动同步索引,无结果缓存。子进程死亡导致调用失败时换新客户端透明重试一次;CLI 缺失、init 被拒绝或服务调用失败都不得抛出或阻止其他工具,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。
- 组合包不加载任何 MCP adapter 或 MCP SDK;Exa 由 `pi-web-search/clients/exa.ts` 以纯 `fetch` JSON-RPC 直连托管端点 `https://mcp.exa.ai/mcp`:`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;CodeGraph 由 `extensions/codegraph-client.ts` 以 stdio 逐行 JSON-RPC 直连 `codegraph serve --mcp`。`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
- 在线搜索对模型只暴露 `web_search` / `web_fetch` 两个统一工具名,不得重新引入按厂商命名的工具;`web_search` 必须经 `router.ts` 的主模型分类选择 provider,`web_fetch` 固定优先 Exa。Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 auth.json 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 与 `$VAR` 间接寻址,`configure.sh search` / `search-auth.mjs` 只输出配置状态,禁止回显 key。旧的 `search.env` 不再被读取,仅由 `--migrate` 一次性导入。
- `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。
- `pi-compaction` 负责已经进入 Session 的历史投影,与 Context Mode 的输入隔离职责互补。首次 Session hydration 允许一次完整扫描;后续普通 `context` hot path 必须复用不可变 snapshot 或只处理 delta,不得调用 summarizer、写 Session 或重新构建完整 branch。
- 已闭合工具结果先同步生成保留工具名、参数、状态、首尾证据和 source key 的有界确定性 stub;大于 `semantic.minInputChars` 的结果再进入单 Session 有界后台语义 worker。后台结果只在安全事件边界提交,陈旧 hash 必须丢弃,Provider 请求不得等待后台摘要。
- effective token 默认使用 Provider usage-backed baseline 加 projected delta;无可靠 baseline 时使用带上下界的估算。Pi Core raw threshold 只负责唤醒,只有 effective lower bound 达到 `hardRatio` 才执行 threshold compaction;manual 与 overflow 不得被阻断。
- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。配置只从组合包 `config/pi-compaction.json` 读取且不写回 `settings.json`,默认 soft/hard/target 比例为 72%/90%/55%。
- Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
- 权限策略默认允许常规工具,允许 FFF 工具,并放行 `ls`、LSP 查询(`lsp_definition`/`lsp_references`/`lsp_hover`/`lsp_symbols`)、`ctx_search`/`ctx_index`/`ctx_stats`/`ctx_doctor`、`web_search` 与 Chrome 只读检查工具;`web_fetch`、`ctx_fetch_and_index` 与 `skill_manage` 保持 `ask`(模型自选的 URL 可能外传数据,skill_manage 写入的 skill 会进入所有后续会话);拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 只读管道命令(`blame`/`describe`/`rev-list`/`merge-base`/`ls-tree`/`cat-file`/`name-rev`/`shortlog`/`stash list`/`worktree list`/`tag -l|--list`/`config --get|--list`)放行,其余非只读操作、包管理、文件/系统/网络高风险操作与内置 MCP 的 `mcp__<server>__<tool>` 调用按唯一全局 policy 判定。项目外路径不再产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的默认 `input.path` 归一化。
- `auto-review` 是 `pi-permission-system` 内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。
- reviewer 是对话式 Codex Guardian 风格审查:以独立请求(不写入主会话)把有界 active-branch 对话记录与权限请求交给模型,模型只能通过 `approve` / `reject` 两个工具给出带 `reason` 的结论,不强制 `toolChoice`。`approve` 自动批准(包括 `path`);`reject` 以及配置、模型、超时、Provider 错误、截断、未调用工具、多次调用或参数无效等一切异常都必须 `defer` 到人工提示,并在弹窗中显示原因(模型理由标注为模型生成)。正文文本即使是合法 JSON 也不得作为结论;只有用户按 Esc 中止当前操作时才直接拒绝。不得在 authorizer chain 外增加第二层内置授权范围限制,也不得恢复 annotator 机制。唯一例外是 `protectedWrites`:硬防御文件里配置的确定性 human-only 写路径——它不经 authorizer chain(含 AutoReview)、yolo 或 Session grant,无确认界面时直接拒绝,本身不产生裁决,只是永远把人拉进回路。
- reviewer 默认跟随会话当前主模型(每次审查实时读取 `ctx.model`),经 `modelRegistry.streamSimple(...).result()` 调用;`autoReview.provider` / `autoReview.model` 必须同时设置(固定审查模型)或同时留空,只设其一视为配置错误并 `defer`。默认 `reasoning: "low"`(仅对支持推理的模型生效)、总超时 30 秒、最多 2 次尝试(仅 Provider 错误重试)、`maxOutputTokens` 4000。只把 active branch 中的直接用户消息与已识别、未取消的结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权;工具结果可替换为更短的 pi-compaction 摘要,但仍属不可信证据。同一条用户消息内完全相同的请求只缓存 `approve` 结论。
- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,不设 provider/model 种子值,使审查默认跟随主模型;`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`,以及 `protectedWrites` 人工写保护清单。
- `protectedWrites`(`config/pi-permission-hard-guards.jsonc`)是确定性的 human-only 写路径,不属于普通策略也不是第二个授权范围:命中目标的 `write`/`edit` 与本地 `bash` 写入(redirect-out、写操作程序参数、`bash -c`/`eval` 等包装载荷中的受保护路径)一律弹出人工确认,跳过 AutoReview、yolo 与既有 Session grant,弹窗不提供「本会话允许」选项;没有可用确认界面(无 UI、子代理转发终端)时直接拒绝。读取不受影响;`ssh_bash` 等远端 shell 工具不参与本机路径保护。
- `pi-lsp` 的 TypeScript/JavaScript 后端由根包固定依赖提供,`extensions/lsp.ts` 使用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 仍使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件,便捷脚本可在用户逐项明确确认后通过 Homebrew 安装 Kotlin LSP、Java 21 和 JDT LS。
- 终端环境配置只属于便捷脚本:Kitty 可选安装后可通过官方 kitten 启用 Solarized Dark;Oh My Zsh 使用不切换 shell 的 unattended 安装;Powerlevel10k 将仓库内置的 `config/p10k.zsh`(Rainbow/ASCII 单行紧凑主题)部署到 `~/.config/my-pi/p10k.zsh`(遵循 `XDG_CONFIG_HOME`),不得覆盖用户自己的 `~/.p10k.zsh`。`.zshrc` 中脚本拥有的内容必须使用 `# >>> my-pi:<id> >>>` / `# <<< my-pi:<id> <<<` 受管块,更新时只替换块内内容;标记不完整或重复时拒绝修改。实际修改前必须创建带时间戳的备份。`git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 在安装流程中逐项询问后才安装或启用。
- 原生远程压缩限定为 `openai-codex` + `openai-codex-responses` 与 `sub-api` + `openai-responses`,默认在 turn boundary 达到 effective 下界 90% 时触发。`pi-compaction/src/native-provider.ts` 统一资格与传输选择:Codex 保留原 endpoint/JWT/account ID;SubAPI 从显式 `baseUrl` 解析公共 `/responses`,使用配置的 API key 和请求头,不解析 ChatGPT account ID、不硬编码网关域名,也不调用独立 `/responses/compact`。两者复用 projection、`compaction_trigger`、SSE 结果、native checkpoint 恢复和 portable 模型切换摘要;SubAPI 部署须支持原生 compaction v2。普通聊天支持不代表压缩支持,失败回退 Pi,不自动改写用户模型配置。
- SubAPI 原生压缩认证必须保留非空显式 `Authorization`(大小写不敏感)的优先级;没有该头时才使用非空 API key 生成 Bearer,均缺失时可接受非空 `cf-aig-authorization` 代理认证头。不得要求 header-only 配置额外提供假 API key,也不得生成 `Bearer undefined`;空白或缺失凭据在网络请求前拒绝并回退 Pi。Codex 仍必须提供有效 JWT,不能用请求头绕过。
- 记忆由 `pi-hermes-memory` 统一负责:默认 `policy-only` 模式只注入 `<memory-policy>` 指令而不注入记忆正文,因此它不改变 system prompt 前缀、对 DeepSeek 前缀缓存友好;模型需要历史上下文时主动调用 `memory_search` / `session_search`,写入用 `memory_add` / `memory_replace` / `memory_remove`,过程记忆用 `skill_manage`。
- 六个记忆工具与 `context_tree_query` 必须由 `pi-tool-search` 的 `alwaysEnabled` 常驻,不得放入动态组:注入的 policy 会在普通工作中指示模型调用记忆工具,而恢复被投影的工具证据也不能依赖 `tool_search` 激活;常驻同时保持请求前缀稳定。它们只写扩展自己的 agent 目录数据或只读本地索引,不触碰工作区,因此在权限策略中默认 `allow`,避免每轮 AutoReview 往返。
- `pi-hermes-memory` 必须注册在 `pi-compaction` 之前。`ExtensionRunner.emit()` 在第一个返回 `{ cancel: true }` 的 `session_before_compact` handler 处立即停止,而 `pi-compaction` 在判定无需压缩时正是这样返回;注册在其后的 handler 会被跳过,从而静默禁用 `flushOnCompact`。
- `pi-hermes-memory` 依赖 `better-sqlite3@13`,运行时实际 `dlopen` 的是包内 N-API 预编译二进制 `prebuilds/<platform>.node`,不依赖 node-gyp 编译产物。npm 会因该包存在 `binding.gyp` 而推断出隐式 `node-gyp rebuild`,该步骤既无必要也不得批准;根 `allowScripts` 必须保持不新增条目,同时保留既有的 `better-sqlite3@12.11.1` 放行。
- `skill_manage` 是 `pi-hermes-memory` 提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。
- `install.sh` 默认从 `git:git@bitbucket.org:siakitem/my-pi.git` 安装,并允许用 `PI_PACKAGE_SOURCE` 覆盖来源;组合包安装失败时立即停止,机器级依赖安装失败时继续检查其余依赖并最终返回非零状态。
- CodeGraph 的可选安装使用官方远程安装脚本;macOS Kitty 使用 Homebrew cask,其他系统使用 Kitty 官方安装器;Kotlin LSP、Java 21 和 JDT LS 使用 Homebrew;Oh My Zsh 使用官方安装器,Powerlevel10k 与两个第三方 Zsh 插件从各自官方 Git 仓库克隆。升级时 Git checkout、Homebrew 包、Kitty 官方安装和 CodeGraph 都必须先比较本地与远端版本,版本一致时不得重新下载安装;无法可靠比较时保留现状并报告。缺少 Homebrew、curl 或 git 时保留已经完成的组合包安装并报告失败。
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件;单独的 `pi update` 只在新版本存在时整体覆盖已安装副本(含 `config/`),`update.sh` 每次成功升级后都会把受管 checkout 还原到已发布版本,两者都不逐字段迁移或合并本机对已安装副本的改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。
- `<agentDir>/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不存放配置;组合包配置一律以仓库 `config/` 为唯一来源并由扩展直接读取。用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外(扩展加载时从 `config/` 覆盖写入);旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。
## 系统提示词单一所有权
- `extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后,也是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展。提示词内容统一由 `config/prompt/` 管理:`preamble.md`(开场白)、`tools.jsonc`(每个工具的简介、规则及第三方工具说明覆盖)、`routing.jsonc`(跨工具工作流规则,渲染为 `<tool_routing>`)、`sections.jsonc`(分区顺序)。
- 其他组合包扩展不得返回 `systemPrompt`,也不得在 `context` 钩子中注入提示性消息;需要动态提示时只能写入 `event.systemPromptOptions.sections.<name>` 并在 `sections.jsonc` 登记顺序,静态规则写入 `promptGuidelines` 或该工具在 `tools.jsonc` 中的条目。
- 第三方扩展必须经 `extensions/` 下的组合包包装文件加载,由 `extensions/prompt-guard.ts` 的代理 API 拦截其对系统提示词的直接改写,并在注册工具时应用 `tools.jsonc` 的 `description` 覆盖;上游硬编码且无配置开关的注入只能在对应包装文件内处理,并以测试锁定所依赖的上游原文(依赖保持精确版本)。
- 新增工具必须在 `tools.jsonc` 登记(覆盖说明,或以 `"inherit": true` 表示已按本组合包审阅上游原文),否则 `tests/system-prompt.test.ts` 失败;`config/prompt/` 中的文字不得引用被策略禁止或被替换的用法。运行时若检测到其他扩展已定死提示词或存在未登记工具,`system-prompt.ts` 会各提示一次。
## 组合包配置原则
- 本组合包是私有组合包,所有机器上的组合包配置必须一致。组合包自有扩展的全部配置只以仓库 `config/` 为唯一来源,扩展运行时直接读取已安装组合包内的 `config/` 文件,不复制、不播种、不迁移到 agent 目录。
- 配置变更一律修改仓库 `config/` 并随组合包发布;`pi install` / `pi update` 即完成部署,便捷脚本不得再承担配置部署。某台机器上对已安装副本的临时修改(包括扩展设置界面的保存)不做保护、合并或提示:单独运行 `pi update` 只在有新版本时覆盖,`update.sh` 每次成功升级后都会把受管 checkout 还原到已发布版本。
- 新增或导入扩展时,其可配置项必须放进 `config/` 下的独立文件;不得读写 Pi `settings.json` 的自定义段、agent 目录下的独立配置文件,也不得以环境变量作为持久默认值。只有外部 npm 扩展固定读取 agent 目录路径时(当前为 `hashline.json`、`lsp.json`),才允许在扩展加载时从 `config/` 覆盖写入该路径。
- `config/` 只放与机器无关的值;依赖本机路径的默认值(home 下的目录、可执行文件绝对路径等)保留在代码中运行时计算。
- `<agentDir>/my-pi/` 只存放可随时丢弃的运行时状态(日志、缓存),不得存放配置。用户数据(记忆、会话、`auth.json`、`models.json`、凭据库)不属于组合包配置,不受本节约束,也不得放进 `config/`。
## 修改边界
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
- agent 目录下扩展运行时产生的数据——`<agentDir>/my-pi/` 可丢弃运行时目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/` 下的全部文件(`pi-permission-policy.jsonc`、`pi-permission-hard-guards.jsonc`、`tool-search.json`、`rtk-optimizer.json`、`pi-compaction.json`、`pi-worktree.json`、`pi-hermes-memory.json`、`prompt/preamble.md`、`prompt/tools.jsonc`、`prompt/routing.jsonc`、`prompt/sections.jsonc`、`pi-hashline-edit.json`、`lsp.json`、`p10k.zsh` 等)是组合包权威源配置,必须提交并维护。
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
- 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。`pi-hermes-memory` 的 `better-sqlite3@13` 运行时使用包内 N-API 预编译二进制,npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 无需执行,不得为此新增放行条目。
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
- 新引入或同步自维护扩展时,必须先联网确认其官方源码仓库地址,再从官方 Git 仓库的明确 tag/commit 拉取源码快照;禁止以 npm tarball、npm 缓存、`node_modules`、`dist/` 或其他预编译发布产物作为导入源。npm registry 只能用于核对包名、版本和发布元数据,不能替代源码仓库。
- 源码导入必须保留上游源码、测试、必要文档、许可证和来源记录,移除嵌套 `.git`、上游本地 `.pi` 状态、`node_modules`、覆盖率与构建产物,并在本文件和扩展来源说明中记录官方仓库、tag、commit 与版本。
- 每个自维护扩展目录必须保留 `UPSTREAM.md` 来源记录,并且**必须用中文维护**:记录官方仓库、tag/commit、导入版本、导入日期、许可证归属、本地改动清单,以及同步上游时的约束。`UPSTREAM.md` 是仓库自有的维护文档,不是上游产物;上游自带的源码、测试、文档、README、CHANGELOG 与 `LICENSE` 一律保持上游原文,不翻译、不改写。新增扩展或同步上游导致来源信息变化时,必须同步更新该文件。
- `pi-notify/` 的根依赖必须保持 `file:./pi-notify`,组合包直接加载 `pi-notify/src/index.ts`;不得同时安装或加载 npm 预编译入口,否则会重复通知。
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
- `install.sh`、`update.sh`、`uninstall.sh`、`configure.sh` 和 `scripts/lib.sh` 必须保持 POSIX `sh` 兼容,前者包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
- 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。
## 根目录 TypeScript 与 LSP 验证约定
- 根目录当前有意不安装 Pi runtime,也没有根级 `tsconfig.json`、`devDependencies` 或 `@types/node`;`@earendil-works/pi-*` 只作为 optional host peer dependencies。TypeScript LSP 因而会把根目录 `.ts` 文件放入 inferred project,不能把宿主 Pi 的运行时解析能力等同于仓库内静态类型环境。
- 每个任务首次对相关根目录 TypeScript 文件调用 `lsp_diagnostics` 时先建立并记录基线;后续只关注相对基线新增、消失或位置变化的诊断。文件和类型环境都未变化时不得重复调用 LSP 并重复报告同一组结果。
- 当前可预期的基线包括:宿主 peer 缺失导致的 `TS2307`(例如找不到 `@earendil-works/pi-coding-agent`),Node 类型缺失导致的 `node:*` / `node:test` / `node:assert` `TS2591`,以及由这些上游类型缺失级联产生的 `TS7006`、`TS18048`、`TS2722` 等。遇到级联错误时先确认其是否随缺失类型而产生,不要把它们直接归因于本次实现。
- 已知基线不等于忽略所有诊断:凡是无法由上述缺失依赖解释的新语法错误、结构类型错误、错误属性访问或本次修改所在代码的新诊断,必须修复并重新验证。交付时应把“已知类型环境基线”和“本次新增诊断”分开说明。
- 不得为了消除 LSP 基线而在根包安装第二套 Pi runtime、加入机器相关的宿主绝对路径或提交只在单机有效的 `paths` 映射。若确需新增根级 `tsconfig.json`、`@types/node` 或其他开发类型环境,必须作为明确的仓库设计变更评估,并同步更新依赖、锁文件和本文档。
- 不要从泛型或重载的 `ExtensionAPI` 方法直接用 `Parameters<...>` 猜取具体工具定义;解析失败时它可能退化为 `unknown`。包装第三方工具时优先使用上游导出的具体类型;没有稳定导出时定义覆盖实际访问字段的最小结构类型,并用运行时集成测试验证。
- `node --test` 可直接测试仓库内的纯 TypeScript helper,但测试入口不得静态导入 `node_modules` 中的 `.ts`;Node 原生 type stripping 会对这种路径报 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`。第三方 TypeScript 扩展 wrapper 应拆出本地纯 helper 做单元测试,并通过隔离的临时 Pi agent/jiti 加载验证真实扩展入口。
- 对根目录扩展的最终验证优先组合使用:纯 helper 的 `node --test`、`git diff --check`、所需 npm 锁文件检查,以及隔离 Pi 环境的扩展加载。LSP 启动成功、单元测试通过和真实 Pi 加载通过是不同证据,不得互相替代或把类型环境基线误报为 LSP 后端启动失败。
## `pi-rtk-optimizer` 开发约定
- 组合包运行环境为 Node.js 22.19 或更高版本;RTK 子目录开发验证还需要 npm、Bun 和项目声明的开发依赖。
- 扩展入口是 `pi-rtk-optimizer/index.ts`,主要实现位于 `pi-rtk-optimizer/src/`。
- RTK 命令改写默认关闭;若用户主动开启,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
- FFF override 独占搜索工具与搜索输出;RTK 不得处理 `grep` 工具结果。
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
- 修改配置结构时同步检查默认值、归一化逻辑、设置界面、类型定义、README 示例和相关测试。
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
## `pi-permission-system` 开发约定
- 扩展入口是 `pi-permission-system/src/index.ts`,公共跨扩展服务入口是 `pi-permission-system/src/service.ts`,测试保留在该目录的 `test/`。
- 同步上游必须从明确 tag/commit 移植并记录快照;保留上游 `LICENSE`、作者和来源,不导入 `dist/`、嵌套 `.git` 或上游包目录的 `.pi` 本地状态。
- 配置在 `authorizerChain` 中显式命名的 authorizer 可对任一普通策略 `ask`(包括 `path`)返回最终 `allow` / `deny`;`defer` 才继续下一个 authorizer 或终端人工 authority。
- 修改 gate、authorizer chain、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。
- 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。
- 内置 AutoReview 实现位于 `pi-permission-system/src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。
- reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`。
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;唯一的组合包 hard-guards 文件不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
- 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。
## `pi-hermes-memory` 开发约定
- 扩展入口是 `pi-hermes-memory/src/index.ts`,来源记录是 `pi-hermes-memory/UPSTREAM.md`;只从官方 `chandra447/pi-hermes-memory` 的明确 tag/commit 移植并保留官方 README、LICENSE 和版权。
- 根包必须通过 `file:./pi-hermes-memory` 提供依赖并直接加载仓库源码;不得同时安装或加载 npm 预编译入口,也不得保留第二个记忆扩展(`hippo-memory-pi` 已移除)。
- `package.json` 的 `pi.extensions` 必须把 `./pi-hermes-memory/src/index.ts` 排在 `./pi-compaction/index.ts` 之前,否则 `flushOnCompact` 会被 `pi-compaction` 的 `{ cancel: true }` 静默跳过;根 `tests/extension-load.test.ts` 必须断言该顺序。
- 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻,并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS`、`config/tool-search.json`(二者必须完全一致)、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。
- 依赖必须保持 `better-sqlite3@^13.0.3`:13.x 运行时使用包内 N-API 预编译二进制 `prebuilds/<platform>.node`,不依赖 node-gyp 编译产物,必须能由 npm 嵌套安装并与根 `better-sqlite3@12.11.1` 并存。不得把它降级回 12.x(Node 24 下会在进程退出时 SIGABRT),也不得让根包提升为单一版本。
- `devDependencies` 中的 `@earendil-works/*` 必须与根包当前版本精确一致。若写成 `^0.80.2` 这类与根不兼容的范围,npm 会在本目录嵌套安装第二套 Pi runtime,违反"不得在组合包内再安装一套 Pi runtime"的纪律;`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 同理,必须钉到根已有版本。
- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除;扩展配置位于已安装组合包的 `config/pi-hermes-memory.json`(其中路径类默认值由代码运行时计算),旧 `~/.pi/agent/hermes-memory-config.json` 被忽略。
- 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。
## `pi-compaction` 开发约定
- 扩展入口是 `pi-compaction/index.ts`,增量索引、投影、后台摘要、服务与 Codex native 实现在 `pi-compaction/src/`,测试位于 `pi-compaction/test/`,双上游来源记录是 `pi-compaction/UPSTREAM.md`。
- 根包必须通过 `file:./pi-compaction` 提供依赖并直接加载源码;不得重新声明或加载 `pi-condense`、npm `@ogulcancelik/pi-codex-compaction` 或 npm `pi-context-view`。
- `pi-compaction` 只支持自己的 version 1 记录和 `config/pi-compaction.json` 配置,不得恢复旧状态兼容、自动迁移或读写 `settings.json` 自定义段。
- Context Authority 是 compaction 唯一生产投影者。普通 Provider、Codex native、`/context`、`/compaction-status` 和 footer 必须消费同一 snapshot;background worker 不得在 Provider 请求中被 await。
- Segment custom entry 只能保存摘要、hash 和 source reference,不得复制完整原始工具输出。`context_tree_query` 恢复结果必须有界,native checkpoint 的 portable summary 也必须有界。
- 修改 projection、token accounting、threshold、checkpoint、模型切换、后台并发或恢复索引后,至少运行 `npm test`、`npm run check`、`npm pack --dry-run --json`、根扩展联合加载、packed 根 tarball 隔离加载和性能回归。性能门禁为 cache-hit P95 <2ms、delta projection P95 <10ms,普通 hot path 不得扫描完整 branch。
- 同步 Codex 协议代码必须从官方 `ogulcancelik/pi-extensions` 明确 commit 移植并保留对应 MIT 许可证;不得从 npm tarball、缓存、`node_modules` 或构建产物导入。
## `pi-subagents` 开发约定
- 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、作者和 LICENSE;不得导入嵌套 `.git`、上游 `.pi` 状态、`node_modules`、覆盖率或构建产物。
- 根包当前必须保持禁用 `pi-subagents`:不得声明 `file:./pi-subagents` 或 npm 运行时依赖,也不得在 `pi.extensions` 中加载 `extensions/subagents.ts` 或上游入口;源码、测试、来源记录和 wrapper 继续保留,且不得把 Pi host runtime 作为组合包开发依赖带入根安装。
- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。
- `extensions/subagents.ts` 是当前禁用但保留的组合包 wrapper:若以后经明确决定恢复加载,它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。
- mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。
- `ask` 可由 auto-review 明确 `allow` 时自动批准,包括路径请求;reviewer `defer` 或异常必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
## `pi-notify` 开发约定
- 扩展入口是 `pi-notify/src/index.ts`,Kitty/macOS 后端位于 `pi-notify/src/terminal.ts`,测试保留在 `pi-notify/tests/`,来源记录是 `pi-notify/UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。
- 完成通知默认只在 TUI `agent_settled` 后发送;权限通知只在 `permissions:ui_prompt` 后立即发送。非 TUI 输出完整性、策略/AutoReview 静默决定、隐私模式、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
## `pi-chrome` 开发约定
- 扩展入口是 `pi-chrome/extensions/chrome-profile-bridge/index.ts`,Companion MV3 扩展位于其 `browser-extension/`,Session 快照存储位于 `pi-chrome/src/session-snapshot-store.ts`,测试保留在 `pi-chrome/test/` 与 `pi-chrome/test-suite/`,来源记录是 `pi-chrome/UPSTREAM.md`。
- 同步上游必须从官方 `tianrendong/pi-chrome` 的明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、文档、测试、作者和 LICENSE;不得导入嵌套 `.git`、`node_modules` 或构建产物。
- 根包必须通过 `file:./pi-chrome` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
- 完整 Chrome snapshot 不得进入普通工具文本或 `details`;只能原子写入当前项目 `.pi/chrome-context/<session>/<target>/snapshot.json`,使用哈希目录、POSIX `700` 目录 / `600` 文件,并只返回工作区相对路径和有界元数据。快照不得跨 Session 持久化,Session 启动/关闭、撤销和授权过期必须清理。
- `chrome_snapshot` 和带 `includeSnapshot=true` 的动作必须引导模型立即使用 `ctx_execute_file`,不得使用 `read` / `cat`;任何可能改变页面或目标的成功操作必须使旧快照失效,`chrome_find` 只可额外返回有界匹配摘要。
- 修改 bridge、Companion 扩展、授权 lifecycle、快照存储或加载入口后至少运行包内 `npm test`、根 Tool Routing/Tool Search/扩展联合加载测试、`npm pack --dry-run --json` 和实际 packed tarball 隔离安装验证;未经明确要求不得连接或控制真实 Chrome。
## `pi-ssh` 开发约定
- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`,AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `configure.sh ssh` 与 `pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git`、`node_modules` 或构建产物。
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;认证可使用密码、私钥文件或 `ssh2` 直连的 SSH Agent socket(包括 1Password),Agent 模式只保存 socket 路径且不得导出私钥。OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2` 与 `cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、Agent 提供的签名材料、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
- `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行默认 `input.path` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭本机路径推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
- `ssh_bash` 必须在唯一全局 policy 的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;不得新增第二个并行 `tool_call` 审批层。
- RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read`、`ssh_find` 或 `ssh_grep` 返回。
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。
- 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system` 的 `npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。
- 根加载顺序必须保持 `pi-permission-system` → `pi-ssh` → `pi-chrome` → `pi-tool-search` → `system-prompt`(`extensions/system-prompt.ts` 为最后一个条目),确保内置 authorizer 与权限服务由同一扩展先注册/发布、SSH 桥接随后安装、Chrome 工具可在授权后进入目录、Tool Search 在提示词组装前建立完整工具目录,且单一提示词出口最后汇总所有扩展的结构化贡献。
## `extensions/system-prompt.ts` 开发约定
- 扩展入口是 `extensions/system-prompt.ts`,必须排在 `pi.extensions` 最后;纯 helper(JSONC 加载与校验、`applyToolCatalog` / `renderRouting` / `orderSections` / `spliceCover` / `readPreamble` 等)位于 `extensions/system-prompt-config.ts` 以便 `node --test` 单测;组合包权威源是 `config/prompt/`(`preamble.md` / `tools.jsonc` / `routing.jsonc` / `sections.jsonc`),扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。
- 现代 Pi 下必须通过修改规范化 `systemPromptOptions`(`toolSnippets` / `toolGuidelines` / `sections`)保留分区结构,再读取重渲染的 `event.systemPrompt` 拼入封面并返回 `systemPrompt`;宿主不提供结构化 options 或 options 不参与重渲染时自动降级为文本拼接。不得改用 `customPrompt` 选项——它会抑制 `tools` / `rules` / `docs` 分区。
- 封面拼接边界必须是独占一行的裸标签,不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
- `preamble.md` 缺失、不可读或空白时保持 Pi 默认封面,不得写入空 preamble;`config/prompt/` 解析或校验失败时 `before_agent_start` 警告一次并整体 no-op,绝不抛出。
- 检测到其他扩展已设置 `forceSystemPrompt`、或激活工具未在 `tools.jsonc` 登记时各提示一次(warning);本扩展返回的 `systemPrompt` 把提示词冻结为最终文本是刻意设计——这也是它必须最后加载的原因。
- `config/prompt/` 随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。
- 修改 loader、`spliceCover` 语义、注册顺序或 `config/prompt/` 路径后,必须运行 `node --test tests/system-prompt.test.ts`、`node --test tests/bundle-config.test.ts`、`node --test tests/extension-load.test.ts`,并确认 `npm pack --dry-run` 包含 `config/prompt/`。
## `pi-ask-user` 开发约定
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI、RPC extension UI bridge 与工具注册位于 `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;RPC 必须只依赖 Pi 标准 `select` / `input` extension UI、逐题执行、传递 abort signal,并把 dismiss/abort 归一化为取消。
- 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-system/src/auto-review/transcript.ts` 解析。
- 同步上游示例时从明确 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`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `pi-tool-search/test/`。
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
- 修改 Tool Card 提示词、目录校验、分组、缓存、active-tool/LRU 生命周期、配置或工具结果时,必须同步更新包内 README、当前动态加载文档、CHANGELOG 和测试。
- 新增、删除或重命名组合包暴露工具时必须同步维护 `BUNDLE_GROUP_DEFINITIONS`;日常只读工具、provider 工具和高风险管理工具不得因名称相似而混入同一大组,根验证应确认标准组合包没有落入 unknown-tools 路径。
- 生成目录只能让模型提供摘要、使用边界、关键词与分组;工具名、参数和原始 schema 必须来自宿主。校验或模型调用失败必须 fail-safe 到确定性目录,缓存不得写入仓库或放宽权限。
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现。
## 验证 ## 验证
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证扩展运行时解析到的 `config/` 路径确实位于组合包内。 - 组合包依赖或加载入口变化:验证根 `npm install` 幂等、锁文件有效、全部扩展可在隔离临时 Pi agent 目录加载(见 `tests/extension-load.test.ts`);权限配置变化用包内固定 schema 校验,并确认运行时 `config/` 路径确实位于组合包内。
- 脚本变化:`sh -n` 每个脚本 + ShellCheck,并确认可执行权限、`files` 包含与 README 一致;搜索/SSH 配置测试只用虚拟凭据、隔离 HOME 与 mock transport,不连真实服务器。
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n configure.sh`、`sh -n scripts/lib.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 - 真实 `pi install`/`update`/`remove`、Homebrew、远程安装器、Git 克隆、真实 SSH、真实终端配置的端到端验证属外部写操作,未经要求不执行;用隔离 HOME + mock 验证分支行为。
- 根目录测试:`node --test 'tests/*.test.ts'`、`git diff --check`;各扩展目录的验证命令见其 `AGENTS.md`。未能运行的验证交付时明确说明,不以静态检查代替运行结果。
记忆扩展变化必须额外验证:`pi-hermes-memory` 在隔离临时 Pi agent 目录中可加载且注册 6 个工具、`pi.extensions` 中排在 `pi-compaction` 之前、Tool Search 目录把六个工具归入 `memory-recall` / `memory-management` 且全部常驻、权限策略默认 `allow`、`better-sqlite3@13` 嵌套安装与根 `12.11.1` 并存。不得在测试中连接真实数据库或修改真实 `~/.pi/agent/` 记忆数据。
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。涉及内置 AutoReview 或硬防御时,还要验证 authorizer 对普通工具与 `path` 的 `allow` / `deny` / `defer`、hard-guard precedence、未配置/未知 provider 或模型时的 `defer` 与一次性提示,以及 owner-only 持久化。
在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
在 `pi-compaction/` 内至少运行 `npm test`、`npm run check` 和 `npm pack --dry-run --json`;涉及依赖、加载入口、context writer、footer 或 observer 时还要验证根 `npm install` 幂等、锁文件仅包含预期本地 link 变化、根扩展联合加载、性能回归,以及实际 packed 根 tarball 的隔离安装/加载。
在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
在 `pi-rtk-optimizer/` 内按改动范围选择最小充分验证:
- `npm run build`:TypeScript 转译检查。
- `npm run typecheck`:完整类型检查。
- `npm run test`:运行 Bun 测试。
- `npm run check`:类型、测试和打包检查的完整验证。
若环境缺少依赖或未执行某项验证,交付时明确说明,不以静态检查代替运行结果。
<!-- pi-hermes-memory:start --> <!-- pi-hermes-memory:start -->
## 项目记忆(pi-hermes-memory) ## 项目记忆(pi-hermes-memory)
+1 -1
View File
@@ -18,7 +18,7 @@
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。 - 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。 - 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。 - 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。子 Agent 使用独立提示词画像 `config/prompt/subagent/`(自有 preamble 与内置 Agent 定义),不嵌入父会话提示词。
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。 - 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 - `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
+6
View File
@@ -54,6 +54,12 @@
"ctx_doctor": "allow", "ctx_doctor": "allow",
"tool_search": "allow", "tool_search": "allow",
"ask_user_question": "allow", "ask_user_question": "allow",
// Launching, steering or reading a subagent has no side effect itself;
// every child action is still gated by the permission system and any
// unresolved "ask" is forwarded to the root session.
"Agent": "allow",
"get_subagent_result": "allow",
"steer_subagent": "allow",
"web_search": "allow", "web_search": "allow",
"chrome_snapshot": "allow", "chrome_snapshot": "allow",
"chrome_find": "allow", "chrome_find": "allow",
+8
View File
@@ -3,6 +3,8 @@
// <tool_routing> section. A rule is included when every tool in "whenAll" // <tool_routing> section. A rule is included when every tool in "whenAll"
// and at least one tool in "whenAny" is active; a rule with neither is // and at least one tool in "whenAny" is active; a rule with neither is
// always included. Rules render in file order as "- <text>". // always included. Rules render in file order as "- <text>".
// Optional "scope": "main" | "subagent" restricts a rule to that session
// audience (main session vs pi-subagents child); absent means both.
"version": 1, "version": 1,
"rules": [ "rules": [
{ {
@@ -27,8 +29,14 @@
}, },
{ {
"whenAny": ["ssh_connect"], "whenAny": ["ssh_connect"],
"scope": "main",
"text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host." "text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
}, },
{
"whenAny": ["ssh_connect"],
"scope": "subagent",
"text": "Use ssh_connect only when the delegated task explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
},
{ {
"whenAny": ["ssh_cd"], "whenAny": ["ssh_cd"],
"text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change." "text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change."
+5 -1
View File
@@ -3,6 +3,10 @@
// systemPromptOptions.sections. extensions/system-prompt.ts reorders the // systemPromptOptions.sections. extensions/system-prompt.ts reorders the
// contributed sections to this list; contributed sections not listed here // contributed sections to this list; contributed sections not listed here
// keep their contribution order after the listed ones. // keep their contribution order after the listed ones.
// Optional "subagentExclude": section names removed entirely in subagent
// (customPrompt) sessions — e.g. sections that describe main-session-only
// affordances. Defaults to [].
"version": 1, "version": 1,
"order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"] "order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"],
"subagentExclude": ["memory_policy", "chrome_profile_bridge"]
} }
+27
View File
@@ -0,0 +1,27 @@
---
name: Explore
display_name: Explore
description: 'Fast read-only search agent for locating code. Use it to find files by pattern (eg. "src/components/**/*.tsx"), grep for symbols or keywords (eg. "API endpoints"), or answer "where is X defined / which files reference Y." Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: "quick" for a single targeted lookup, "medium" for moderate exploration, or "very thorough" to search across multiple locations and naming conventions.'
tools: read, bash, grep, find, ls
extensions: true
skills: true
prompt_mode: replace
---
# Role: read-only code search
You locate code and answer "where is X / what references Y" questions for the delegating agent. You never modify anything.
## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools.
## Approach
- Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions.
- When codegraph_explore is available, start there for symbols and relationships. Otherwise use find to narrow paths, grep inside the narrowed paths, and read small ranges only to confirm a match.
- Make independent lookups in parallel.
## Report
- Lead with the direct answer.
- Cite every finding with an absolute path and line number.
- State what you searched for and did not find, so the delegating agent knows the coverage.
+31
View File
@@ -0,0 +1,31 @@
---
name: Plan
display_name: Plan
description: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.
tools: read, bash, grep, find, ls
extensions: true
skills: true
prompt_mode: replace
---
# Role: read-only implementation planner
You explore the codebase and return an implementation plan to the delegating agent. You never modify anything.
## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools.
## Process
1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing.
2. Explore enough to ground every step in real files, existing patterns, and reusable helpers.
3. Design the change: sequencing, interfaces and data shapes, edge cases, and the tests that prove it.
4. Call out trade-offs and risks, and name the rejected alternative when the choice is not obvious.
## Report
- A step-by-step plan where each step names the files it touches.
- Open questions, if any.
- End with:
### Critical files
3–5 absolute paths, each with a one-line reason.
@@ -0,0 +1,8 @@
---
name: general-purpose
display_name: Agent
description: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.
extensions: true
skills: true
prompt_mode: append
---
+8
View File
@@ -0,0 +1,8 @@
You are a subagent operating inside pi, a coding agent harness. Another agent — the delegating agent — assigned you one bounded task, and it is your only audience. The human user does not see your messages and cannot answer questions.
- Do exactly the assigned task. Do not expand its scope, start unrelated work, or delegate further unless the task requires it.
- If the task is ambiguous, make the most reasonable assumption, state it, and continue. If you are blocked, stop and report precisely what is missing instead of guessing.
- Your final message is the deliverable returned to the delegating agent. Make it self-contained: the answer or result first, then the supporting evidence (absolute file paths with line numbers, commands you ran and their outcomes), then assumptions, risks, and anything left unfinished.
- Keep verified facts separate from inferences. Never claim something was changed, run, or tested unless you actually did it in this session.
- Durable memory and skill writes are unavailable here. If you learn something worth remembering, include it in your final report for the delegating agent to decide.
- Be concise but complete. Do not use emojis.
+47
View File
@@ -0,0 +1,47 @@
# my-pi 维护约定(extensions/)
本目录是组合包的薄 wrapper 与纯 helper 层:第三方扩展一律经这里的包装文件(多数再套 `prompt-guard.ts` 代理)加载,自维护实现位于各自顶层目录。
## 系统提示词单一所有权
- `extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后,也是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展。提示词内容统一由 `config/prompt/` 管理:`preamble.md`(开场白)、`tools.jsonc`(每个工具的简介、规则及第三方工具说明覆盖)、`routing.jsonc`(跨工具工作流规则,渲染为 `<tool_routing>`)、`sections.jsonc`(分区顺序)。
- 其他组合包扩展不得返回 `systemPrompt`,也不得在 `context` 钩子中注入提示性消息;需要动态提示时只能写入 `event.systemPromptOptions.sections.<name>` 并在 `sections.jsonc` 登记顺序,静态规则写入 `promptGuidelines` 或该工具在 `tools.jsonc` 中的条目。
- 第三方扩展必须经 `extensions/` 下的组合包包装文件加载,由 `extensions/prompt-guard.ts` 的代理 API 拦截其对系统提示词的直接改写,并在注册工具时应用 `tools.jsonc` 的 `description` 覆盖;上游硬编码且无配置开关的注入只能在对应包装文件内处理,并以测试锁定所依赖的上游原文(依赖保持精确版本)。
- 新增工具必须在 `tools.jsonc` 登记(覆盖说明,或以 `"inherit": true` 表示已按本组合包审阅上游原文),否则 `tests/system-prompt.test.ts` 失败;`config/prompt/` 中的文字不得引用被策略禁止或被替换的用法。运行时若检测到其他扩展已定死提示词或存在未登记工具,`system-prompt.ts` 会各提示一次。
## `system-prompt.ts` 开发约定
- 扩展入口是 `extensions/system-prompt.ts`,必须排在 `pi.extensions` 最后;纯 helper(JSONC 加载与校验、`applyToolCatalog` / `renderRouting` / `orderSections` / `spliceCover` / `readPreamble` 等)位于 `extensions/system-prompt-config.ts` 以便 `node --test` 单测;组合包权威源是 `config/prompt/`(`preamble.md` / `tools.jsonc` / `routing.jsonc` / `sections.jsonc`),扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。
- 现代 Pi 下必须通过修改规范化 `systemPromptOptions`(`toolSnippets` / `toolGuidelines` / `sections`)保留分区结构,再读取重渲染的 `event.systemPrompt` 拼入封面并返回 `systemPrompt`;宿主不提供结构化 options 或 options 不参与重渲染时自动降级为文本拼接。不得改用 `customPrompt` 选项——它会抑制 `tools` / `rules` / `docs` 分区。
- 宿主传入 `customPrompt`(pi-subagents 子 Agent prompt、SYSTEM.md)时,该开场白自含身份标记(如 `<active_agent/>`),不得用 `preamble.md` 覆盖;其余分区处理(tool catalog、`<tool_routing>`、分区排序、降级追加)照常。子会话判定只用 `systemPromptOptions.customPrompt` 上的 `<active_agent name="..."/>` 契约(`isSubagentCustomPrompt`),绝不检测渲染后提示词——主会话的项目文档也可能含该字面量。
- 子 Agent 提示词画像在 `config/prompt/subagent/`(`preamble.md` + `agents/*.md`):由 `extensions/subagents.ts` 注入 pi-subagents 的 `basePrompt`/`bundledAgentsDir` 选项,替代上游的父提示词嵌入与内置默认 Agent;子会话经 `excludedExtensionPaths` 排除 `pi-ask-user`(子会话无人可问)。
- `routing.jsonc` 规则可选 `"scope": "main" | "subagent"`(缺省两者皆适用,其他值校验失败),`renderRouting` 按 audience 过滤;`sections.jsonc` 可选 `"subagentExclude"` 分区名列表,子会话在排序前删除这些分区(当前排除 `memory_policy` 与 `chrome_profile_bridge`)。
- 封面拼接边界必须是独占一行的裸标签,不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
- `preamble.md` 缺失、不可读或空白时保持 Pi 默认封面,不得写入空 preamble;`config/prompt/` 解析或校验失败时 `before_agent_start` 警告一次并整体 no-op,绝不抛出。
- 检测到其他扩展已设置 `forceSystemPrompt`、或激活工具未在 `tools.jsonc` 登记时各提示一次(warning);本扩展返回的 `systemPrompt` 把提示词冻结为最终文本是刻意设计——这也是它必须最后加载的原因。
- `config/prompt/` 随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。路由规则本体见 `config/prompt/routing.jsonc` 与 README,`system-prompt.ts` 只负责渲染,不得替换 Pi 默认系统提示词。
- 修改 loader、`spliceCover` 语义、注册顺序或 `config/prompt/` 路径后,必须运行 `node --test tests/system-prompt.test.ts`、`node --test tests/bundle-config.test.ts`、`node --test tests/extension-load.test.ts`,并确认 `npm pack --dry-run` 包含 `config/prompt/`。
## 各 wrapper 约定
- `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 `@` 补全;FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。
- `codegraph.ts` + `codegraph-client.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性常驻 `codegraph serve --mcp -p <root>` 子进程,经组合包自有 stdio 逐行 JSON-RPC 客户端直连(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭、会话结束统一关闭;调用中死亡换新客户端透明重试一次;其启动的上游 daemon(`.codegraph/daemon.sock`)退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
- CodeGraph 是机器级依赖,`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后才可 `codegraph init` 当前目录,拒绝记入会话级 declined 不再重复询问;init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。CLI 缺失、init 被拒或服务调用失败都不得抛出,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。已移除的 `plugin-init.ts`(原 `/plugin_init`)职责已并入本文件的惰性确认,不得恢复。
- `context-mode.ts` + `context-mode-filter.ts`:经 `prompt-guard.ts` 代理加载上游 `context-mode` Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性;Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文。filter 只丢弃其 context 注入中与本组合包路由重复且冲突的锚点段。
- `hashline.ts` + `hashline-config.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`;默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 与 `replaceText` 均关闭。它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
- `permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置;运行时直接读取已安装组合包内 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `<agentDir>/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理;项目级 policy 不读取。
- `lsp.ts` + `lsp-config.ts`:TypeScript/JavaScript 后端由根包固定依赖提供,用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件。
- `subagents.ts`:pi-subagents 的组合包 wrapper,注入权威 `permission-system.ts` 的 canonical absolute path 为子会话 mandatory extension,并提供 `config/prompt/subagent/` 提示词画像(`basePrompt` + `bundledAgentsDir`)与 `excludedExtensionPaths`(排除 `pi-ask-user`);完整规则见 `pi-subagents/AGENTS.md`。
- `context-view.ts`:只读的本地 effective-context observer(`/context`),只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage。
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
## 根目录 TypeScript 与 LSP 验证约定
- 根目录当前有意不安装 Pi runtime,也没有根级 `tsconfig.json`、`devDependencies` 或 `@types/node`;`@earendil-works/pi-*` 只作为 optional host peer dependencies。TypeScript LSP 因而会把根目录 `.ts` 文件放入 inferred project,不能把宿主 Pi 的运行时解析能力等同于仓库内静态类型环境。
- 每个任务首次对相关根目录 TypeScript 文件调用 `lsp_diagnostics` 时先建立并记录基线;后续只关注相对基线新增、消失或位置变化的诊断。文件和类型环境都未变化时不得重复调用 LSP 并重复报告同一组结果。
- 当前可预期的基线包括:宿主 peer 缺失导致的 `TS2307`(例如找不到 `@earendil-works/pi-coding-agent`),Node 类型缺失导致的 `node:*` / `node:test` / `node:assert` `TS2591`,以及由这些上游类型缺失级联产生的 `TS7006`、`TS18048`、`TS2722` 等。遇到级联错误时先确认其是否随缺失类型而产生,不要把它们直接归因于本次实现。
- 已知基线不等于忽略所有诊断:凡是无法由上述缺失依赖解释的新语法错误、结构类型错误、错误属性访问或本次修改所在代码的新诊断,必须修复并重新验证。交付时应把“已知类型环境基线”和“本次新增诊断”分开说明。
- 不得为了消除 LSP 基线而在根包安装第二套 Pi runtime、加入机器相关的宿主绝对路径或提交只在单机有效的 `paths` 映射。若确需新增根级 `tsconfig.json`、`@types/node` 或其他开发类型环境,必须作为明确的仓库设计变更评估,并同步更新依赖、锁文件和本文档。
- 不要从泛型或重载的 `ExtensionAPI` 方法直接用 `Parameters<...>` 猜取具体工具定义;解析失败时它可能退化为 `unknown`。包装第三方工具时优先使用上游导出的具体类型;没有稳定导出时定义覆盖实际访问字段的最小结构类型,并用运行时集成测试验证。
- `node --test` 可直接测试仓库内的纯 TypeScript helper,但测试入口不得静态导入 `node_modules` 中的 `.ts`;Node 原生 type stripping 会对这种路径报 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`。第三方 TypeScript 扩展 wrapper 应拆出本地纯 helper 做单元测试,并通过隔离的临时 Pi agent/jiti 加载验证真实扩展入口。
- 对根目录扩展的最终验证优先组合使用:纯 helper 的 `node --test`、`git diff --check`、所需 npm 锁文件检查,以及隔离 Pi 环境的扩展加载。LSP 启动成功、单元测试通过和真实 Pi 加载通过是不同证据,不得互相替代或把类型环境基线误报为 LSP 后端启动失败。
+15
View File
@@ -1,5 +1,9 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts"; import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
import {
readSubagentPreamble,
resolveSubagentAgentsDir,
} from "./system-prompt-config.ts";
// Bundle authority: Agent frontmatter may disable optional extensions, but it // Bundle authority: Agent frontmatter may disable optional extensions, but it
// cannot remove the exact wrapper that deploys my-pi's permission baseline and // cannot remove the exact wrapper that deploys my-pi's permission baseline and
@@ -8,6 +12,17 @@ const BUNDLED_PERMISSION_EXTENSION = fileURLToPath(
new URL("./permission-system.ts", import.meta.url), new URL("./permission-system.ts", import.meta.url),
); );
// Subagents get their own prompt profile: config/prompt/subagent/preamble.md
// replaces the parent-prompt embedding upstream performs, and
// config/prompt/subagent/agents/*.md are loaded as built-in agent types.
// ask_user_question is main-session-only — a child has no user to ask.
const ASK_USER_EXTENSION = fileURLToPath(
new URL("../pi-ask-user/index.ts", import.meta.url),
);
export default createPiSubagentsExtension({ export default createPiSubagentsExtension({
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION], mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
basePrompt: readSubagentPreamble,
bundledAgentsDir: resolveSubagentAgentsDir(),
excludedExtensionPaths: [ASK_USER_EXTENSION],
}); });
+68 -5
View File
@@ -20,6 +20,8 @@ export interface ToolCatalog {
export interface RoutingRule { export interface RoutingRule {
whenAll?: string[]; whenAll?: string[];
whenAny?: string[]; whenAny?: string[];
/** Which sessions the rule renders for; absent means both. */
scope?: "main" | "subagent";
text: string; text: string;
} }
@@ -31,6 +33,8 @@ export interface RoutingConfig {
export interface SectionsConfig { export interface SectionsConfig {
version: 1; version: 1;
order: string[]; order: string[];
/** Contributed sections removed from subagent (customPrompt) sessions. */
subagentExclude: string[];
} }
/** /**
@@ -64,6 +68,14 @@ export function resolveSectionsPath(): string {
return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR)); return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR));
} }
export function resolveSubagentPreamblePath(): string {
return fileURLToPath(new URL("subagent/preamble.md", PROMPT_CONFIG_DIR));
}
export function resolveSubagentAgentsDir(): string {
return fileURLToPath(new URL("subagent/agents/", PROMPT_CONFIG_DIR));
}
/** /**
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a * Strip `//` and `/* ... *\/` comments while respecting string literals, so a
* `//` inside a quoted value (for example `"https://…"`) is never removed. * `//` inside a quoted value (for example `"https://…"`) is never removed.
@@ -185,7 +197,7 @@ function validateToolCatalog(raw: unknown): ToolCatalog {
return { version: 1, tools }; return { version: 1, tools };
} }
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "text"]); const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "scope", "text"]);
function validateRoutingRule(index: number, raw: unknown): RoutingRule { function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
@@ -205,10 +217,13 @@ function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if ("whenAny" in raw && !isStringArray(raw.whenAny)) { if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`); fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
} }
if ("scope" in raw && raw.scope !== "main" && raw.scope !== "subagent") {
fail("routing.jsonc", `rule #${index}.scope must be "main" or "subagent"`);
}
return raw as RoutingRule; return raw as RoutingRule;
} }
function validateRoutingConfig(raw: unknown): RoutingConfig { export function validateRoutingConfig(raw: unknown): RoutingConfig {
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) { if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }'); fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }');
} }
@@ -220,7 +235,7 @@ function validateRoutingConfig(raw: unknown): RoutingConfig {
const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/; const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
function validateSectionsConfig(raw: unknown): SectionsConfig { export function validateSectionsConfig(raw: unknown): SectionsConfig {
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) { if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }'); fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }');
} }
@@ -229,7 +244,19 @@ function validateSectionsConfig(raw: unknown): SectionsConfig {
fail("sections.jsonc", `"${name}" is not a valid section name`); fail("sections.jsonc", `"${name}" is not a valid section name`);
} }
} }
return { version: 1, order: raw.order }; if ("subagentExclude" in raw && !isStringArray(raw.subagentExclude)) {
fail("sections.jsonc", `"subagentExclude" must be an array of section names`);
}
for (const name of raw.subagentExclude ?? []) {
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
fail("sections.jsonc", `"subagentExclude" entry "${name}" is not a valid section name`);
}
}
return {
version: 1,
order: raw.order,
subagentExclude: isStringArray(raw.subagentExclude) ? raw.subagentExclude : [],
};
} }
export function loadToolCatalog(): ToolCatalog { export function loadToolCatalog(): ToolCatalog {
@@ -262,6 +289,34 @@ export function readPreamble(): string | undefined {
return trimmed.length > 0 ? trimmed : undefined; return trimmed.length > 0 ? trimmed : undefined;
} }
/**
* Read the bundle's subagent base preamble (`config/prompt/subagent/preamble.md`),
* re-read on each call so the file is never cached between spawns.
* Blank or missing → `undefined`, which keeps pi-subagents' upstream composition.
*/
export function readSubagentPreamble(): string | undefined {
let raw: string;
try {
raw = readFileSync(resolveSubagentPreamblePath(), "utf8");
} catch {
return undefined;
}
const trimmed = raw.replace(/\s+$/, "");
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* The `<active_agent name="..."/>` tag pi-subagents stamps into every child
* prompt — tested ONLY against `systemPromptOptions.customPrompt`, never the
* rendered prompt: a main session may contain the literal tag via project
* docs and must still be treated as main.
*/
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
}
/** /**
* Apply the tool catalog to the mutable prompt options: a `snippet` string * Apply the tool catalog to the mutable prompt options: a `snippet` string
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces * replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
@@ -302,11 +357,19 @@ export function applyToolCatalog(options: MutablePromptOptions, catalog: ToolCat
* `whenAny` tool is active (a rule with neither always applies). An absent * `whenAny` tool is active (a rule with neither always applies). An absent
* `selectedTools` means every tool counts as active, matching the previous * `selectedTools` means every tool counts as active, matching the previous
* tool-routing behavior. * tool-routing behavior.
*
* `audience` filters `scope`: rules without a scope render for both "main"
* and "subagent" sessions, scoped rules only for their own audience.
*/ */
export function renderRouting(rules: RoutingRule[], selectedTools: string[] | undefined): string { export function renderRouting(
rules: RoutingRule[],
selectedTools: string[] | undefined,
audience: "main" | "subagent" = "main",
): string {
const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name); const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
const lines: string[] = []; const lines: string[] = [];
for (const rule of rules) { for (const rule of rules) {
if (rule.scope !== undefined && rule.scope !== audience) continue;
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue; if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue; if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
lines.push(`- ${rule.text}`); lines.push(`- ${rule.text}`);
+24 -5
View File
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import { import {
applyToolCatalog, applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules, loadRoutingRules,
loadSectionsConfig, loadSectionsConfig,
loadToolCatalog, loadToolCatalog,
@@ -23,8 +24,11 @@ export function getSystemPromptDumpPath(cwd: string): string {
return resolve(cwd, DUMP_RELATIVE_PATH); return resolve(cwd, DUMP_RELATIVE_PATH);
} }
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */ /** The options object as Pi 1.3 normalizes it; `MutablePromptOptions` omits `forceSystemPrompt`/`customPrompt`. */
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string }; type RuntimePromptOptions = MutablePromptOptions & {
forceSystemPrompt?: string;
customPrompt?: string;
};
/** /**
* Injectable seams so the composer can be exercised under `node --test` * Injectable seams so the composer can be exercised under `node --test`
@@ -105,6 +109,12 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
return undefined; return undefined;
} }
// A subagent session is identified by its customPrompt carrying the
// pi-subagents <active_agent/> tag — never by the rendered prompt, which
// a main session may legitimately contain the literal tag inside.
const isSubagent = isSubagentCustomPrompt(options?.customPrompt);
const audience = isSubagent ? "subagent" : "main";
let routingText = ""; let routingText = "";
if (options) { if (options) {
const missing = applyToolCatalog(options, catalog); const missing = applyToolCatalog(options, catalog);
@@ -115,14 +125,19 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning"); ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
} }
} }
routingText = renderRouting(routing.rules, options.selectedTools); routingText = renderRouting(routing.rules, options.selectedTools, audience);
options.sections ??= {}; options.sections ??= {};
if (isSubagent) {
for (const name of sections.subagentExclude) {
delete options.sections[name];
}
}
if (routingText.length > 0) { if (routingText.length > 0) {
options.sections.tool_routing = routingText; options.sections.tool_routing = routingText;
} }
options.sections = orderSections(options.sections, sections.order); options.sections = orderSections(options.sections, sections.order);
} else { } else {
routingText = renderRouting(routing.rules, undefined); routingText = renderRouting(routing.rules, undefined, audience);
} }
let rendered = event.systemPrompt; let rendered = event.systemPrompt;
@@ -132,7 +147,11 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`; rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
} }
const final = preamble ? spliceCover(rendered, preamble) : rendered; // A non-empty customPrompt (pi-subagents agent prompt, SYSTEM.md) owns
// the whole preamble — including identities like <active_agent/> that
// the permission system reads — so the bundle cover must not replace it.
const ownsPreamble = typeof options?.customPrompt === "string" && options.customPrompt.length > 0;
const final = preamble && !ownsPreamble ? spliceCover(rendered, preamble) : rendered;
lastComposed = final; lastComposed = final;
return { systemPrompt: final }; return { systemPrompt: final };
} }
+1384 -2299
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -60,6 +60,7 @@
"./pi-minimal-footer/index.ts", "./pi-minimal-footer/index.ts",
"./pi-notify/src/index.ts", "./pi-notify/src/index.ts",
"./extensions/permission-system.ts", "./extensions/permission-system.ts",
"./extensions/subagents.ts",
"./pi-ssh/index.ts", "./pi-ssh/index.ts",
"./pi-ask-user/index.ts", "./pi-ask-user/index.ts",
"./pi-worktree/index.ts", "./pi-worktree/index.ts",
@@ -76,6 +77,7 @@
"@gotgenes/pi-permission-system": "file:./pi-permission-system", "@gotgenes/pi-permission-system": "file:./pi-permission-system",
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer", "@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
"@smoose/pi-notify": "file:./pi-notify", "@smoose/pi-notify": "file:./pi-notify",
"@tintinweb/pi-subagents": "file:./pi-subagents",
"context-mode": "1.0.169", "context-mode": "1.0.169",
"jiti": "2.7.0", "jiti": "2.7.0",
"pi-ask-user": "file:./pi-ask-user", "pi-ask-user": "file:./pi-ask-user",
+9
View File
@@ -0,0 +1,9 @@
# my-pi 维护约定(pi-ask-user)
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI、RPC extension UI bridge 与工具注册位于 `src/`,纯 helper 测试保留在 `test/`;根组合包直接加载源码并通过本地 `file:` 依赖打包。
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具统一支持单题、多题、选择、自由文本、自定义回答与复核;顺序执行和最多 8 题;不加载 `/qna`。
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor;RPC 必须只依赖 Pi 标准 `select` / `input` extension UI、逐题执行、传递 abort signal,并把 dismiss/abort 归一化为取消;JSON/print 等无 UI 模式保持拒绝。
- 取消或 abort 必须返回 `cancelled: true`,不得伪造答案;成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`,以匹配 AutoReview 已有的可信结构化用户交互边界;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-system/src/auto-review/transcript.ts` 解析。
- 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
+9
View File
@@ -0,0 +1,9 @@
# my-pi 维护约定(pi-chrome)
- 扩展入口是 `pi-chrome/extensions/chrome-profile-bridge/index.ts`,Companion MV3 扩展位于 `browser-extension/`,Session 快照存储位于 `src/session-snapshot-store.ts`,测试保留在 `test/` 与 `test-suite/`,来源记录是 `UPSTREAM.md`。
- 同步上游必须从官方 `tianrendong/pi-chrome` 的明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、文档、测试、作者和 LICENSE;不得导入嵌套 `.git`、`node_modules` 或构建产物。
- 根包必须通过 `file:./pi-chrome` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
- 控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`;未授权时不注册 Chrome 工具。显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
- 完整 Chrome snapshot 不得进入普通工具文本或 `details`;只能原子写入当前项目 `.pi/chrome-context/<session>/<target>/snapshot.json`,使用哈希目录、POSIX `700` 目录 / `600` 文件,并只返回工作区相对路径和有界元数据。快照不得跨 Session 持久化,Session 启动/关闭、撤销和授权过期必须清理。
- `chrome_snapshot` 和带 `includeSnapshot=true` 的动作必须引导模型立即使用 `ctx_execute_file`,不得使用 `read` / `cat` 把原始 JSON 重新塞入上下文;任何可能改变页面或目标的成功操作必须使旧快照失效,`chrome_find` 只可额外返回有界匹配摘要。
- 修改 bridge、Companion 扩展、授权 lifecycle、快照存储或加载入口后至少运行包内 `npm test`、根 Tool Routing/Tool Search/扩展联合加载测试、`npm pack --dry-run --json` 和实际 packed tarball 隔离安装验证;未经明确要求不得连接或控制真实 Chrome。
+19
View File
@@ -0,0 +1,19 @@
# my-pi 维护约定(pi-compaction)
- 扩展入口是 `pi-compaction/index.ts`,增量索引、投影、后台摘要、服务与 Codex native 实现在 `src/`,测试位于 `test/`,双上游来源记录是 `UPSTREAM.md`。
- 根包必须通过 `file:./pi-compaction` 提供依赖并直接加载源码;不得重新声明或加载 `pi-condense`、npm `@ogulcancelik/pi-codex-compaction` 或 npm `pi-context-view`。
- `pi-compaction` 只支持自己的 version 1 记录和 `config/pi-compaction.json` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。配置不写回 `settings.json`,默认 soft/hard/target 比例为 72%/90%/55%。
- Context Authority 是 compaction 唯一生产投影者。普通 Provider、Codex native、`/context`、`/compaction-status` 和 footer 必须消费同一个不可变 projection snapshot;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage;background worker 不得在 Provider 请求中被 await。
- `pi-compaction` 负责已经进入 Session 的历史投影,与 Context Mode 的输入隔离职责互补。首次 Session hydration 允许一次完整扫描;后续普通 `context` hot path 必须复用不可变 snapshot 或只处理 delta,不得调用 summarizer、写 Session 或重新构建完整 branch。
- 已闭合工具结果先同步生成保留工具名、参数、状态、首尾证据和 source key 的有界确定性 stub;大于 `semantic.minInputChars` 的结果再进入单 Session 有界后台语义 worker。后台结果只在安全事件边界提交,陈旧 hash 必须丢弃,Provider 请求不得等待后台摘要。
- Segment custom entry 只能保存摘要、hash 和 source reference,不得复制完整原始工具输出。`context_tree_query` 恢复结果必须有界,native checkpoint 的 portable summary 也必须有界。
- effective token 默认使用 Provider usage-backed baseline 加 projected delta;无可靠 baseline 时使用带上下界的估算。Pi Core raw threshold 只负责唤醒,只有 effective lower bound 达到 `hardRatio` 才执行 threshold compaction;manual 与 overflow 不得被阻断。
- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。
- 原生远程压缩限定为 `openai-codex` + `openai-codex-responses` 与 `sub-api` + `openai-responses`,默认在 turn boundary 达到 effective 下界 90% 时触发。`src/native-provider.ts` 统一资格与传输选择:Codex 保留原 endpoint/JWT/account ID;SubAPI 从显式 `baseUrl` 解析公共 `/responses`,使用配置的 API key 和请求头,不解析 ChatGPT account ID、不硬编码网关域名,也不调用独立 `/responses/compact`。两者复用 projection、`compaction_trigger`、SSE 结果、native checkpoint 恢复和 portable 模型切换摘要;SubAPI 部署须支持原生 compaction v2。普通聊天支持不代表压缩支持,失败回退 Pi,不自动改写用户模型配置。
- SubAPI 原生压缩认证必须保留非空显式 `Authorization`(大小写不敏感)的优先级;没有该头时才使用非空 API key 生成 Bearer,均缺失时可接受非空 `cf-aig-authorization` 代理认证头。不得要求 header-only 配置额外提供假 API key,也不得生成 `Bearer undefined`;空白或缺失凭据在网络请求前拒绝并回退 Pi。Codex 仍必须提供有效 JWT,不能用请求头绕过。
- 同步 Codex 协议代码必须从官方 `ogulcancelik/pi-extensions` 明确 commit 移植并保留对应 MIT 许可证;不得从 npm tarball、缓存、`node_modules` 或构建产物导入。
## 验证
- 在本目录内至少运行 `npm test`、`npm run check` 和 `npm pack --dry-run --json`;涉及依赖、加载入口、context writer、footer 或 observer 时还要验证根 `npm install` 幂等、锁文件仅包含预期本地 link 变化、根扩展联合加载、性能回归,以及实际 packed 根 tarball 的隔离安装/加载。
- 修改 projection、token accounting、threshold、checkpoint、模型切换、后台并发或恢复索引后,至少运行 `npm test`、`npm run check`、`npm pack --dry-run --json`、根扩展联合加载、packed 根 tarball 隔离加载和性能回归。性能门禁为 cache-hit P95 <2ms、delta projection P95 <10ms,普通 hot path 不得扫描完整 branch。
+18
View File
@@ -1,3 +1,21 @@
# my-pi 维护约定(优先于下文上游约定)
- 扩展入口是 `pi-hermes-memory/src/index.ts`,来源记录是 `UPSTREAM.md`;只从官方 `chandra447/pi-hermes-memory` 的明确 tag/commit 移植并保留官方 README、LICENSE 和版权。
- 根包必须通过 `file:./pi-hermes-memory` 提供依赖并直接加载仓库源码;不得同时安装或加载 npm 预编译入口,也不得保留第二个记忆扩展(`hippo-memory-pi` 已移除)。
- `package.json` 的 `pi.extensions` 必须把 `./pi-hermes-memory/src/index.ts` 排在 `./pi-compaction/index.ts` 之前:`ExtensionRunner.emit()` 在第一个返回 `{ cancel: true }` 的 `session_before_compact` handler 处立即停止,而 `pi-compaction` 在判定无需压缩时正是这样返回,注册在其后的 handler 会被跳过从而静默禁用 `flushOnCompact`;根 `tests/extension-load.test.ts` 必须断言该顺序。
- 记忆默认 `policy-only` 模式:只注入 `<memory-policy>` 指令而不注入记忆正文,不改变 system prompt 前缀、对 DeepSeek 前缀缓存友好;模型需要历史上下文时主动调用 `memory_search` / `session_search`,写入用 `memory_add` / `memory_replace` / `memory_remove`,过程记忆用 `skill_manage`。
- 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻(不放入动态组——注入的 policy 指示模型在普通工作中调用记忆工具,恢复被投影的工具证据也不能依赖 `tool_search` 激活,常驻同时保持请求前缀稳定),并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS`、`config/tool-search.json`(二者必须完全一致)、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。它们只写扩展自己的 agent 目录数据或只读本地索引,不触碰工作区,因此在权限策略中默认 `allow`,避免每轮 AutoReview 往返。
- 依赖必须保持 `better-sqlite3@^13.0.3`:13.x 运行时使用包内 N-API 预编译二进制 `prebuilds/<platform>.node`,不依赖 node-gyp 编译产物,必须能由 npm 嵌套安装并与根 `better-sqlite3@12.11.1` 并存。不得把它降级回 12.x(Node 24 下会在进程退出时 SIGABRT),也不得让根包提升为单一版本;npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 不得批准,根 `allowScripts` 不新增条目。
- `devDependencies` 中的 `@earendil-works/*` 必须与根包当前版本精确一致。若写成 `^0.80.2` 这类与根不兼容的范围,npm 会在本目录嵌套安装第二套 Pi runtime,违反"不得在组合包内再安装一套 Pi runtime"的纪律;`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 同理,必须钉到根已有版本。
- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除;扩展配置位于已安装组合包的 `config/pi-hermes-memory.json`(其中路径类默认值由代码运行时计算),旧 `~/.pi/agent/hermes-memory-config.json` 被忽略。
- 子会话(pi-subagents child)只读:`src/subagent-mode.ts` 用与 pi-subagents 相同的 `<active_agent name="..."/>` 契约检测——权威判定在 `before_agent_start` 的 `systemPromptOptions.customPrompt`(绝不检测渲染后提示词,主会话文档里也可能含该字面量),`session_start` 仅以"渲染提示词以该 tag 开头"作提前预判以跳过 session_start 写入;按 `sessionManager` 逐会话标记(同一进程内父子会话并发,不能用全局开关)。子会话中 `memory_add`/`memory_replace`/`memory_remove`/`skill_manage` 返回固定错误且零写入,后台 review、correction detection、compact/shutdown flush、live/shutdown session 索引与初始化迁移/同步全部跳过;`memory_search`/`session_search` 保持只读可用。
- `skill_manage` 是本扩展提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。
- 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。
## 验证
- 在本目录内运行包内测试与类型检查。记忆扩展变化必须额外验证:在隔离临时 Pi agent 目录中可加载且注册 6 个工具、`pi.extensions` 中排在 `pi-compaction` 之前、Tool Search 目录把六个工具归入 `memory-recall` / `memory-management` 且全部常驻、权限策略默认 `allow`、`better-sqlite3@13` 嵌套安装与根 `12.11.1` 并存。不得在测试中连接真实数据库或修改真实 `~/.pi/agent/` 记忆数据。
# Pi Hermes Memory Extension # Pi Hermes Memory Extension
## Project Overview ## Project Overview
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- **Subagent (pi-subagents child) sessions are read-only**: sessions whose `systemPromptOptions.customPrompt` carries the `<active_agent name="..."/>` identity tag (detected in `before_agent_start`, tracked per `sessionManager`) get a fixed error result from `memory_add`, `memory_replace`, `memory_remove`, and `skill_manage` — no writes — and skip background review, correction detection, compact/shutdown flush, live and shutdown session indexing, and the write side of initialization (markdown↔SQLite sync, legacy migration, pruning, backfill). `memory_search` and `session_search` stay fully usable. Sessions started from a tag-leading rendered prompt are detected as early as `session_start`; otherwise the authoritative `customPrompt` check at `before_agent_start` applies before any turn-level work.
- **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged. - **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged.
### Added ### Added
+621 -2269
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -53,9 +53,9 @@
"@earendil-works/pi-tui": "*" "@earendil-works/pi-tui": "*"
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-ai": "0.84.2", "@earendil-works/pi-ai": "latest",
"@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "0.84.2", "@earendil-works/pi-tui": "latest",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"tsx": "4.23.15", "tsx": "4.23.15",
"typebox": "1.3.7", "typebox": "1.3.7",
@@ -17,6 +17,7 @@ import { MemoryStore } from "../store/memory-store.js";
import { DatabaseManager } from "../store/db.js"; import { DatabaseManager } from "../store/db.js";
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js"; import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js"; import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js";
@@ -196,6 +197,7 @@ export function setupBackgroundReview(
}); });
pi.on("turn_end", async (event, ctx) => { pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
turnsSinceReview++; turnsSinceReview++;
if (!config.reviewEnabled) return; if (!config.reviewEnabled) return;
@@ -23,6 +23,7 @@ import {
} from "../constants.js"; } from "../constants.js";
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { getMessageText } from "../types.js"; import { getMessageText } from "../types.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js"; import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js";
@@ -151,6 +152,7 @@ export function setupCorrectionDetector(
// Trigger on turn_end (we need full context: user correction + what agent said) // Trigger on turn_end (we need full context: user correction + what agent said)
pi.on("turn_end", async (event, ctx) => { pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
if (!pendingCorrection) { if (!pendingCorrection) {
turnsSinceLastCorrection++; turnsSinceLastCorrection++;
return; return;
@@ -37,6 +37,7 @@ import {
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { measureLifecycle } from "../lifecycle-timing.js"; import { measureLifecycle } from "../lifecycle-timing.js";
import { isSubagentSession } from "../subagent-mode.js";
import { collectMessageParts } from "./message-parts.js"; import { collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js"; import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
@@ -167,6 +168,7 @@ export function setupSessionFlush(
kind: FlushKind, kind: FlushKind,
): Promise<void> { ): Promise<void> {
try { try {
if (isSubagentSession(ctx)) return;
if (userTurnCount < config.flushMinTurns) return; if (userTurnCount < config.flushMinTurns) return;
let entries; let entries;
+17 -3
View File
@@ -61,6 +61,7 @@ import { AGENT_ROOT } from "./paths.js";
import { isDatabaseMigrationPending } from "./extension-root-migration.js"; import { isDatabaseMigrationPending } from "./extension-root-migration.js";
import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js"; import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js";
import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js"; import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js";
import { isSubagentSession, markSubagentSession, markSubagentSessionFromPrompt } from "./subagent-mode.js";
export function resolveProjectSkillDiscovery( export function resolveProjectSkillDiscovery(
skillStore: SkillStore, skillStore: SkillStore,
@@ -195,8 +196,12 @@ export default function (pi: ExtensionAPI) {
const initialization = createMemoryInitializer(async (ctx) => { const initialization = createMemoryInitializer(async (ctx) => {
const timingPrefix = lazy ? "memory-init" : "session-start"; const timingPrefix = lazy ? "memory-init" : "session-start";
if (lazy) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir); // Subagent sessions are read-only: skip the write side of initialization
if (!persistenceInitialized) { // (legacy-dir migration, markdown↔SQLite sync, pruning, backfill) while
// still loading the store so memory_search/session_search keep working.
const subagent = isSubagentSession(ctx);
if (lazy && !subagent) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
if (!persistenceInitialized && !subagent) {
try { try {
await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => { await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => {
// Startup reconciles the global files plus the current project only: // Startup reconciles the global files plus the current project only:
@@ -290,18 +295,25 @@ export default function (pi: ExtensionAPI) {
// Skills and pinned instructions must be available even without a lookup. // Skills and pinned instructions must be available even without a lookup.
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
sessionContext = ctx; sessionContext = ctx;
// session_start runs before before_agent_start; a child's rendered prompt
// leads with the <active_agent> tag, so detect it here early enough to
// skip the session_start writes below.
markSubagentSessionFromPrompt(ctx.getSystemPrompt?.() ?? "", ctx);
// Pinned directives must not depend on migration/SQLite being healthy. // Pinned directives must not depend on migration/SQLite being healthy.
if (standingStore) await standingStore.load(); if (standingStore) await standingStore.load();
if (!lazy) await ensureMemoryReady(ctx); if (!lazy) await ensureMemoryReady(ctx);
refreshSkillProjectContext(ctx.cwd); refreshSkillProjectContext(ctx.cwd);
if (!isSubagentSession(ctx)) {
await skillStore.migrateLegacySkills(); await skillStore.migrateLegacySkills();
await skillStore.ensureDiscoveredRoots(); await skillStore.ensureDiscoveredRoots();
}
}); });
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir); registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
// ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ── // ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ──
pi.on("before_agent_start", async (event, _ctx) => { pi.on("before_agent_start", async (event, ctx) => {
markSubagentSession(event, ctx);
const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore); const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore);
if (!promptContext) return; if (!promptContext) return;
@@ -400,6 +412,7 @@ export default function (pi: ExtensionAPI) {
// ── 10. Live session indexing ── // ── 10. Live session indexing ──
pi.on("message_end", async (_event, ctx) => { pi.on("message_end", async (_event, ctx) => {
if (isSubagentSession(ctx)) return;
// Cold lazy sessions remain in Pi's JSONL files and are backfilled on use. // Cold lazy sessions remain in Pi's JSONL files and are backfilled on use.
if (lazy && !initialization.isReady()) return; if (lazy && !initialization.isReady()) return;
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, { scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
@@ -432,6 +445,7 @@ export default function (pi: ExtensionAPI) {
return; return;
} }
try { try {
if (isSubagentSession(ctx)) return;
measureLifecycleSync("shutdown.active-index", () => { measureLifecycleSync("shutdown.active-index", () => {
const sessionFile = ctx.sessionManager.getSessionFile(); const sessionFile = ctx.sessionManager.getSessionFile();
if (sessionFile && fs.existsSync(sessionFile)) { if (sessionFile && fs.existsSync(sessionFile)) {
+58
View File
@@ -0,0 +1,58 @@
/**
* subagent-mode.ts — subagent sessions are read-only for memory and skill
* writes. All automatic write-related work (background review, correction
* detection, session flushes, session indexing) is skipped as well.
*
* A child session is identified by the `<active_agent name="..."/>` identity
* tag that pi-subagents stamps into every child prompt
* (permission-system-bridge.ts): the tag arrives via
* `systemPromptOptions.customPrompt`. The tag is only trusted on that field —
* never in the rendered system prompt, which a main session can legitimately
* contain through project docs.
*
* Membership is tracked per session by its sessionManager identity: the same
* process hosts the parent and its children concurrently, so a global boolean
* would let a child's turn flip the parent's session into read-only mid-turn.
*/
import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
/** pi-subagents `<active_agent name="X"/>` identity tag contract. */
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
const LEADING_ACTIVE_AGENT_TAG = /^\s*<active_agent\s+name=["'][^"']+["'][^>]*>/i;
export const SUBAGENT_WRITE_DISABLED_MESSAGE =
"Memory and skill writes are disabled in subagent sessions; include this in your final report so the delegating agent can decide.";
const subagentSessionManagers = new WeakSet<object>();
/** Same regex as the pi-subagents `<active_agent>` contract, applied only to customPrompt. */
export function isSubagentCustomPrompt(customPrompt: string | undefined | null): boolean {
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
}
/**
* before_agent_start hook: authoritative detection via the event's
* systemPromptOptions.customPrompt.
*/
export function markSubagentSession(event: BeforeAgentStartEvent, ctx: ExtensionContext): void {
if (isSubagentCustomPrompt(event.systemPromptOptions?.customPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
/**
* session_start runs before before_agent_start; detect tag-leading prompts
* early so session_start writes (skill migration, root setup) can be skipped.
* A main prompt cannot lead with the tag — without customPrompt the rendered
* prompt always opens with the base preamble.
*/
export function markSubagentSessionFromPrompt(systemPrompt: string, ctx: ExtensionContext): void {
if (LEADING_ACTIVE_AGENT_TAG.test(systemPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
export function isSubagentSession(ctx: unknown): boolean {
const sm = (ctx as { sessionManager?: unknown } | undefined | null)?.sessionManager;
return typeof sm === "object" && sm !== null && subagentSessionManagers.has(sm);
}
+8 -1
View File
@@ -25,6 +25,7 @@ import type { MemoryCategory, MemoryResult } from "../types.js";
import { normalizeMemoryLookupText } from "../store/memory-lookup.js"; import { normalizeMemoryLookupText } from "../store/memory-lookup.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js"; import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { memoryResultView } from "./tool-result-views.js"; import { memoryResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult { function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult {
const warnings = [...(((result as any).warnings ?? []) as string[]), warning]; const warnings = [...(((result as any).warnings ?? []) as string[]), warning];
@@ -425,7 +426,13 @@ This action-specific tool accepts only the parameters listed in its schema.`;
], ],
renderResult: createSharedToolResultRenderer(memoryResultView), renderResult: createSharedToolResultRenderer(memoryResultView),
parameters, parameters,
async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string }) { async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string; sessionManager?: object }) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text" as const, text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
if (bindProjectFromCwd && ctx?.cwd) { if (bindProjectFromCwd && ctx?.cwd) {
await bindProjectFromCwd(ctx.cwd); await bindProjectFromCwd(ctx.cwd);
} }
+14
View File
@@ -10,6 +10,7 @@ import { SkillStore } from "../store/skill-store.js";
import { SKILL_TOOL_DESCRIPTION } from "../constants.js"; import { SKILL_TOOL_DESCRIPTION } from "../constants.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js"; import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { skillResultView } from "./tool-result-views.js"; import { skillResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function normalizeTextList(value: unknown): string[] { function normalizeTextList(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
@@ -90,6 +91,13 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
export const SKILL_MANAGE_TOOL_NAME = "skill_manage"; export const SKILL_MANAGE_TOOL_NAME = "skill_manage";
export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void { export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
if (typeof pi.on === "function") {
pi.on("tool_result", (event) => {
if (event.toolName !== SKILL_MANAGE_TOOL_NAME) return;
const details = event.details as { success?: unknown } | undefined;
if (details?.success === false) return { isError: true };
});
}
pi.registerTool({ pi.registerTool({
name: SKILL_MANAGE_TOOL_NAME, name: SKILL_MANAGE_TOOL_NAME,
label: "Skill Manager", label: "Skill Manager",
@@ -108,6 +116,12 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
renderResult: createSharedToolResultRenderer(skillResultView), renderResult: createSharedToolResultRenderer(skillResultView),
parameters: SKILL_TOOL_PARAMETERS, parameters: SKILL_TOOL_PARAMETERS,
async execute(toolCallId, params, signal, onUpdate, ctx) { async execute(toolCallId, params, signal, onUpdate, ctx) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text", text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
const skillParams = params as { const skillParams = params as {
action: "create" | "view" | "patch" | "update" | "edit" | "delete"; action: "create" | "view" | "patch" | "update" | "edit" | "delete";
name?: string; name?: string;
@@ -0,0 +1,228 @@
/**
* Subagent (pi-subagents child) sessions are read-only: memory/skill write
* tools return a fixed error and all automatic writes are skipped.
*
* Child detection keys on the `<active_agent name="X"/>` tag pi-subagents
* stamps into `systemPromptOptions.customPrompt`; membership is tracked per
* sessionManager so a child in the same process never flips the parent.
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
isSubagentCustomPrompt,
isSubagentSession,
markSubagentSession,
markSubagentSessionFromPrompt,
SUBAGENT_WRITE_DISABLED_MESSAGE,
} from "../src/subagent-mode.js";
import { registerMemoryTool } from "../src/tools/memory-tool.js";
import { registerSkillTool } from "../src/tools/skill-tool.js";
import { setupSessionFlush } from "../src/handlers/session-flush.js";
import { MemoryStore } from "../src/store/memory-store.js";
import { SkillStore } from "../src/store/skill-store.js";
import type { MemoryConfig } from "../src/types.js";
const CHILD_PROMPT = 'You are the Explore agent.\n<active_agent name="Explore"/>';
function beforeAgentStartEvent(customPrompt?: string) {
return { systemPromptOptions: customPrompt === undefined ? {} : { customPrompt } } as any;
}
describe("subagent detection", () => {
it("matches the pi-subagents <active_agent> contract on customPrompt only", () => {
assert.ok(isSubagentCustomPrompt(CHILD_PROMPT));
assert.ok(isSubagentCustomPrompt("intro <active_agent name=\"General Purpose\" extra=\"1\"> tail"));
assert.ok(!isSubagentCustomPrompt("a normal custom prompt"));
assert.ok(!isSubagentCustomPrompt(""));
assert.ok(!isSubagentCustomPrompt(undefined));
assert.ok(!isSubagentCustomPrompt(null));
});
it("marks only sessions whose before_agent_start customPrompt carries the tag", () => {
const childManager = {};
const mainManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager: childManager } as any);
markSubagentSession(beforeAgentStartEvent(), { sessionManager: mainManager } as any);
markSubagentSession(beforeAgentStartEvent("no tag here"), { sessionManager: {} } as any);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
assert.ok(!isSubagentSession(undefined));
assert.ok(!isSubagentSession({}));
});
it("detects tag-leading rendered prompts early (session_start), not mid-prompt tags", () => {
const childManager = {};
const mainManager = {};
markSubagentSessionFromPrompt(
`<active_agent name="Explore"/>\n\nYou are the Explore agent.\n\nrest`,
{ sessionManager: childManager } as any,
);
markSubagentSessionFromPrompt(
`preamble\n\nliteral <active_agent name="X"/> inside project docs`,
{ sessionManager: mainManager } as any,
);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
});
});
describe("subagent write gating", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagent-mode-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMemoryStore(): MemoryStore {
return new MemoryStore({
memoryMode: "policy-only",
memoryCharLimit: 5000,
userCharLimit: 5000,
projectCharLimit: 5000,
nudgeInterval: 10,
reviewEnabled: false,
flushOnCompact: false,
flushOnShutdown: false,
flushMinTurns: 6,
autoConsolidate: false,
correctionDetection: false,
failureInjectionEnabled: false,
failureInjectionMaxAgeDays: 7,
failureInjectionMaxEntries: 5,
nudgeToolCalls: 15,
consolidationTimeoutMs: 60000,
memoryDir: tmpDir,
});
}
it("memory_add/replace/remove return the disabled error and perform no writes", async () => {
const tools = new Map<string, any>();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
let bindCalls = 0;
registerMemoryTool(mockPi, store, null, null, null, () => { bindCalls++; });
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const ctx = { cwd: tmpDir, sessionManager };
for (const name of ["memory_add", "memory_replace", "memory_remove"]) {
const result = await tools.get(name).execute("tc", { target: "memory", content: "x", old_text: "x" }, undefined, undefined, ctx);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
assert.strictEqual(result.details.success, false, name);
assert.strictEqual(result.details.error, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
}
assert.strictEqual(bindCalls, 0, "blocked writes must not bind projects");
assert.deepStrictEqual(store.getMemoryEntries(), [], "blocked writes must not mutate the store");
assert.ok(!fs.existsSync(path.join(tmpDir, "MEMORY.md")), "blocked writes must not write files");
});
it("memory tools still write in an unmarked (main) session", async () => {
const tools = new Map<string, any>();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
registerMemoryTool(mockPi, store, null);
const result = await tools.get("memory_add").execute(
"tc",
{ target: "memory", content: "main session entry" },
undefined,
undefined,
{ cwd: tmpDir, sessionManager: {} },
);
assert.strictEqual(result.details.success, true);
assert.deepStrictEqual(store.getMemoryEntries(), ["main session entry"]);
});
it("skill_manage returns the disabled error in a subagent session", async () => {
let tool: any;
const mockPi = { registerTool: (def: any) => { tool = def; } } as any;
const store = new SkillStore({
globalSkillsDir: path.join(tmpDir, "skills"),
piGlobalSkillsDir: path.join(tmpDir, "pi-skills"),
projectSkillsDir: null,
projectName: null,
legacySkillsDir: path.join(tmpDir, "legacy-skills"),
migrationSentinelPath: path.join(tmpDir, ".migrated"),
});
registerSkillTool(mockPi, store);
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const result = await tool.execute(
"tc",
{ action: "create", name: "demo", description: "d", scope: "global", content: "body" },
undefined,
undefined,
{ sessionManager },
);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE);
assert.strictEqual(result.details.success, false);
assert.ok(!fs.existsSync(path.join(tmpDir, "skills")), "blocked skill writes must not create the skills dir");
});
it("session flush skips compact and shutdown flushes in a subagent session", async () => {
const handlers: Record<string, Function[]> = {};
const mockPi = {
on(event: string, handler: Function) {
(handlers[event] ??= []).push(handler);
},
async exec() {
throw new Error("subagent flush must not spawn a child pi");
},
registerTool() {},
registerCommand() {},
} as any;
let directCalls = 0;
const config = {
flushOnCompact: true,
flushOnShutdown: true,
flushMinTurns: 1,
flushCompactTimeoutMs: 60000,
reviewTransport: "direct",
} as MemoryConfig;
const sessionManager = { getBranch: () => [] };
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
setupSessionFlush(mockPi, { getMemoryEntries: () => [], getUserEntries: () => [] } as any, null, config, null, null, {
runDirectMemoryCompletion: async () => {
directCalls++;
return { ok: true } as any;
},
});
const ctx = { sessionManager, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, ctx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, ctx);
}
for (const handler of handlers["session_shutdown"] ?? []) {
await handler({ reason: "exit" }, ctx);
}
assert.strictEqual(directCalls, 0, "subagent sessions must not flush");
// Control: the same handlers must still flush an unmarked (main) session.
const mainCtx = { sessionManager: { getBranch: () => [] }, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, mainCtx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, mainCtx);
}
assert.ok(directCalls > 0, "main sessions must still flush");
});
});
+13
View File
@@ -0,0 +1,13 @@
# my-pi 维护约定(pi-notify)
- 扩展入口是 `pi-notify/src/index.ts`,Kitty/macOS 后端位于 `src/terminal.ts`,测试保留在 `tests/`,来源记录是 `UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。
- 完成通知默认只在 TUI `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只在 `permissions:ui_prompt` 后立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。非 TUI 输出完整性、策略/AutoReview 静默决定、隐私模式、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
- 默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现,否则会重复通知。
## 验证
- 在本目录内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
+1209 -1586
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@
"@earendil-works/pi-coding-agent": "*" "@earendil-works/pi-coding-agent": "*"
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-coding-agent": "latest",
"@types/node": "24.10.13", "@types/node": "24.10.13",
"typescript": "6.0.3" "typescript": "6.0.3"
}, },
+20
View File
@@ -0,0 +1,20 @@
# my-pi 维护约定(pi-permission-system)
- 扩展入口是 `pi-permission-system/src/index.ts`,公共跨扩展服务入口是 `pi-permission-system/src/service.ts`,测试保留在该目录的 `test/`;根包经 `extensions/permission-system.ts` 直接加载源码。
- 同步上游必须从明确 tag/commit 移植并记录快照;保留上游 `LICENSE`、作者和来源,不导入 `dist/`、嵌套 `.git` 或上游包目录的 `.pi` 本地状态。双上游来源见 `UPSTREAM.md` 与 `docs/auto-review/UPSTREAM.md`。
- 配置在 `authorizerChain` 中显式命名的 authorizer 可对任一普通策略 `ask`(包括 `path`)返回最终 `allow` / `deny`;`defer` 才继续下一个 authorizer 或终端人工 authority。
- `auto-review` 是内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。
- reviewer 是对话式 Codex Guardian 风格审查:以独立请求(不写入主会话)把有界 active-branch 对话记录与权限请求交给模型,模型只能通过 `approve` / `reject` 两个工具给出带 `reason` 的结论,不强制 `toolChoice`。`approve` 自动批准(包括 `path`);`reject` 以及配置、模型、超时、Provider 错误、截断、未调用工具、多次调用或参数无效等一切异常都必须 `defer` 到人工提示,并在弹窗中显示原因(模型理由标注为模型生成)。正文文本即使是合法 JSON 也不得作为结论;只有用户按 Esc 中止当前操作时才直接拒绝。不得在 authorizer chain 外增加第二层内置授权范围限制,也不得恢复 annotator 机制。唯一例外是 `protectedWrites`:硬防御文件里配置的确定性 human-only 写路径——它不经 authorizer chain(含 AutoReview)、yolo 或 Session grant,无确认界面时直接拒绝,本身不产生裁决,只是永远把人拉进回路。
- reviewer 默认跟随会话当前主模型(每次审查实时读取 `ctx.model`),经 `modelRegistry.streamSimple(...).result()` 调用;`autoReview.provider` / `autoReview.model` 必须同时设置(固定审查模型)或同时留空,只设其一视为配置错误并 `defer`。默认 `reasoning: "low"`(仅对支持推理的模型生效)、总超时 30 秒、最多 2 次尝试(仅 Provider 错误重试)、`maxOutputTokens` 4000。只把 active branch 中的直接用户消息与已识别、未取消的结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权;工具结果可替换为更短的 pi-compaction 摘要,但仍属不可信证据。同一条用户消息内完全相同的请求只缓存 `approve` 结论。
- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,不设 provider/model 种子值。`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`,以及 `protectedWrites` 人工写保护清单。
- `protectedWrites` 是确定性的 human-only 写路径,不属于普通策略也不是第二个授权范围:命中目标的 `write`/`edit` 与本地 `bash` 写入(redirect-out、写操作程序参数、`bash -c`/`eval` 等包装载荷中的受保护路径)一律弹出人工确认,跳过 AutoReview、yolo 与既有 Session grant,弹窗不提供「本会话允许」选项;没有可用确认界面(无 UI、子代理转发终端)时直接拒绝。读取不受影响;`ssh_bash` 等远端 shell 工具不参与本机路径保护。
- 策略默认允许常规只读与低风险工具;`web_fetch`、`ctx_fetch_and_index`、`skill_manage` 保持 `ask`(模型自选的 URL 可能外传数据,skill 写入会进入所有后续会话);硬防御默认只保留确定性的破坏性 Shell 命令;Git 只读管道命令放行,其余非只读操作、包管理、文件/系统/网络高风险操作与 `mcp__<server>__<tool>` 调用按唯一全局 policy 判定。项目外路径不产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;唯一的组合包 hard-guards 文件不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
- 内置 AutoReview 实现位于 `src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。
- reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`。
- 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。权限系统只能在调用时裁决,不能读写 active tool 列表(工具显隐由 `pi-tool-search` 独占管理)。
- 修改 gate、authorizer chain、子代理转发、公共 service 类型、reviewer 策略、可信证据边界、配置结构、hard-guard precedence、模型解析或 authorizer/lifecycle 注册时,必须同步更新包内 README/架构文档、配置文档、schema 和相应测试。
## 验证
- 在本目录内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。涉及内置 AutoReview 或硬防御时,还要验证 authorizer 对普通工具与 `path` 的 `allow` / `deny` / `defer`、hard-guard precedence、未配置/未知 provider 或模型时的 `defer` 与一次性提示,以及 owner-only 持久化。
+23
View File
@@ -0,0 +1,23 @@
# 上游来源(多个)
本目录由 `my-pi` 组合包直接维护,组合了两份独立 MIT 许可的来源。
## pi-permission-system
- 官方仓库:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>
- 导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`
- 导入版本:`pi-permission-system-v26.2.1`
- 许可证:MIT,上游 `LICENSE` 原样保留。
## pi-permission-auto-review(已整合)
- 官方仓库:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>
- 导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`
- 导入版本:`@mzwing/pi-permission-auto-review@0.2.0`
- 许可证:MIT,保留在 `LICENSES/pi-permission-auto-review-MIT.txt`。
AutoReview 源码已整合进 `src/auto-review/`,不再保留或加载第二个顶层扩展;其来源记录与本地集成差异见 `docs/auto-review/UPSTREAM.md`。
## 本地维护
本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。
+636 -2285
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -84,9 +84,9 @@
"@earendil-works/pi-tui": { "optional": true } "@earendil-works/pi-tui": { "optional": true }
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-ai": "0.84.2", "@earendil-works/pi-ai": "latest",
"@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "0.84.2", "@earendil-works/pi-tui": "latest",
"@types/node": "24.10.13", "@types/node": "24.10.13",
"rollup": "4.62.2", "rollup": "4.62.2",
"rollup-plugin-dts": "6.4.1", "rollup-plugin-dts": "6.4.1",
+20
View File
@@ -0,0 +1,20 @@
# my-pi 维护约定(pi-rtk-optimizer)
- 扩展入口是 `pi-rtk-optimizer/index.ts`,主要实现位于 `pi-rtk-optimizer/src/`。
- 组合包运行环境为 Node.js 22.19 或更高版本;本子目录开发验证还需要 npm、Bun 和项目声明的开发依赖。
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合和兜底截断;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。
- RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
- FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
- 修改配置结构时同步检查默认值、归一化逻辑、设置界面、类型定义、README 示例和相关测试。
## 验证
按改动范围选择最小充分验证:
- `npm run build`:TypeScript 转译检查。
- `npm run typecheck`:完整类型检查。
- `npm run test`:运行 Bun 测试。
- `npm run check`:类型、测试和打包检查的完整验证。
+15
View File
@@ -0,0 +1,15 @@
# 上游来源
- 官方仓库:<https://github.com/MasuRii/pi-rtk-optimizer>
- 导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`
- 快照日期 / 导入版本:2026-07-03,`0.9.0`
- 许可证:MIT,`LICENSE` 原样保留。
## 本地维护
本目录由 `my-pi` 组合包直接维护,不是 submodule,也不保留嵌套 `.git`。同步上游时必须从明确 tag/commit 审查并移植,不得覆盖本地修改,不得使用 npm 预编译产物作为导入源。
## 本地改动概要
- 默认只负责非搜索工具输出压缩;RTK 命令改写收敛为可选能力(`commandRewritingEnabled` 默认 `false`)。
- `readCompaction`、`sourceCodeFiltering`、`smartTruncate` 默认关闭,源码读取保持原样。
+1725 -1245
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -53,6 +53,8 @@
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest",
"esbuild": "0.28.1", "esbuild": "0.28.1",
"typescript": "6.0.3" "typescript": "6.0.3"
}, },
@@ -62,8 +64,8 @@
] ]
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0", "@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0" "@earendil-works/pi-tui": "*"
}, },
"overrides": { "overrides": {
"protobufjs": "7.6.3", "protobufjs": "7.6.3",
+20
View File
@@ -0,0 +1,20 @@
# my-pi 维护约定(pi-ssh)
- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `src/ssh2-transport.ts`,有界自适应远程搜索位于 `src/remote-search.ts`,AES-GCM vault 位于 `src/vault.ts`,选择性导入入口是根 `configure.sh ssh` 与 `scripts/ssh-config.mjs`,权限桥接位于 `permission-integration.ts`,测试保留在 `test/`,来源记录是 `UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git`、`node_modules` 或构建产物。
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;认证可使用密码、私钥文件或 `ssh2` 直连的 SSH Agent socket(包括 1Password),Agent 模式只保存 socket 路径且不得导出私钥。OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2` 与 `cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、Agent 提供的签名材料、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
- `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行默认 `input.path` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭本机路径推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
- `ssh_bash` 必须在唯一全局 policy 的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;不得新增第二个并行 `tool_call` 审批层。
- SSH 权限沿用 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。全部远端路径不得送入基于本机 cwd 的默认 `input.path` 归一化。
- RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read`、`ssh_find` 或 `ssh_grep` 返回。
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。
- 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system` 的 `npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。
- 根加载顺序必须保持 `pi-permission-system` → `subagents` → `pi-ssh` → `pi-chrome` → `pi-tool-search` → `system-prompt`(`extensions/system-prompt.ts` 为最后一个条目),确保内置 authorizer 与权限服务由同一扩展先注册/发布、subagents wrapper 随后把权限扩展注入子会话 mandatory 列表、SSH 桥接随后安装、Chrome 工具可在授权后进入目录、Tool Search 在提示词组装前建立完整工具目录,且单一提示词出口最后汇总所有扩展的结构化贡献。
+13
View File
@@ -1,3 +1,16 @@
# my-pi 维护约定(优先于下文上游约定)
- 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、作者和 LICENSE;不得导入嵌套 `.git`、上游 `.pi` 状态、`node_modules`、覆盖率或构建产物。
- 根包必须通过 `file:./pi-subagents` 提供运行时依赖,并只通过 `extensions/subagents.ts` wrapper 直接加载仓库源码;不得绕过 wrapper 直接列出上游入口,不得同时安装或加载 npm 预编译入口,也不得把 Pi host runtime 作为组合包开发依赖带入根安装。3 个根编排工具由 Tool Search 的 `subagents` 组按需加载。
- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。
- `extensions/subagents.ts` 是组合包配置入口:它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。
- mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。
- 三个 my-pi 扩展选项必须保持 opt-in 且上游兼容,不传时 `buildAgentPrompt` 逐字节维持上游行为:`basePrompt?: () => string | undefined`(返回非空串时替代父提示词嵌入,省略 `<sub_agent_context>` 与 replace 头,保留环境/worktree/custom 分区与 extras,`<active_agent>` tag 仍恰好一个);`bundledAgentsDir?: string`(目录下 `*.md` 经现有 parser 加载为内置 Agent,替换同名嵌入默认项、排序确定性,标记 `isDefault` 无 `source`/`sourcePath`,优先级 embedded < bundled < global < workspace < project);`excludedExtensionPaths?: readonly string[]`(canonical 化后从每个子会话加载扩展中移除,无论 frontmatter,与 mandatory 路径重叠必须 fail closed)。组合包由 `extensions/subagents.ts` 注入 `config/prompt/subagent/` preamble 与 agents 目录并排除 `pi-ask-user`。
- `ask` 可由 auto-review 明确 `allow` 时自动批准,包括路径请求;reviewer `defer` 或异常必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
# Development Rules # Development Rules
## Conversational Style ## Conversational Style
+2 -2
View File
@@ -2,13 +2,13 @@
A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types. A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types.
> **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. Its source, tests, host-owned `extensions/subagents.ts` wrapper, and `pi-permission-system` child lifecycle bridge are retained, but the root bundle currently declares no `pi-subagents` runtime dependency and does not default-load the wrapper. See [`UPSTREAM.md`](UPSTREAM.md). > **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. The root bundle installs it through `file:./pi-subagents`, default-loads the host-owned `extensions/subagents.ts` wrapper, and exposes its orchestration tools through Tool Search's checked-in `subagents` group. The local source implements the `pi-permission-system` child lifecycle bridge described below. See [`UPSTREAM.md`](UPSTREAM.md).
## my-pi permission integration ## my-pi permission integration
Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed. Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed.
The retained bundle wrapper configures this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`, although that wrapper is currently disabled in the root extension list. If re-enabled, these exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list. The bundle loads this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`. These exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list.
Each child system prompt carries one sanitized `<active_agent name="..."/>` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator. Each child system prompt carries one sanitized `<active_agent name="..."/>` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator.
+1 -1
View File
@@ -13,4 +13,4 @@
初始导入保留了上游 TypeScript 源码、测试、示例、文档、媒体、包元数据和许可证。上游 `.github/` 自动化与本地 `.pi/` 状态有意未导入。 初始导入保留了上游 TypeScript 源码、测试、示例、文档、媒体、包元数据和许可证。上游 `.github/` 自动化与本地 `.pi/` 状态有意未导入。
源码包含在根包中,并通过根 `file:./pi-subagents` 依赖安装,而宿主自有的 `extensions/subagents.ts` wrapper 当前不由根 `pi.extensions` 默认加载。本地快照新增了文档化的权限子会话生命周期桥接、根 authority 转发身份、活跃 Agent 身份,以及宿主自有的精确路径 mandatory extension 策略。组合包 wrapper 保护 `extensions/permission-system.ts` 免受 Agent 控制的扩展过滤,并在该入口不可用时 fail closed。根编排工具归入 Tool Search 已提交的 `subagents` 分组,Pi host peer 标记为 optional,以免组合包安装第二套 Pi runtime。 源码包含在根包中,通过根 `file:./pi-subagents` 依赖安装,并由宿主自有的 `extensions/subagents.ts` wrapper 经根 `pi.extensions` 默认加载。本地快照新增了文档化的权限子会话生命周期桥接、根 authority 转发身份、活跃 Agent 身份,以及宿主自有的精确路径 mandatory extension 策略。组合包 wrapper 保护 `extensions/permission-system.ts` 免受 Agent 控制的扩展过滤,并在该入口不可用时 fail closed。另新增三个 opt-in 扩展选项(`basePrompt`、`bundledAgentsDir`、`excludedExtensionPaths`,经 `createPiSubagentsExtension` → `AgentManager` → agent-runner → `buildAgentPrompt` 透传),不传时保持上游逐字节行为。根编排工具归入 Tool Search 已提交的 `subagents` 分组,Pi host peer 标记为 optional,以免组合包安装第二套 Pi runtime。
+593 -2342
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -57,9 +57,9 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.14", "@biomejs/biome": "^2.4.14",
"@earendil-works/pi-ai": "0.84.2", "@earendil-works/pi-ai": "latest",
"@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "0.84.2", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "^4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
+6 -4
View File
@@ -104,15 +104,17 @@ const FENCE = /^---[ \t]*$/;
* what `parseFrontmatter` (the load side) considers a frontmatter block. * what `parseFrontmatter` (the load side) considers a frontmatter block.
* *
* Lines keep their terminators, so an edit preserves the file's existing line * Lines keep their terminators, so an edit preserves the file's existing line
* endings instead of rewriting CRLF to LF. Returns undefined when there is no * endings instead of rewriting CRLF to LF. A leading BOM stays attached to the
* usable block — notably for a BOM-prefixed file, which the parser also reads * fence line — the parser skips it, so the block behind it is real frontmatter.
* as having none, so writing a key into it would change nothing on load. * Returns undefined when there is no usable block.
*/ */
function splitFrontmatter(content: string): function splitFrontmatter(content: string):
| { lines: string[]; openIdx: number; closeIdx: number; eol: string } | { lines: string[]; openIdx: number; closeIdx: number; eol: string }
| undefined { | undefined {
const lines = content.split(/(?<=\n)/); const lines = content.split(/(?<=\n)/);
if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, ""))) return undefined; if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, "").replace(/^/, ""))) {
return undefined;
}
const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, ""))); const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, "")));
if (closeIdx === -1) return undefined; if (closeIdx === -1) return undefined;
return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" }; return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" };
+19 -1
View File
@@ -237,7 +237,14 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
finally { unregisterPermissionChildSession(session); } finally { unregisterPermissionChildSession(session); }
} }
export interface AgentManagerOptions extends MandatoryExtensionPolicy {} export interface AgentManagerOptions extends MandatoryExtensionPolicy {
/** Canonical bundle-owned extension entries removed from every child session regardless of Agent frontmatter. */
excludedExtensionPaths?: readonly string[];
/** Per-spawn base prompt supplier — non-blank replaces the inherited parent prompt in buildAgentPrompt. */
basePrompt?: () => string | undefined;
/** Bundle-shipped agent .md directory forwarded to nested-delegation registries. */
bundledAgentsDir?: string;
}
export class AgentManager { export class AgentManager {
private agents = new Map<string, AgentRecord>(); private agents = new Map<string, AgentRecord>();
@@ -248,6 +255,9 @@ export class AgentManager {
private onUsage?: OnAgentUsage; private onUsage?: OnAgentUsage;
private maxConcurrent: number; private maxConcurrent: number;
private readonly mandatoryExtensionPaths: readonly string[]; private readonly mandatoryExtensionPaths: readonly string[];
private readonly excludedExtensionPaths: readonly string[];
private readonly basePrompt?: () => string | undefined;
private readonly bundledAgentsDir?: string;
/** Base repos worktrees were created from — so dispose() can prune them all, /** Base repos worktrees were created from — so dispose() can prune them all,
* not just the parent repo (caller-supplied cwd can target other repos). */ * not just the parent repo (caller-supplied cwd can target other repos). */
private worktreeRepos = new Set<string>(); private worktreeRepos = new Set<string>();
@@ -281,6 +291,11 @@ export class AgentManager {
this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths( this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(
options.mandatoryExtensionPaths, options.mandatoryExtensionPaths,
); );
this.excludedExtensionPaths = normalizeMandatoryExtensionPaths(
options.excludedExtensionPaths,
);
this.basePrompt = options.basePrompt;
this.bundledAgentsDir = options.bundledAgentsDir;
// Cleanup completed agents after 10 minutes (but keep sessions for resume) // Cleanup completed agents after 10 minutes (but keep sessions for resume)
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
this.cleanupInterval.unref(); this.cleanupInterval.unref();
@@ -442,6 +457,9 @@ export class AgentManager {
// rootSessionId; programmatic callers fall back to their current context. // rootSessionId; programmatic callers fall back to their current context.
authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(), authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
mandatoryExtensionPaths: this.mandatoryExtensionPaths, mandatoryExtensionPaths: this.mandatoryExtensionPaths,
excludedExtensionPaths: this.excludedExtensionPaths,
basePrompt: this.basePrompt,
bundledAgentsDir: this.bundledAgentsDir,
// Worktree wins for the working dir (the agent must run in the copy — // Worktree wins for the working dir (the agent must run in the copy —
// which, with a custom cwd, was created from that target). Config stays // which, with a custom cwd, was created from that target). Config stays
// with the parent project when a caller-supplied cwd is in play; it must // with the parent project when a caller-supplied cwd is in play; it must
+44 -9
View File
@@ -24,6 +24,7 @@ import { DEFAULT_AGENTS } from "./default-agents.js";
import { detectEnv } from "./env.js"; import { detectEnv } from "./env.js";
import { import {
assertMandatoryExtensionsLoaded, assertMandatoryExtensionsLoaded,
extensionPathMatches,
isMandatoryExtensionPath, isMandatoryExtensionPath,
normalizeMandatoryExtensionPaths, normalizeMandatoryExtensionPaths,
} from "./mandatory-extensions.js"; } from "./mandatory-extensions.js";
@@ -432,6 +433,21 @@ export interface RunOptions {
authoritySessionId?: string; authoritySessionId?: string;
/** Canonical bundle-owned extension entries that Agent config cannot disable. */ /** Canonical bundle-owned extension entries that Agent config cannot disable. */
mandatoryExtensionPaths?: readonly string[]; mandatoryExtensionPaths?: readonly string[];
/**
* Canonical bundle-owned extension entries removed from every child session
* regardless of Agent frontmatter — the host-level counterpart of
* `exclude_extensions`. Overlap with `mandatoryExtensionPaths` is rejected
* by `createPiSubagentsExtension`.
*/
excludedExtensionPaths?: readonly string[];
/**
* Per-spawn base prompt supplier. A non-blank return replaces the inherited
* parent prompt and the `<sub_agent_context>` bridge / replace-mode header
* inside `buildAgentPrompt`; undefined or blank keeps upstream composition.
*/
basePrompt?: () => string | undefined;
/** Bundle-shipped agent .md directory forwarded to nested-delegation registries. */
bundledAgentsDir?: string;
/** Override working directory (e.g. for worktree isolation). */ /** Override working directory (e.g. for worktree isolation). */
cwd?: string; cwd?: string;
/** /**
@@ -651,16 +667,18 @@ export async function runAgent(
} }
} }
// Build system prompt from agent config // Build system prompt from agent config. `basePrompt` resolves once per
// spawn — a host-owned base owns the prompt base instead of the parent's.
const basePrompt = options.basePrompt?.();
let systemPrompt: string; let systemPrompt: string;
if (agentConfig) { if (agentConfig) {
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras); systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
} else { } else {
// Unknown type fallback: spread the canonical general-purpose config (defensive — // Unknown type fallback: spread the canonical general-purpose config (defensive —
// unreachable in practice since index.ts resolves unknown types before calling runAgent). // unreachable in practice since index.ts resolves unknown types before calling runAgent).
const fallback = DEFAULT_AGENTS.get("general-purpose"); const fallback = DEFAULT_AGENTS.get("general-purpose");
if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`); if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras); systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
} }
// Child permission policy is selected by the agent-definition filename, // Child permission policy is selected by the agent-definition filename,
// which is also where pi-permission-system reads `permission:` frontmatter. // which is also where pi-permission-system reads `permission:` frontmatter.
@@ -701,6 +719,11 @@ export async function runAgent(
); );
const optionalExtensionsDisabled = extensions === false; const optionalExtensionsDisabled = extensions === false;
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths); const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
// Bundle-level exclusion: canonicalized like mandatory paths, removed from
// every child's loaded set regardless of the Agent's own extension policy.
const excludedExtensionPaths = normalizeMandatoryExtensionPaths(options.excludedExtensionPaths);
const isExcludedExtension = (extensionPath: string) =>
excludedExtensionPaths.some((path) => extensionPathMatches(extensionPath, path));
const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0; const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0;
const extensionsSpec = Array.isArray(extensions) const extensionsSpec = Array.isArray(extensions)
@@ -718,10 +741,12 @@ export async function runAgent(
if (canons.some((name) => excludeNames.has(name))) return false; if (canons.some((name) => excludeNames.has(name))) return false;
return loadAll || canons.some((name) => keepNames.has(name)); return loadAll || canons.some((name) => keepNames.has(name));
}; };
// An excluded path stays out even when an `extensions:` path entry asks for
// it — host policy outranks Agent frontmatter, same as everywhere else.
const requestedPaths = [ const requestedPaths = [
...(extensionsSpec?.paths ?? []), ...(extensionsSpec?.paths ?? []),
...mandatoryExtensionPaths, ...mandatoryExtensionPaths,
]; ].filter((path) => !isExcludedExtension(path));
const additionalExtensionPaths = requestedPaths.length const additionalExtensionPaths = requestedPaths.length
? [...new Set(requestedPaths)] ? [...new Set(requestedPaths)]
: undefined; : undefined;
@@ -729,16 +754,25 @@ export async function runAgent(
// against discovery, while mandatory identity is checked by exact canonical path. // against discovery, while mandatory identity is checked by exact canonical path.
let discoveredNames: Set<string> | undefined; let discoveredNames: Set<string> | undefined;
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined = const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
noExtensions || (loadAll && !hasExcludes) (noExtensions || (loadAll && !hasExcludes)) && excludedExtensionPaths.length === 0
? undefined ? undefined
: (base) => { : (base) => {
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path))); discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
return { return {
...base, ...base,
extensions: base.extensions.filter((extension) => extensions: base.extensions.filter((extension) => {
isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths) if (isExcludedExtension(extension.path)) {
|| normalPolicySelects(extension.path) if (normalPolicySelects(extension.path)) {
), options.onToolActivity?.({
type: "end",
toolName: `extension-error:extension "${basename(extension.path)}" is excluded from subagent sessions by bundle policy`,
});
}
return false;
}
return isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)
|| normalPolicySelects(extension.path);
}),
}; };
}; };
@@ -880,6 +914,7 @@ export async function runAgent(
maxSubagentDepth: effectiveMaxDepth, maxSubagentDepth: effectiveMaxDepth,
allowedSubagents: agentConfig.allowedSubagents, allowedSubagents: agentConfig.allowedSubagents,
configCwd, configCwd,
bundledAgentsDir: options.bundledAgentsDir,
}) })
: []; : [];
const nestedToolNames = new Set(nestedTools.map(tool => tool.name)); const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
+15 -5
View File
@@ -55,14 +55,20 @@ export function setFallbackSubagent(v: string | undefined): void { fallbackSubag
/** /**
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings), * Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
* then user agents overlaid on top (same name overrides the default). * then bundle-shipped defaults (bundledAgentsDir — they replace same-name
* embedded defaults and are themselves stamped `isDefault`), then user agents
* overlaid on top (same name overrides everything before it).
* Pure — callers that must not disturb the process-wide registry (nested * Pure — callers that must not disturb the process-wide registry (nested
* delegation resolving agents from its own config root) build their own map. * delegation resolving agents from its own config root) build their own map.
*/ */
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> { export function buildAgentRegistry(
userAgents: Map<string, AgentConfig>,
bundledAgents?: Map<string, AgentConfig>,
): Map<string, AgentConfig> {
const registry = new Map<string, AgentConfig>(); const registry = new Map<string, AgentConfig>();
if (!disableDefaults) { if (!disableDefaults) {
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config); for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
if (bundledAgents) for (const [name, config] of bundledAgents) registry.set(name, config);
} }
for (const [name, config] of userAgents) registry.set(name, config); for (const [name, config] of userAgents) registry.set(name, config);
return registry; return registry;
@@ -70,12 +76,16 @@ export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<st
/** /**
* Register agents into the unified registry. * Register agents into the unified registry.
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name). * Starts with DEFAULT_AGENTS (then bundle-shipped defaults), then overlays
* user agents (overrides defaults with same name).
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning. * Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
*/ */
export function registerAgents(userAgents: Map<string, AgentConfig>): void { export function registerAgents(
userAgents: Map<string, AgentConfig>,
bundledAgents?: Map<string, AgentConfig>,
): void {
agents.clear(); agents.clear();
for (const [name, config] of buildAgentRegistry(userAgents)) { for (const [name, config] of buildAgentRegistry(userAgents, bundledAgents)) {
agents.set(name, config); agents.set(name, config);
} }
} }
+78 -20
View File
@@ -79,32 +79,60 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
} }
const { frontmatter: fm, body } = parsed; const { frontmatter: fm, body } = parsed;
// Claude Code's rule: `name:` IS the agent type, and the filename need not // A reserved-name skip carries no `warnSkippedOverride`: this file would
// match. Absent, the filename stands in — Claude Code requires the field, // have registered under its *declared* name, which nothing else can hold
// but most files here predate it and must keep loading. // (a colon keeps it out of the registry), so it shadowed nothing. Passing
// the filename instead would report a substitution of an unrelated agent
// that never happened.
const name = resolveDeclaredName(path, filenameType, fm);
if (name === undefined) continue;
agents.set(name, configFromFrontmatter(name, fm, body, { source, sourcePath: path }));
}
}
/**
* Claude Code's rule: `name:` IS the agent type, and the filename need not
* match. Absent, the filename stands in — Claude Code requires the field, but
* most files here predate it and must keep loading. `||`, not `??`: a quoted
* empty or all-whitespace `name:` would otherwise register the agent under the
* empty type — unspawnable, and it takes the filename-derived one down with it.
*
* A declared name containing ":" is refused — reserved for plugin-scoped
* identifiers. Refusing beats silently substituting: the file would otherwise
* load under its filename, so `Agent({subagent_type})` would succeed against
* an agent whose declared identity nothing honoured.
*/
function resolveDeclaredName(
path: string,
filenameType: string,
fm: Record<string, unknown>,
): string | undefined {
const declared = str(fm.name)?.trim(); const declared = str(fm.name)?.trim();
if (declared?.includes(RESERVED_IN_TYPE)) { if (declared?.includes(RESERVED_IN_TYPE)) {
// Refusing beats silently substituting: the file would otherwise load
// under its filename, so `Agent({subagent_type})` would succeed against
// an agent whose declared identity nothing honoured.
warnIfNew( warnIfNew(
`Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for ` `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.", + "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
); );
// No `warnSkippedOverride`: this file would have registered under its return undefined;
// *declared* name, which nothing else can hold (a colon keeps it out of }
// the registry), so it shadowed nothing. Passing the filename instead return declared || filenameType;
// would report a substitution of an unrelated agent that never happened.
continue;
} }
// `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
// register the agent under the empty type — unspawnable, and it takes the
// filename-derived one down with it.
const name = declared || filenameType;
/**
* Build an AgentConfig from parsed frontmatter. `provenance` is the
* project/global source record; bundled agents pass none and are stamped as
* embedded defaults instead — `isDefault` with no `source`/`sourcePath`, so
* UIs treat them as built-ins rather than editable/deletable files.
*/
function configFromFrontmatter(
name: string,
fm: Record<string, unknown>,
body: string,
provenance?: { source: "project" | "global"; sourcePath: string },
): AgentConfig {
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools); const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
return {
agents.set(name, {
name, name,
// Only `display_name` now: `name` is the type, and `getConfig` already // Only `display_name` now: `name` is the type, and `getConfig` already
// falls back to the type when no label is set — so a Claude Code file // falls back to the type when no label is set — so a Claude Code file
@@ -133,10 +161,40 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
memory: parseMemory(fm.memory), memory: parseMemory(fm.memory),
isolation: parseIsolation(fm.isolation), isolation: parseIsolation(fm.isolation),
enabled: fm.enabled !== false, // default true; explicitly false disables enabled: fm.enabled !== false, // default true; explicitly false disables
source, isDefault: provenance === undefined ? true : undefined,
sourcePath: path, source: provenance?.source,
}); sourcePath: provenance?.sourcePath,
};
} }
/**
* Load bundle-shipped agent .md files as additional embedded defaults
* (the `bundledAgentsDir` extension option). Same frontmatter parser as
* user agent files; entries carry `isDefault` with no `source`/`sourcePath`
* so UIs treat them as built-ins rather than editable files. Files are read
* in sorted order so registration order is deterministic.
*/
export function loadBundledAgents(dir: string, strict = false): Map<string, AgentConfig> {
const agents = new Map<string, AgentConfig>();
if (!existsSync(dir)) return agents;
let files: string[];
try {
files = readdirSync(dir).filter(f => f.endsWith(".md")).sort();
} catch {
return agents;
}
for (const file of files) {
const filenameType = basename(file, ".md");
const path = join(dir, file);
const parsed = readAgentFile(path, strict);
if (!parsed) continue;
const name = resolveDeclaredName(path, filenameType, parsed.frontmatter);
if (name === undefined) continue;
agents.set(name, configFromFrontmatter(name, parsed.frontmatter, parsed.body));
}
return agents;
} }
/** /**
+56 -8
View File
@@ -18,15 +18,15 @@ import { Type } from "@sinclair/typebox";
import { abortable } from "./abortable.js"; import { abortable } from "./abortable.js";
import { hasAgentBadge, renderAgentName } from "./agent-color.js"; import { hasAgentBadge, renderAgentName } from "./agent-color.js";
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js"; import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
import { AgentManager } from "./agent-manager.js"; import { AgentManager, type AgentManagerOptions } from "./agent-manager.js";
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js"; import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js"; import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
import { inChildSessionContext } from "./child-context.js"; import { inChildSessionContext } from "./child-context.js";
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js"; import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
import { loadCustomAgents } from "./custom-agents.js"; import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
import { GroupJoinManager } from "./group-join.js"; import { GroupJoinManager } from "./group-join.js";
import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js"; import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
import type { MandatoryExtensionPolicy } from "./mandatory-extensions.js"; import { canonicalExistingPath, isMandatoryExtensionPath, normalizeMandatoryExtensionPaths } from "./mandatory-extensions.js";
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js"; import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
import { runMentionClone } from "./mention-clone.js"; import { runMentionClone } from "./mention-clone.js";
import { type ModelRegistry, resolveModel } from "./model-resolver.js"; import { type ModelRegistry, resolveModel } from "./model-resolver.js";
@@ -281,13 +281,53 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
return isFullSet ? "*" : tools.join(", "); return isFullSet ? "*" : tools.join(", ");
} }
export interface PiSubagentsExtensionOptions extends MandatoryExtensionPolicy {} export interface PiSubagentsExtensionOptions extends AgentManagerOptions {
/**
* Per-spawn base prompt supplier. A non-blank return owns the child's prompt
* base: `buildAgentPrompt` drops the inherited parent prompt, the
* `<sub_agent_context>` bridge and the replace-mode header, keeping the
* env/worktree blocks, the `<agent_instructions>` section and extras.
* Undefined or blank keeps upstream composition byte-for-byte.
*/
basePrompt?: () => string | undefined;
/**
* Directory of bundle-shipped agent `.md` files, loaded with the same
* frontmatter parser as user agents and registered as embedded defaults
* (`isDefault`, no `source`/`sourcePath`). Priority: embedded < bundled <
* global < workspace < project.
*/
bundledAgentsDir?: string;
/**
* Bundle-owned extension entry paths removed from every child session
* regardless of Agent frontmatter — the host-level counterpart of
* `exclude_extensions`. Canonicalized like `mandatoryExtensionPaths`;
* overlap with it is rejected at creation.
*/
excludedExtensionPaths?: readonly string[];
}
/** Create a host-configured extension; Agent frontmatter cannot mutate these options. */ /** Create a host-configured extension; Agent frontmatter cannot mutate these options. */
export function createPiSubagentsExtension( export function createPiSubagentsExtension(
options: PiSubagentsExtensionOptions = {}, options: PiSubagentsExtensionOptions = {},
): (pi: ExtensionAPI) => void { ): (pi: ExtensionAPI) => void {
return (pi) => installPiSubagentsExtension(pi, options); // Fail fast at bundle-configuration time: a mandatory entry is the one thing
// an excluded entry can never be, so an overlap is a broken bundle policy.
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
const excludedExtensionPaths = normalizeMandatoryExtensionPaths(options.excludedExtensionPaths);
for (const path of excludedExtensionPaths) {
if (isMandatoryExtensionPath(path, mandatoryExtensionPaths)) {
throw new Error(`Extension path is both mandatory and excluded: ${path}`);
}
}
const bundledAgentsDir = options.bundledAgentsDir === undefined
? undefined
: canonicalExistingPath(options.bundledAgentsDir);
return (pi) => installPiSubagentsExtension(pi, {
...options,
mandatoryExtensionPaths,
excludedExtensionPaths,
bundledAgentsDir,
});
} }
const defaultExtension = createPiSubagentsExtension(); const defaultExtension = createPiSubagentsExtension();
@@ -371,10 +411,13 @@ function installPiSubagentsExtension(
// the initial load, which happens hundreds of lines before settings are applied. // the initial load, which happens hundreds of lines before settings are applied.
let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true; let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
/** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */ /** Reload agents from bundled/project/global dirs and merge with defaults (called on init and each Agent invocation). */
const reloadCustomAgents = (strict = false) => { const reloadCustomAgents = (strict = false) => {
const bundledAgents = options.bundledAgentsDir === undefined
? undefined
: loadBundledAgents(options.bundledAgentsDir, strict);
const userAgents = loadCustomAgents(process.cwd(), strict); const userAgents = loadCustomAgents(process.cwd(), strict);
registerAgents(userAgents); registerAgents(userAgents, bundledAgents);
}; };
// Initial load — the only strict one. A bad edit mid-session must not kill the // Initial load — the only strict one. A bad edit mid-session must not kill the
@@ -599,7 +642,12 @@ function installPiSubagentsExtension(
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
// pool grows in a session that will never drain it. // pool grows in a session that will never drain it.
if (reportUsage) pendingUsage.add(usage); if (reportUsage) pendingUsage.add(usage);
}, { mandatoryExtensionPaths: options.mandatoryExtensionPaths }); }, {
mandatoryExtensionPaths: options.mandatoryExtensionPaths,
excludedExtensionPaths: options.excludedExtensionPaths,
basePrompt: options.basePrompt,
bundledAgentsDir: options.bundledAgentsDir,
});
// Expose manager via Symbol.for() global registry for cross-package access. // Expose manager via Symbol.for() global registry for cross-package access.
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.). // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
+1 -1
View File
@@ -6,7 +6,7 @@ export interface MandatoryExtensionPolicy {
mandatoryExtensionPaths?: readonly string[]; mandatoryExtensionPaths?: readonly string[];
} }
function canonicalExistingPath(path: string): string { export function canonicalExistingPath(path: string): string {
if (!isAbsolute(path)) { if (!isAbsolute(path)) {
throw new Error(`Mandatory extension path must be absolute: ${path}`); throw new Error(`Mandatory extension path must be absolute: ${path}`);
} }
+11 -8
View File
@@ -67,6 +67,7 @@ import {
createAgentSession, createAgentSession,
DefaultResourceLoader, DefaultResourceLoader,
type ExtensionContext, type ExtensionContext,
type ExtensionToolContext,
getAgentDir, getAgentDir,
SessionManager, SessionManager,
type ToolDefinition, type ToolDefinition,
@@ -127,7 +128,10 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
{ ...(params as Record<string, unknown>), run_in_background: true } as typeof params, { ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
signal, signal,
onUpdate, onUpdate,
ctx, // Pi 1.3 narrowed tool ctx to ExtensionToolContext; the two added
// members (tools, executeTool) serve tool-to-tool calls, which the
// Agent tool never makes — runAgent reads only session fields.
ctx as ExtensionToolContext,
); );
}, },
}; };
@@ -148,6 +152,11 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
// the settings level instead, which is what a session that never ran // the settings level instead, which is what a session that never ran
// `/think` is on anyway. Same shim shape as `modelRuntime` below. // `/think` is on anyway. Same shim shape as `modelRuntime` below.
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel; const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
// The clone rebuilds a system prompt from cwd and agentDir, which is close
// but not the live one — extensions contribute to it per turn. Copy the
// real thing via the loader override, so the copy reasons under the
// instructions the user's model is actually working under.
const systemPrompt = ctx.getSystemPrompt?.();
// This off-screen clone must expose only its synthetic Agent tool. Loading // This off-screen clone must expose only its synthetic Agent tool. Loading
// host/project extensions would let active-tool owners such as Tool Search // host/project extensions would let active-tool owners such as Tool Search
// replace the clone's allowlist during session_start, and would also run // replace the clone's allowlist during session_start, and would also run
@@ -159,6 +168,7 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
noSkills: true, noSkills: true,
noPromptTemplates: true, noPromptTemplates: true,
noThemes: true, noThemes: true,
...(systemPrompt !== undefined && { systemPromptOverride: () => systemPrompt }),
}); });
const created = await runInChildSessionContext(() => const created = await runInChildSessionContext(() =>
createAgentSession({ createAgentSession({
@@ -185,13 +195,6 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
); );
session = created.session; session = created.session;
// The clone rebuilds a system prompt from cwd and agentDir, which is close
// but not the live one — extensions contribute to it per turn. Copy the
// real thing, so the copy reasons under the instructions the user's model
// is actually working under.
const systemPrompt = ctx.getSystemPrompt?.();
if (systemPrompt) session.agent.state.systemPrompt = systemPrompt;
// The conversation itself. Pushed rather than assigned so the array the // The conversation itself. Pushed rather than assigned so the array the
// session was built around stays the one it goes on using. // session was built around stays the one it goes on using.
session.agent.state.messages.push(...conversation.messages); session.agent.state.messages.push(...conversation.messages);
+7 -2
View File
@@ -15,7 +15,7 @@ import {
resolveEnabledTypeIn, resolveEnabledTypeIn,
resolveTypeIn, resolveTypeIn,
} from "./agent-types.js"; } from "./agent-types.js";
import { loadCustomAgents } from "./custom-agents.js"; import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js"; import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
import { resolveModel } from "./model-resolver.js"; import { resolveModel } from "./model-resolver.js";
import { checkModelScope } from "./model-scope.js"; import { checkModelScope } from "./model-scope.js";
@@ -100,6 +100,8 @@ export interface NestedToolContext {
allowedSubagents: "all" | string[]; allowedSubagents: "all" | string[];
/** Root used for agent/config discovery; may differ from the agent's working directory. */ /** Root used for agent/config discovery; may differ from the agent's working directory. */
configCwd: string; configCwd: string;
/** Bundle-shipped agent .md directory — registered as embedded defaults, like the top-level registry. */
bundledAgentsDir?: string;
} }
function textResult(text: string, isError = false) { function textResult(text: string, isError = false) {
@@ -145,7 +147,10 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
// Agents resolve from a registry built for THIS branch's config root (under // Agents resolve from a registry built for THIS branch's config root (under
// worktree isolation, the copy). Never via registerAgents — that is // worktree isolation, the copy). Never via registerAgents — that is
// process-global state shared with the main session and every other agent. // process-global state shared with the main session and every other agent.
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd)); const loadRegistry = () => buildAgentRegistry(
loadCustomAgents(context.configCwd),
context.bundledAgentsDir === undefined ? undefined : loadBundledAgents(context.bundledAgentsDir),
);
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined => const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
context.allowedSubagents === "all" context.allowedSubagents === "all"
? undefined ? undefined
+33 -4
View File
@@ -32,8 +32,18 @@ export interface PromptExtras {
* parent prompt forms an identical, cacheable byte prefix with the parent * parent prompt forms an identical, cacheable byte prefix with the parent
* session (the LLM's KV cache can then reuse those tokens across every spawn). * session (the LLM's KV cache can then reuse those tokens across every spawn).
* *
* A non-blank `base` (the `basePrompt` extension option, resolved per spawn)
* owns the child's prompt base instead of the inherited parent prompt: the
* `<sub_agent_context>` bridge and the replace-mode header are omitted, while
* the env/worktree blocks, agent-instructions section and extras keep their
* usual positions. Without an inherited prefix to keep cacheable, the
* active_agent tag leads the prompt in both modes — same as upstream replace
* mode — so session hooks can identify the child before before_agent_start.
* Undefined/blank keeps upstream composition byte-for-byte.
*
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode). * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
* @param extras Optional extra sections to inject (memory, preloaded skills). * @param extras Optional extra sections to inject (memory, preloaded skills).
* @param base Host-owned base prompt that replaces the inherited parent prompt when non-blank.
*/ */
export function buildAgentPrompt( export function buildAgentPrompt(
config: AgentConfig, config: AgentConfig,
@@ -41,6 +51,7 @@ export function buildAgentPrompt(
env: EnvInfo, env: EnvInfo,
parentSystemPrompt?: string, parentSystemPrompt?: string,
extras?: PromptExtras, extras?: PromptExtras,
base?: string,
): string { ): string {
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`; const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
@@ -72,7 +83,22 @@ Work only inside it — never in ${extras.worktreeBase}, even if other instructi
} }
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : ""; const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
const bundleBase = base?.trim();
if (config.promptMode === "append") { if (config.promptMode === "append") {
const customSection = config.systemPrompt?.trim()
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
: "";
if (bundleBase) {
// Host-owned base: replaces both the inherited parent prompt and the
// sub_agent_context bridge (the bundle preamble already encodes how a
// sub-agent works). With no parent prompt to keep cacheable, the tag
// leads the prompt — same position as replace mode — so early session
// hooks can spot the child before before_agent_start.
return activeAgentTag + bundleBase + "\n\n" + envBlock + worktreeBlock + customSection + extrasSuffix;
}
const identity = parentSystemPrompt || genericBase; const identity = parentSystemPrompt || genericBase;
const bridge = `<sub_agent_context> const bridge = `<sub_agent_context>
@@ -88,10 +114,6 @@ You are operating as a sub-agent invoked to handle a specific task.
- Be concise but complete - Be concise but complete
</sub_agent_context>`; </sub_agent_context>`;
const customSection = config.systemPrompt?.trim()
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
: "";
// Place shared/stable content first so the LLM's KV cache can reuse the // Place shared/stable content first so the LLM's KV cache can reuse the
// inherited prefix across all subagent invocations. The parent prompt is // inherited prefix across all subagent invocations. The parent prompt is
// placed verbatim (no wrapper tag) so it forms an identical byte prefix // placed verbatim (no wrapper tag) so it forms an identical byte prefix
@@ -101,6 +123,13 @@ You are operating as a sub-agent invoked to handle a specific task.
} }
// "replace" mode — env header + the config's full system prompt // "replace" mode — env header + the config's full system prompt
if (bundleBase) {
// Host-owned base: drops the "You are a pi coding agent sub-agent" header
// (it duplicates the base's own role statement); env/worktree stay.
const configSection = config.systemPrompt?.trim() ? `\n\n${config.systemPrompt}` : "";
return activeAgentTag + bundleBase + "\n\n" + envBlock + worktreeBlock + configSection + extrasSuffix;
}
const replaceHeader = `You are a pi coding agent sub-agent. const replaceHeader = `You are a pi coding agent sub-agent.
You have been invoked to handle a specific task autonomously. You have been invoked to handle a specific task autonomously.
+7 -7
View File
@@ -124,15 +124,15 @@ describe("disableInContent", () => {
expect(loaderSeesDisabled(content)).toBe(true); expect(loaderSeesDisabled(content)).toBe(true);
}); });
it("reports no-frontmatter for a BOM-prefixed file, matching what the loader sees", () => { it("disables a BOM-prefixed file, matching what the loader sees", () => {
// The parser doesn't accept a BOM either — it reports an empty frontmatter // Pi ≥1.3's parser skips a leading BOM, so the block behind it is real
// and treats the whole file as body. So refusing here is agreement with the // frontmatter — refusing here would leave a file the loader reads enabled
// read side, not a gap: inserting the key would have no effect on loading. // while reporting "nothing to do". The edit keeps the BOM in place.
const src = "---\ndescription: Scout\n---\n\nBody.\n"; const src = "---\ndescription: Scout\n---\n\nBody.\n";
expect(parseFrontmatter<Record<string, unknown>>(src).frontmatter).toEqual({});
const { content, outcome } = disableInContent(src); const { content, outcome } = disableInContent(src);
expect(outcome).toBe("no-frontmatter"); expect(outcome).toBe("disabled");
expect(content).toBe(src); expect(content.startsWith("")).toBe(true);
expect(loaderSeesDisabled(content)).toBe(true);
}); });
}); });
+142
View File
@@ -1763,6 +1763,148 @@ describe("mandatory extension policy", () => {
}); });
}); });
describe("excluded extension paths", () => {
function excludedEntry(name = "pi-ask-user"): { dir: string; path: string } {
const dir = mkdtempSync(join(tmpdir(), "subagents-excluded-runner-"));
const extDir = join(dir, name);
mkdirSync(extDir, { recursive: true });
const path = join(extDir, "index.ts");
writeFileSync(path, "export default () => {};\n");
return { dir, path };
}
function setup(extensions: true | string[] | false, excludeExtensions?: string[]) {
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions, excludeExtensions }));
vi.mocked(getAgentConfig).mockReturnValueOnce(
makeAgentConfig({ extensions, excludeExtensions }),
);
vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7);
}
it("removes the extension under extensions:true, tools and all", async () => {
const fixture = excludedEntry();
try {
setup(true);
withExtensions({
[fixture.path]: ["ask_user_question"],
"/ext/other.ts": ["other_tool"],
});
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
const onToolActivity = vi.fn();
await runAgent(ctx, "Explore", "go", {
pi,
excludedExtensionPaths: [fixture.path],
onToolActivity,
});
// Exclusion forces the override on even though extensions:true + no
// frontmatter excludes would otherwise skip it.
expect(lastLoaderOpts().extensionsOverride).toBeDefined();
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
.not.toContain(fixture.path);
const tools = lastToolsPassed();
expect(tools).not.toContain("ask_user_question");
expect(tools).toContain("other_tool");
expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({
toolName: expect.stringContaining("excluded from subagent sessions"),
}));
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
it("removes the extension even when an `extensions:` name entry selects it", async () => {
const fixture = excludedEntry();
try {
setup(["pi-ask-user"]);
withExtensions({ [fixture.path]: ["ask_user_question"] });
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
const onToolActivity = vi.fn();
await runAgent(ctx, "Explore", "go", {
pi,
excludedExtensionPaths: [fixture.path],
onToolActivity,
});
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
.not.toContain(fixture.path);
expect(lastToolsPassed()).not.toContain("ask_user_question");
expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({
toolName: expect.stringContaining("excluded from subagent sessions"),
}));
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
it("removes the extension even when an `extensions:` path entry requests it", async () => {
const fixture = excludedEntry();
try {
setup([fixture.path]);
withExtensions({ [fixture.path]: ["ask_user_question"] });
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
await runAgent(ctx, "Explore", "go", {
pi,
excludedExtensionPaths: [fixture.path],
});
expect(lastLoaderOpts().additionalExtensionPaths).toBeUndefined();
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
.not.toContain(fixture.path);
expect(lastToolsPassed()).not.toContain("ask_user_question");
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
it("is a no-op when empty — no override installed for wildcard agents", async () => {
setup(["*"]);
withExtensions({ "/ext/a.ts": ["tool_a"] });
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
await runAgent(ctx, "Explore", "go", { pi, excludedExtensionPaths: [] });
expect(lastLoaderOpts().extensionsOverride).toBeUndefined();
expect(lastToolsPassed()).toContain("tool_a");
});
it("keeps mandatory entries while removing excluded ones", async () => {
const mandatoryDir = mkdtempSync(join(tmpdir(), "subagents-mandatory-runner-"));
const mandatoryPath = join(mandatoryDir, "permission-system.ts");
writeFileSync(mandatoryPath, "export default () => {};\n");
const fixture = excludedEntry();
try {
setup(true);
withExtensions({
[mandatoryPath]: ["permission_admin"],
[fixture.path]: ["ask_user_question"],
});
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
await runAgent(ctx, "Explore", "go", {
pi,
mandatoryExtensionPaths: [mandatoryPath],
excludedExtensionPaths: [fixture.path],
});
const loaded = loaderExtensionsRef.current.extensions.map((e) => e.path);
expect(loaded).toContain(mandatoryPath);
expect(loaded).not.toContain(fixture.path);
} finally {
rmSync(mandatoryDir, { recursive: true, force: true });
rmSync(fixture.dir, { recursive: true, force: true });
}
});
});
describe("agent-runner extension allowlist", () => { describe("agent-runner extension allowlist", () => {
function setupArrayAgent(extensions: string[]) { function setupArrayAgent(extensions: string[]) {
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions })); vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions }));
+147
View File
@@ -0,0 +1,147 @@
/**
* bundled-agents.test.ts — `bundledAgentsDir` extension option: bundle-shipped
* agent .md files register as embedded defaults (isDefault, no source/path),
* sit between embedded defaults and user agents in precedence, and stay out of
* the registry when the option is unset.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildAgentRegistry, getAgentConfig, getDefaultAgentNames, registerAgents } from "../src/agent-types.js";
import { loadBundledAgents } from "../src/custom-agents.js";
import { createPiSubagentsExtension } from "../src/index.js";
let tmpDir: string;
let bundledDir: string;
let originalHome: string | undefined;
let originalAgentDir: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-bundled-agents-"));
bundledDir = join(tmpDir, "bundled");
mkdirSync(bundledDir, { recursive: true });
originalHome = process.env.HOME;
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.HOME = join(tmpDir, "home");
delete process.env.PI_CODING_AGENT_DIR;
});
afterEach(() => {
if (originalHome == null) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
rmSync(tmpDir, { recursive: true, force: true });
registerAgents(new Map());
});
function writeBundled(name: string, content: string) {
writeFileSync(join(bundledDir, `${name}.md`), content);
}
describe("loadBundledAgents", () => {
it("parses agent files with the custom-agent parser and stamps them as built-ins", () => {
writeBundled("scout", `---
name: scout
description: Bundle scout
prompt_mode: append
tools: read, grep
---
Bundle prompt body.`);
const agents = loadBundledAgents(bundledDir);
const scout = agents.get("scout");
expect(scout).toBeDefined();
expect(scout?.description).toBe("Bundle scout");
expect(scout?.systemPrompt).toBe("Bundle prompt body.");
expect(scout?.promptMode).toBe("append");
expect(scout?.builtinToolNames).toEqual(["read", "grep"]);
expect(scout?.isDefault).toBe(true);
expect(scout?.source).toBeUndefined();
expect(scout?.sourcePath).toBeUndefined();
});
it("returns an empty map for a missing or empty directory", () => {
expect(loadBundledAgents(join(tmpDir, "missing")).size).toBe(0);
expect(loadBundledAgents(bundledDir).size).toBe(0);
});
it("skips non-.md files and reserved ':' names", () => {
writeBundled("fine", "---\ndescription: ok\n---\nbody");
writeFileSync(join(bundledDir, "notes.txt"), "not an agent");
writeBundled("bad", "---\nname: 'plugin:scout'\ndescription: bad\n---\nbody");
const agents = loadBundledAgents(bundledDir);
expect([...agents.keys()]).toEqual(["fine"]);
});
});
describe("bundled agent precedence", () => {
it("replaces a same-name embedded default and reports as a default", () => {
writeBundled("Explore", `---
name: Explore
description: Bundled Explore override
prompt_mode: replace
---
Bundled Explore body.`);
const bundled = loadBundledAgents(bundledDir);
const registry = buildAgentRegistry(new Map(), bundled);
expect(registry.get("Explore")?.systemPrompt).toBe("Bundled Explore body.");
expect(registry.get("Explore")?.isDefault).toBe(true);
// Unrelated embedded defaults are untouched.
expect(registry.get("general-purpose")?.systemPrompt).not.toBe("Bundled Explore body.");
});
it("new bundled names join the default set", () => {
writeBundled("bundle-only", "---\ndescription: only in bundle\n---\nbody");
const registry = buildAgentRegistry(new Map(), loadBundledAgents(bundledDir));
expect(registry.get("bundle-only")?.isDefault).toBe(true);
registerAgents(new Map(), loadBundledAgents(bundledDir));
expect(getDefaultAgentNames()).toContain("bundle-only");
expect(getAgentConfig("bundle-only")?.description).toBe("only in bundle");
});
it("user agents still override bundled definitions", () => {
writeBundled("scout", "---\ndescription: bundled\n---\nbundled body");
const user = new Map([
["scout", {
name: "scout",
description: "project override",
systemPrompt: "project body",
promptMode: "replace" as const,
extensions: true as const,
skills: true as const,
source: "project" as const,
sourcePath: "/proj/.pi/agents/scout.md",
}],
]);
const registry = buildAgentRegistry(user, loadBundledAgents(bundledDir));
expect(registry.get("scout")?.description).toBe("project override");
expect(registry.get("scout")?.isDefault).not.toBe(true);
});
});
describe("extension option validation", () => {
it("rejects a path that is both mandatory and excluded", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-subagents-overlap-"));
const path = join(dir, "permission-system.ts");
writeFileSync(path, "export default () => {};\n");
expect(() =>
createPiSubagentsExtension({
mandatoryExtensionPaths: [path],
excludedExtensionPaths: [path],
}),
).toThrow("both mandatory and excluded");
rmSync(dir, { recursive: true, force: true });
});
it("canonicalizes bundledAgentsDir and rejects missing directories", () => {
expect(() =>
createPiSubagentsExtension({ bundledAgentsDir: join(tmpDir, "missing") }),
).toThrow("is unavailable");
expect(() => createPiSubagentsExtension({ bundledAgentsDir: bundledDir })).not.toThrow();
});
});
@@ -112,20 +112,19 @@ describe("subagent usage reaches the parent session's stats (real pi)", () => {
} }
}); });
it("leaves the context-window percentage alone", async () => { it("counts only the result's own text toward context, not its reported usage", async () => {
// pi derives context usage from assistant messages only. If that ever // Since Pi 1.3 the percentage reflects the transcript's real contents, so
// changed, a delegating session would look like it was filling its context // the message's own few tokens do register. What must not register is the
// with work that happened somewhere else entirely — and users would compact // reported usage: 150k delegated tokens on a 200k window would read as
// for no reason. // ~75% occupied, and a delegating session would look like it was filling
// its context with work that happened somewhere else entirely.
const session = await realSession(); const session = await realSession();
try { try {
const before = session.getSessionStats().contextUsage?.percent ?? null;
const pool = new PendingUsagePool(); const pool = new PendingUsagePool();
pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 }); pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 });
session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any); session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any);
expect(session.getSessionStats().contextUsage?.percent ?? null).toBe(before); expect(session.getSessionStats().contextUsage?.percent ?? 0).toBeLessThan(1);
} finally { } finally {
session.dispose?.(); session.dispose?.();
} }
+41 -1
View File
@@ -195,6 +195,46 @@ function resolveReply(
return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply; return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply;
} }
/**
* Names of the tools the session offered the model for this call. Pi ≥1.3
* normalizes `Context.tools` into the transcript's system messages
* (`toolsAdded`/`toolsRemoved`) before the provider sees the request, so read
* both shapes.
*/
export function contextToolNames(context: Context): string[] {
const names = new Set<string>((context.tools ?? []).map((t) => t.name));
for (const m of context.messages) {
if (m.role !== "system") continue;
for (const t of m.toolsAdded ?? []) names.add(t.name);
for (const t of m.toolsRemoved ?? []) names.delete(t.name);
}
return [...names];
}
/**
* The system-prompt text the session offered for this call. Pi ≥1.3 normalizes
* `Context.systemPrompt` into the transcript's system messages, so read both
* shapes: `context.systemPrompt` plus every system message's `content` and
* named `sections` values.
*/
export function contextSystemPrompt(context: Context): string {
const parts: string[] = [];
if (context.systemPrompt) parts.push(context.systemPrompt);
for (const m of context.messages) {
if (m.role !== "system") continue;
const content = m.content;
if (typeof content === "string") {
parts.push(content);
} else if (Array.isArray(content)) {
for (const b of content) if (b.type === "text" && b.text) parts.push(b.text);
}
for (const v of Object.values(m.sections ?? {})) {
if (typeof v === "string") parts.push(v);
}
}
return parts.join("\n");
}
/** /**
* The common single-spawn flow as a responder. Routes by inspecting the calling * The common single-spawn flow as a responder. Routes by inspecting the calling
* session's own context: * session's own context:
@@ -210,7 +250,7 @@ export function routeBySession(routes: {
subagent: FauxReply | ((ctx: Context) => FauxReply); subagent: FauxReply | ((ctx: Context) => FauxReply);
}): FauxResponder { }): FauxResponder {
return (context) => { return (context) => {
const isParent = (context.tools ?? []).some((t) => t.name === "Agent"); const isParent = contextToolNames(context).includes("Agent");
if (!isParent) return resolveReply(routes.subagent, context); if (!isParent) return resolveReply(routes.subagent, context);
const spawned = context.messages.some( const spawned = context.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
+19 -1
View File
@@ -17,10 +17,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted: vi.mock's factory is lifted above the imports, so it cannot close // Hoisted: vi.mock's factory is lifted above the imports, so it cannot close
// over ordinary top-level consts. // over ordinary top-level consts.
const { buildSessionContext, createAgentSession, inMemory } = vi.hoisted(() => ({ const { buildSessionContext, createAgentSession, inMemory, loaderOptions } = vi.hoisted(() => ({
buildSessionContext: vi.fn(), buildSessionContext: vi.fn(),
createAgentSession: vi.fn(), createAgentSession: vi.fn(),
inMemory: vi.fn(), inMemory: vi.fn(),
// The clone's prompt reaches the session through the loader's
// systemPromptOverride (Pi 1.3 made AgentState.systemPrompt read-only), so
// capture what runMentionClone hands DefaultResourceLoader.
loaderOptions: { current: undefined as Record<string, unknown> | undefined },
})); }));
vi.mock("@earendil-works/pi-coding-agent", async () => { vi.mock("@earendil-works/pi-coding-agent", async () => {
@@ -29,6 +33,12 @@ vi.mock("@earendil-works/pi-coding-agent", async () => {
...actual, ...actual,
buildSessionContext, buildSessionContext,
createAgentSession, createAgentSession,
DefaultResourceLoader: class extends actual.DefaultResourceLoader {
constructor(options?: Record<string, unknown>) {
loaderOptions.current = options;
super(options);
}
},
SessionManager: { ...actual.SessionManager, inMemory }, SessionManager: { ...actual.SessionManager, inMemory },
}; };
}); });
@@ -44,6 +54,7 @@ const CONVERSATION = [
beforeEach(() => { beforeEach(() => {
createAgentSession.mockReset(); createAgentSession.mockReset();
loaderOptions.current = undefined;
inMemory.mockReset(); inMemory.mockReset();
inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any); inMemory.mockReturnValue({ kind: "in-memory-session-manager" } as any);
buildSessionContext.mockReset(); buildSessionContext.mockReset();
@@ -104,6 +115,13 @@ function cloneSession(turn?: (tool: any) => Promise<void> | void) {
dispose: vi.fn(), dispose: vi.fn(),
} as any; } as any;
createAgentSession.mockImplementation(async (opts: any) => { createAgentSession.mockImplementation(async (opts: any) => {
// What the real wiring does: the loader's systemPromptOverride rewrites the
// rebuilt base before the session's state gets it.
const override = loaderOptions.current?.systemPromptOverride as
| ((base: string | undefined) => string | undefined)
| undefined;
const overridden = override?.(session.agent.state.systemPrompt);
if (overridden !== undefined) session.agent.state.systemPrompt = overridden;
const tools = visibleTools(opts); const tools = visibleTools(opts);
session.prompt.mockImplementation(async () => { session.prompt.mockImplementation(async () => {
// No tool, no tool call: the model can only answer in prose. // No tool, no tool call: the model can only answer in prose.
@@ -26,6 +26,7 @@ import { loadCustomAgents } from "../src/custom-agents.js";
import { encodeCwd } from "../src/output-file.js"; import { encodeCwd } from "../src/output-file.js";
import { import {
agentCall, agentCall,
contextToolNames,
type FauxReply, type FauxReply,
type PrintModeRun, type PrintModeRun,
runPrintMode, runPrintMode,
@@ -101,7 +102,7 @@ describe("nested delegation e2e (real pi-mono, faux model)", () => {
const respond = (context: Context): FauxReply => { const respond = (context: Context): FauxReply => {
const text = firstUserText(context); const text = firstUserText(context);
const names = (context.tools ?? []).map((t) => t.name); const names = contextToolNames(context);
// Leaf: no nested tools (it never opted in) — just answer. // Leaf: no nested tools (it never opted in) — just answer.
if (text.includes("Do the leaf work")) { if (text.includes("Do the leaf work")) {
+112
View File
@@ -433,4 +433,116 @@ describe("buildAgentPrompt", () => {
expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("<sub_agent_context>")); expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("<sub_agent_context>"));
}); });
}); });
describe("host-owned base prompt", () => {
const BASE = "You are a subagent operating inside pi. The human user does not see your messages.";
function baseConfig(promptMode: "append" | "replace", systemPrompt = "Custom instructions."): AgentConfig {
return {
name: "bundled-agent",
description: "Bundled",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt,
promptMode,
inheritContext: false,
runInBackground: false,
isolated: false,
};
}
it("append mode: replaces parent prompt and drops the bridge, keeps env/instructions/tag", () => {
const prompt = buildAgentPrompt(
baseConfig("append"),
"/workspace",
env,
"SECRET parent prompt content",
undefined,
BASE,
);
// No inherited parent prefix to keep cacheable, so the tag leads the
// prompt — same position as replace mode — and lets early session hooks
// identify the child before before_agent_start.
expect(prompt.startsWith('<active_agent name="bundled-agent"/>')).toBe(true);
expect(prompt).not.toContain("SECRET parent prompt content");
expect(prompt).not.toContain("<sub_agent_context>");
expect(prompt).not.toContain("You are a pi coding agent sub-agent");
expect(prompt).toContain("Working directory: /workspace");
expect(prompt).toContain("<agent_instructions>");
expect(prompt).toContain("Custom instructions.");
expect(prompt.match(/<active_agent name="bundled-agent"\/>/g)).toHaveLength(1);
const order = [
'<active_agent name="bundled-agent"/>',
BASE,
"# Environment",
"<agent_instructions>",
].map((s) => prompt.indexOf(s));
expect(order.every((i) => i >= 0)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
});
it("replace mode: drops the sub-agent header, keeps env + config prompt + tag", () => {
const prompt = buildAgentPrompt(
baseConfig("replace", "You are a read-only search agent."),
"/workspace",
env,
"SECRET parent prompt content",
undefined,
BASE,
);
expect(prompt).not.toContain("You are a pi coding agent sub-agent");
expect(prompt).not.toContain("SECRET parent prompt content");
expect(prompt).not.toContain("<sub_agent_context>");
expect(prompt).toContain("Working directory: /workspace");
expect(prompt).toContain("You are a read-only search agent.");
expect(prompt.match(/<active_agent name="bundled-agent"\/>/g)).toHaveLength(1);
// The tag leads, then base, then env, then the config's system prompt.
const order = [
'<active_agent name="bundled-agent"/>',
BASE,
"# Environment",
"You are a read-only search agent.",
].map((s) => prompt.indexOf(s));
expect(order.every((i) => i >= 0)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
});
it("keeps worktree and extras blocks", () => {
const prompt = buildAgentPrompt(
baseConfig("append"),
"/wt/copy",
env,
undefined,
{ worktreeBase: "/repo", memoryBlock: "# Agent Memory\nRemember this." },
BASE,
);
expect(prompt).toContain("isolated git worktree copy of /repo");
expect(prompt).toContain("# Agent Memory");
expect(prompt).not.toContain("<sub_agent_context>");
});
it("empty agent systemPrompt yields no agent_instructions in append mode", () => {
const prompt = buildAgentPrompt(
baseConfig("append", ""),
"/workspace",
env,
"Parent.",
undefined,
BASE,
);
expect(prompt).not.toContain("<agent_instructions>");
});
it("blank base keeps upstream composition byte-for-byte", () => {
for (const promptMode of ["append", "replace"] as const) {
const config = baseConfig(promptMode);
for (const blank of [undefined, "", " \n "]) {
expect(buildAgentPrompt(config, "/workspace", env, "Parent.", undefined, blank)).toBe(
buildAgentPrompt(config, "/workspace", env, "Parent."),
);
}
}
});
});
}); });
@@ -22,6 +22,7 @@ import { registerAgents } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js"; import { loadCustomAgents } from "../src/custom-agents.js";
import { import {
agentCall, agentCall,
contextToolNames,
type FauxResponder, type FauxResponder,
type PrintModeRun, type PrintModeRun,
runPrintMode, runPrintMode,
@@ -47,7 +48,7 @@ function userPrompt(ctx: Context): string {
} }
function tools(ctx: Context): string[] { function tools(ctx: Context): string[] {
return (ctx.tools ?? []).map((tool) => tool.name); return contextToolNames(ctx);
} }
function toolResults(ctx: Context, name: string): string[] { function toolResults(ctx: Context, name: string): string[] {
@@ -21,6 +21,8 @@ import {
agentCall, agentCall,
agentToolCalls, agentToolCalls,
agentToolResults, agentToolResults,
contextSystemPrompt,
contextToolNames,
conversationText, conversationText,
invokedToolNames, invokedToolNames,
type PrintModeRun, type PrintModeRun,
@@ -92,7 +94,7 @@ describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)",
// finishes → the child's own model turn actually runs (≥3 calls). // finishes → the child's own model turn actually runs (≥3 calls).
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const respond = async (ctx: Context) => { const respond = async (ctx: Context) => {
const isParent = (ctx.tools ?? []).some((t) => t.name === "Agent"); const isParent = contextToolNames(ctx).includes("Agent");
if (!isParent) { if (!isParent) {
await sleep(80); // child takes long enough that a non-held parent exits first await sleep(80); // child takes long enough that a non-held parent exits first
return "CHILD_BG_RAN"; return "CHILD_BG_RAN";
@@ -151,7 +153,7 @@ describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)",
parentFinal: "Reported.", parentFinal: "Reported.",
// The child reflects whether the frontmatter body reached its own prompt. // The child reflects whether the frontmatter body reached its own prompt.
subagent: (ctx: Context) => subagent: (ctx: Context) =>
`child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`, `child saw: ${contextSystemPrompt(ctx).includes(MARKER) ? MARKER : "MISSING"}`,
}), }),
}); });
@@ -185,7 +187,7 @@ describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)",
}), }),
parentFinal: "Reported.", parentFinal: "Reported.",
subagent: (ctx: Context) => subagent: (ctx: Context) =>
`child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`, `child saw: ${contextSystemPrompt(ctx).includes(MARKER) ? MARKER : "MISSING"}`,
}), }),
}); });
+16
View File
@@ -0,0 +1,16 @@
# my-pi 维护约定(pi-tool-search)
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。
- 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决。
- 新增、删除或重命名组合包暴露工具时必须同步维护 `BUNDLE_GROUP_DEFINITIONS`;日常只读工具、provider 工具和高风险管理工具不得因名称相似而混入同一大组,根验证应确认标准组合包没有落入 unknown-tools 路径。
- 生成目录只能让模型提供摘要、使用边界、关键词与分组;工具名、参数和原始 schema 必须来自宿主。校验或模型调用失败必须 fail-safe 到确定性目录,缓存不得写入仓库或放宽权限。
- 修改 Tool Card 提示词、目录校验、分组、缓存、active-tool/LRU 生命周期、配置或工具结果时,必须同步更新包内 README、当前动态加载文档、CHANGELOG 和测试。
## 验证
- 在本目录内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
+8
View File
@@ -0,0 +1,8 @@
# my-pi 维护约定(pi-web-search)
- 完全自维护的统一在线搜索扩展(无上游源码导入;Keenable/Exa/Tavily client 为按上游语义复刻的纯 `fetch` 直连实现)。对模型只暴露 `web_search` / `web_fetch`,schema 与提示词不含厂商名,不得重新引入按厂商命名的工具。
- `web_search` 必须经 `router.ts` 由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`)按内置路由规则在 Tavily / Exa / Keenable 间选择,经统一参数翻译调用;`details` 记录实际 provider 与路由方式,决策按会话内 LRU 缓存;路由失败或被选厂商报错按可用性顺序降级。确定性逻辑只处理可用性与失败兜底,不得改写成启发式断路。路由规则:广泛发现、新闻和候选来源偏 Tavily,官方/技术/论文和高确定性来源偏 Exa,中文、站点限定和日期筛选偏 Keenable。
- `web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily;Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 `auth.json` 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
- `clients/` 为纯 `fetch` 直连,不使用 `pi-mcp-adapter` 或任何 MCP SDK:Tavily 走 `api.tavily.com`(必须有 key);Keenable 复刻上游 client,保留 keyless `/public` 端点与 SSRF 防护;Exa 直接对 `https://mcp.exa.ai/mcp` 发 JSON-RPC——`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 执行、`$VAR`/`${VAR}` 插值间接寻址,`$$`/`$!` 转义。credential `env` 优先于进程 env;显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。
- `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或参数方式写入凭据:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。
+7
View File
@@ -0,0 +1,7 @@
# my-pi 维护约定(pi-worktree)
- 完全自维护(无上游)的斜杠命令 git worktree 管理扩展,提供 `/wt`(别名 `/worktree`)的 `new` / `merge` / `switch` / `list` / `remove` / `prune` 子命令,实现「一键拆分」与「一键合并」;不注册模型可调用工具。
- git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
- 状态登记位于 `<agentDir>/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入),不放 `<agentDir>/my-pi/` 下;Pi 不能修改进程 cwd,会话切换通过 `SessionManager.forkFrom`(或为空会话写入最小 header)生成 cwd 指向目标目录的新会话文件再 `ctx.switchSession` 重建运行时,切换后的收尾只能在新 `ctx` 的 `withSession` 回调中执行。
- 合并流程固定为先确认、再 rebase 到来源分支、最后默认 `--ff-only` 合并(`--squash` 可选压缩),冲突时可选择中止或保留后用 `/wt merge --continue` / `--abort` 处理,来源改动用 stash 兜底,永远不会 push;登记文件写入走 `state.json.lock` 锁文件串行化;worktree 根目录与分支前缀由组合包 `config/pi-worktree.json` 的 `root` / `branchPrefix` 配置。
- 来源 checkout 的 `.pi/worktree.json`(`version: 1`)声明环境复刻:`copy` 只复制未跟踪文件、`link` 只软链 gitignore 目标、`env` 渲染为 `600` 权限环境文件、`postCreate` 仅在 `ctx.isProjectTrusted()` 时以 argv 形式 `execFile`(绝不走 shell),任一失败即停并可用 `/wt reopen` 增量重跑;未被 `.gitignore` 覆盖的软链/环境文件写入 `.git/info/exclude`(仓库各 worktree 共享),保证 `/wt merge` 不会提交它们。
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# update-pi-devdeps.sh — refresh every package-local @earendil-works/* dev
# dependency (pinned to "latest" in each manifest) and re-resolve the lockfiles
# to the newest published Pi release.
#
# Runs `npm update` in the bundle root and in every top-level directory that has
# its own package-lock.json and declares @earendil-works/* devDependencies.
# Package dirs without Pi devDependencies (locks without @earendil-works
# entries) are skipped, so the Pi host runtime never lands where it does not
# belong.
set -eu
cd "$(dirname "$0")/.."
ROOT=$(pwd)
# prints the @earendil-works/* devDependency names declared in $1/package.json
pi_devdeps_of() {
node -e '
const pkg = require(process.argv[1]);
const devDeps = pkg.devDependencies || {};
const names = Object.keys(devDeps).filter((n) => n.startsWith("@earendil-works/"));
process.stdout.write(names.join(" "));
' "$1/package.json"
}
update_dir() {
dir=$1
names=$(pi_devdeps_of "$dir")
if [ -z "$names" ]; then
echo "skip $dir (no @earendil-works/* devDependencies)"
return 0
fi
echo "==> npm update in $dir: $names"
# Word splitting is intended: $names is a space-separated package list.
# shellcheck disable=SC2086
(cd "$dir" && npm update $names)
}
# The root declares no devDependencies, but its lock records the linked
# packages' @earendil-works/* devDependencies, so it is always refreshed.
echo "==> npm update in $ROOT"
(cd "$ROOT" && npm update @earendil-works/pi-ai @earendil-works/pi-coding-agent @earendil-works/pi-tui)
for dir in "$ROOT"/*/; do
[ -f "$dir/package-lock.json" ] || continue
[ -f "$dir/package.json" ] || continue
update_dir "$dir"
done
+45 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import test from "node:test"; import test from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -115,3 +115,47 @@ test("disposable runtime state stays under the agent-dir my-pi tree", () => {
), ),
); );
}); });
test("every package-local @earendil-works/* devDependency is pinned to latest", async () => {
for (const entry of await readdir(repositoryRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = join(repositoryRoot, entry.name, "package.json");
let manifest: { devDependencies?: Record<string, string> };
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch {
continue; // not a package directory
}
for (const [name, range] of Object.entries(manifest.devDependencies ?? {})) {
if (!name.startsWith("@earendil-works/")) continue;
assert.equal(
range,
"latest",
`${entry.name} devDependency ${name} must track "latest" (refresh locks via scripts/update-pi-devdeps.sh)`,
);
}
}
});
test("subagent prompt profile: preamble non-blank, agent names match filename stems", async () => {
const subagentDir = join(bundleConfigDir, "prompt", "subagent");
const preamble = await readFile(join(subagentDir, "preamble.md"), "utf8");
assert.ok(
preamble.trim().length > 0,
"config/prompt/subagent/preamble.md must not be blank",
);
const agentsDir = join(subagentDir, "agents");
const agentFiles = (await readdir(agentsDir)).filter((name) => name.endsWith(".md"));
assert.ok(agentFiles.length > 0, "config/prompt/subagent/agents/ must ship agent files");
for (const file of agentFiles) {
const raw = await readFile(join(agentsDir, file), "utf8");
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(frontmatter, `${file} must open with a frontmatter block`);
const name = frontmatter[1].match(/^name:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m)?.[1];
assert.equal(
name,
file.slice(0, -".md".length),
`config/prompt/subagent/agents/${file} frontmatter name must match its filename stem`,
);
}
});
+62 -13
View File
@@ -10,19 +10,38 @@ import test from "node:test";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0; const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0;
test("subagents source is retained but disabled in the root bundle", async () => { test("enabled subagents wrapper loads with its bundle-owned mandatory permission entry", { skip: !hasPi }, async () => {
const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as { const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
dependencies: Record<string, string>; dependencies: Record<string, string>;
files: string[]; files: string[];
pi: { extensions: string[] }; pi: { extensions: string[] };
}; };
assert.ok(packageJson.files.includes("pi-subagents"), "the bundle must retain the maintained subagents source"); assert.ok(packageJson.files.includes("pi-subagents"));
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined); assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
assert.equal( assert.ok(
packageJson.pi.extensions.includes("./extensions/subagents.ts"), packageJson.pi.extensions.includes("./extensions/subagents.ts"),
false, "the bundle must default-load the locally maintained subagents wrapper",
"the bundle must not default-load the disabled subagents wrapper",
); );
const home = await mkdtemp(join(tmpdir(), "my-pi-subagents-wrapper-load-"));
const result = spawnSync(
"pi",
["--no-extensions", "-e", join(repositoryRoot, "extensions", "subagents.ts"), "--mode", "rpc"],
{
encoding: "utf8",
timeout: 60_000,
env: {
...process.env,
HOME: home,
XDG_CONFIG_HOME: join(home, ".config"),
PI_CODING_AGENT_DIR: join(home, ".pi-agent"),
PI_OFFLINE: "1",
},
},
);
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension/u);
}); });
test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => { test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => {
@@ -76,8 +95,8 @@ test("all package extensions load together without global registration conflicts
); );
assert.ok(packageJson.pi.extensions.includes("./pi-ssh/index.ts"), "the bundle must load the locally maintained SSH extension"); assert.ok(packageJson.pi.extensions.includes("./pi-ssh/index.ts"), "the bundle must load the locally maintained SSH extension");
assert.equal(packageJson.dependencies["pi-ssh"], "file:./pi-ssh"); assert.equal(packageJson.dependencies["pi-ssh"], "file:./pi-ssh");
assert.equal(packageJson.dependencies.ssh2, "1.17.0", "the packed bundle must install pi-ssh's runtime transport"); assert.match(packageJson.dependencies.ssh2 ?? "", /^\d+\.\d+\.\d+$/, "the packed bundle must install pi-ssh's runtime transport at an exact version");
assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi"); assert.match(packageJson.dependencies.jiti ?? "", /^\d+\.\d+\.\d+$/, "the packed helper must load local TypeScript outside Pi at an exact version");
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source"); assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
assert.ok(packageJson.files.includes("configure.sh"), "the packed bundle must include the search/SSH config helper"); assert.ok(packageJson.files.includes("configure.sh"), "the packed bundle must include the search/SSH config helper");
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user"); assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
@@ -117,6 +136,9 @@ test("all package extensions load together without global registration conflicts
assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate"); assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate");
assert.equal(permissionConfig.permission.ssh_cd, "ask", "remote workspace changes must enter the permission gate"); assert.equal(permissionConfig.permission.ssh_cd, "ask", "remote workspace changes must enter the permission gate");
assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview"); assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview");
assert.equal(permissionConfig.permission.Agent, "allow", "launching a subagent is side-effect free; child actions stay gated");
assert.equal(permissionConfig.permission.get_subagent_result, "allow");
assert.equal(permissionConfig.permission.steer_subagent, "allow");
assert.equal(hardGuardConfig.permission.path, undefined, "sensitive paths must not be hard-denied by default"); assert.equal(hardGuardConfig.permission.path, undefined, "sensitive paths must not be hard-denied by default");
assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny"); assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny");
assert.equal( assert.equal(
@@ -149,8 +171,18 @@ test("all package extensions load together without global registration conflicts
"the packed bundle must include the locally maintained notification source", "the packed bundle must include the locally maintained notification source",
); );
assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer"); assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined); assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
assert.equal(packageJson.pi.extensions.includes("./extensions/subagents.ts"), false); assert.ok(packageJson.pi.extensions.includes("./extensions/subagents.ts"));
assert.ok(
packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") <
packageJson.pi.extensions.indexOf("./extensions/subagents.ts"),
"the permission service must load before the subagents wrapper injects it as a mandatory child extension",
);
assert.ok(
packageJson.pi.extensions.indexOf("./extensions/subagents.ts") <
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
"the subagent orchestration tools must register before Tool Search builds its catalog",
);
assert.ok( assert.ok(
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"), packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
"the bundle must load the locally maintained Hermes memory extension", "the bundle must load the locally maintained Hermes memory extension",
@@ -210,11 +242,28 @@ test("all package extensions load together without global registration conflicts
); );
assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined); assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined);
assert.equal(packageJson.dependencies["better-sqlite3"], undefined); assert.equal(packageJson.dependencies["better-sqlite3"], undefined);
assert.equal(packageJson.allowScripts["better-sqlite3@12.11.1"], true); // allowScripts must approve exactly the root lockfile's better-sqlite3
// build — and nothing else. pi-hermes-memory's own lockfile carries a
// nested major that ships N-API prebuilds and needs no approval entry.
const rootLock = JSON.parse(await readFile(join(repositoryRoot, "package-lock.json"), "utf8")) as {
packages: Record<string, { version?: string }>;
};
const hermesLock = JSON.parse(await readFile(join(repositoryRoot, "pi-hermes-memory", "package-lock.json"), "utf8")) as {
packages: Record<string, { version?: string }>;
};
const rootSqliteVersion = rootLock.packages["node_modules/better-sqlite3"]?.version;
const hermesSqliteVersion = hermesLock.packages["node_modules/better-sqlite3"]?.version;
assert.match(rootSqliteVersion ?? "", /^\d+\.\d+\.\d+$/, "the root lockfile must pin a root-level better-sqlite3");
assert.match(hermesSqliteVersion ?? "", /^\d+\.\d+\.\d+$/, "the Hermes lockfile must pin its own better-sqlite3");
assert.deepEqual(
Object.keys(packageJson.allowScripts).filter((key) => key.startsWith("better-sqlite3@")),
[`better-sqlite3@${rootSqliteVersion}`],
"allowScripts must approve exactly the root lockfile's better-sqlite3 build",
);
assert.equal( assert.equal(
packageJson.allowScripts["better-sqlite3@13.0.3"], packageJson.allowScripts[`better-sqlite3@${hermesSqliteVersion}`],
undefined, undefined,
"Hermes uses better-sqlite3 13 N-API prebuilds, so no extra install-script approval is required", `Hermes uses better-sqlite3 ${hermesSqliteVersion} N-API prebuilds, so no extra install-script approval is required`,
); );
assert.equal(packageJson.dependencies["@sinclair/typebox"], undefined); assert.equal(packageJson.dependencies["@sinclair/typebox"], undefined);
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-")); const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
+162 -5
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
import { import {
applyToolCatalog, applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules, loadRoutingRules,
loadSectionsConfig, loadSectionsConfig,
loadToolCatalog, loadToolCatalog,
@@ -18,6 +19,8 @@ import {
resolvePreamblePath, resolvePreamblePath,
spliceCover, spliceCover,
stripJsoncComments, stripJsoncComments,
validateRoutingConfig,
validateSectionsConfig,
type MutablePromptOptions, type MutablePromptOptions,
type RoutingRule, type RoutingRule,
} from "../extensions/system-prompt-config.ts"; } from "../extensions/system-prompt-config.ts";
@@ -74,6 +77,40 @@ test("config/prompt files load with the expected shapes", () => {
"chrome_profile_bridge", "chrome_profile_bridge",
"tool_routing", "tool_routing",
]); ]);
assert.deepEqual(sections.subagentExclude, ["memory_policy", "chrome_profile_bridge"]);
});
test("routing scope validation: main/subagent accepted, other values rejected", () => {
assert.doesNotThrow(() =>
validateRoutingConfig({
version: 1,
rules: [
{ text: "both" },
{ scope: "main", text: "m" },
{ scope: "subagent", text: "s" },
],
}),
);
assert.throws(
() => validateRoutingConfig({ version: 1, rules: [{ scope: "everyone", text: "x" }] }),
/scope must be "main" or "subagent"/,
);
});
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
version: 1,
order: ["tool_routing"],
subagentExclude: [],
});
assert.throws(
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: "memory_policy" }),
/subagentExclude.*array of section names/,
);
assert.throws(
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: ["Bad Name"] }),
/not a valid section name/,
);
}); });
test("readPreamble reads the bundle preamble from config/prompt/", () => { test("readPreamble reads the bundle preamble from config/prompt/", () => {
@@ -185,6 +222,40 @@ test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
assert.equal(renderRouting([], ["a"]), ""); assert.equal(renderRouting([], ["a"]), "");
}); });
test("renderRouting filters scoped rules by audience, unscoped rules render for both", () => {
const rules: RoutingRule[] = [
{ text: "both audiences" },
{ scope: "main", text: "main only" },
{ scope: "subagent", text: "subagent only" },
];
assert.equal(renderRouting(rules, undefined, "main"), "- both audiences\n- main only");
assert.equal(renderRouting(rules, undefined, "subagent"), "- both audiences\n- subagent only");
// The omitted-audience default stays "main" for callers that predate scopes.
assert.equal(renderRouting(rules, undefined), "- both audiences\n- main only");
});
test("isSubagentCustomPrompt matches the <active_agent> contract only on customPrompt", () => {
assert.ok(isSubagentCustomPrompt('a\n<active_agent name="Explore"/>'));
assert.ok(isSubagentCustomPrompt("<active_agent name='x'/>"));
assert.ok(!isSubagentCustomPrompt("plain main-session prompt"));
assert.ok(!isSubagentCustomPrompt(undefined));
assert.ok(!isSubagentCustomPrompt(42));
});
test("bundled ssh_connect rules differ per audience", () => {
const routing = loadRoutingRules();
const tools = ["ssh_connect", "ssh_bash"];
const main = renderRouting(routing.rules, tools, "main");
assert.match(main, /the user explicitly names an imported host/);
assert.doesNotMatch(main, /the delegated task explicitly names/);
const subagent = renderRouting(routing.rules, tools, "subagent");
assert.match(subagent, /the delegated task explicitly names an imported host/);
assert.doesNotMatch(subagent, /the user explicitly names an imported host/);
});
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => { test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
const routing = loadRoutingRules(); const routing = loadRoutingRules();
@@ -275,12 +346,18 @@ test("spliceCover degenerates to the preamble when the prompt has no sections",
interface FakePromptOptions extends MutablePromptOptions { interface FakePromptOptions extends MutablePromptOptions {
forceSystemPrompt?: string; forceSystemPrompt?: string;
customPrompt?: string;
} }
/** Simplified re-render of Pi's prompt from the mutable options object. */ /** Simplified re-render of Pi's prompt from the mutable options object. */
function renderPiPrompt(options: FakePromptOptions): string { function renderPiPrompt(options: FakePromptOptions): string {
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt; if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
const parts = ["PI DEFAULT PREAMBLE"]; // Like the real host: a non-empty customPrompt owns the preamble section.
const preamble =
typeof options.customPrompt === "string" && options.customPrompt.length > 0
? options.customPrompt
: "PI DEFAULT PREAMBLE";
const parts = [preamble];
const snippets = Object.entries(options.toolSnippets ?? {}); const snippets = Object.entries(options.toolSnippets ?? {});
if (snippets.length > 0) { if (snippets.length > 0) {
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`); parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
@@ -378,6 +455,86 @@ test("composer degrades to text splicing when an earlier extension froze the pro
); );
}); });
test("composer keeps a customPrompt-owned preamble (subagent / SYSTEM.md) intact", () => {
const composer = createSystemPromptComposer();
const customPrompt = 'You are the Explore agent.\n<active_agent name="Explore"/>';
const options: FakePromptOptions = {
customPrompt,
selectedTools: ["read"],
toolSnippets: { read: "reads files" },
};
const { ctx } = makeUiCtx();
const event = {
systemPromptOptions: options,
systemPrompt: `${customPrompt}\n\n<tools>\n- read: reads files\n</tools>`,
};
const result = composer.handleBeforeAgentStart(event as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(customPrompt), "the agent's own preamble is kept verbatim");
assert.ok(
output.includes('<active_agent name="Explore"/>'),
"the <active_agent> identity the permission system reads survives",
);
assert.ok(
!output.includes(BUNDLE_PREAMBLE),
"the bundle preamble must not be spliced over a customPrompt-owned preamble",
);
assert.match(output, /<tool_routing>/, "routing still degrades to an appended section");
});
test("composer treats an <active_agent> customPrompt as a subagent session", () => {
const composer = createSystemPromptComposer();
const customPrompt =
'You are a subagent.\n<active_agent name="Explore"/>\n# Environment\nWorking directory: /work';
const options: FakePromptOptions = {
customPrompt,
selectedTools: ["read", "ssh_connect"],
toolSnippets: { read: "reads files", ssh_connect: "connects" },
sections: {
memory_policy: "memory guidance for main sessions",
chrome_profile_bridge: "chrome bridge preamble",
remote_ssh: "remote ssh preamble",
},
};
const { ctx } = makeUiCtx();
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(customPrompt), "the child preamble stays untouched");
assert.doesNotMatch(output, /<memory_policy>/, "subagentExclude removes memory_policy");
assert.doesNotMatch(output, /<chrome_profile_bridge>/, "subagentExclude removes chrome_profile_bridge");
assert.match(output, /<remote_ssh>/, "unexcluded sections are kept");
assert.match(output, /the delegated task explicitly names an imported host/, "subagent-scope routing renders");
assert.doesNotMatch(output, /the user explicitly names an imported host/, "main-scope routing stays out");
});
test("a main session keeps excluded sections and main-scope routing even with a literal <active_agent> tag", () => {
const composer = createSystemPromptComposer();
// The tag appears inside a contributed section (project docs can carry the
// literal text) but there is no customPrompt — this is a main session.
const options: FakePromptOptions = {
selectedTools: ["read", "ssh_connect"],
toolSnippets: { read: "reads files", ssh_connect: "connects" },
sections: {
memory_policy: 'docs mention <active_agent name="Guide"/>',
chrome_profile_bridge: "chrome bridge preamble",
},
};
const { ctx } = makeUiCtx();
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(BUNDLE_PREAMBLE), "the bundle preamble still splices for a main session");
assert.match(output, /<memory_policy>/, "main sessions are unaffected by subagentExclude");
assert.match(output, /<chrome_profile_bridge>/);
assert.match(output, /the user explicitly names an imported host/, "main-scope routing renders");
assert.doesNotMatch(output, /the delegated task explicitly names/, "subagent-scope routing stays out");
});
test("composer falls back to text appends when the host exposes no prompt options", () => { test("composer falls back to text appends when the host exposes no prompt options", () => {
const composer = createSystemPromptComposer(); const composer = createSystemPromptComposer();
const { ctx } = makeUiCtx(); const { ctx } = makeUiCtx();
@@ -551,10 +708,10 @@ test("context-mode upstream still appends the single anchor-bearing user message
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(join(repositoryRoot, "package.json"), "utf8"), await readFile(join(repositoryRoot, "package.json"), "utf8"),
) as { dependencies: Record<string, string> }; ) as { dependencies: Record<string, string> };
assert.equal( assert.match(
packageJson.dependencies["context-mode"], packageJson.dependencies["context-mode"] ?? "",
"1.0.169", /^\d+\.\d+\.\d+$/,
"the context-filter pin requires the exact upstream version", "the context-filter pin requires an exact upstream version",
); );
const upstream = await readFile( const upstream = await readFile(
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"), join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),