mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat: 移除 Codex Fast mode 与 AutoReview 后台分析,模型改为配置驱动
移除 pi-extension-codex-fast-mode:
- 删除整个扩展目录;同步清理根 package.json 的 files / pi.extensions /
dependencies 条目、package-lock.json、tests/extension-load.test.ts 断言
与文档章节。
- pi-minimal-footer 的 Fast on 渲染是该扩展的唯一消费者。扩展移除后 accent
分支不再有生产者,collectFooterStatusItems 简化为直接返回非空状态字符串,
去掉 FooterStatusItem 与 accent 标志,避免留下死代码。
移除 500 次裁决后的后台分析体系:
- 删除 analysis-agent.ts(192 行)与 ledger.ts(296 行),以及对应的
auto-review-runtime / auto-review-ledger 测试。
- runtime.ts 由 177 行收敛到 85 行:安装选项缩为 { service, config };删除
handleAgentSettled 与 agent_settled 注册;保留 session_start / turn_start
(denial circuit breaker 属安全语义)/ session_shutdown。
- 删除 analysis 配置键:config、extension-config、config-schema、生成的
permissions.schema.json 与种子配置。
- 删除 reviewer 的 onDecision 回调(唯一消费者是 ledger)。
- 删除迁移机制 scripts/migrate-permission-policy.mjs、其测试与 update.sh 中
46 行 migrate_permission_policy 接线。种子部署保持“目标缺失才写入”,已存在
的 policy.jsonc 永不被覆盖。
AutoReview 模型改为纯配置驱动:
- 删除 DEFAULT_PROVIDER / DEFAULT_MODEL 常量与 findCodexTemplate 合成逻辑;
model.ts 由 65 行收敛到 48 行。
- provider / model 任一缺失时返回新增失败类别 model-unconfigured 并 defer 到
人工审批;provider 未注册或模型未发布同样 defer。
- reasoning 不再有默认常量,完全由配置决定。
- session_start 在 auto-review 已列入 authorizerChain 且 provider/model 未配齐
时 ui.notify 提示一次,避免不使用 AutoReview 的用户被打扰。
- 种子 autoReview 只保留 enabled,provider/model/reasoning 由用户按本机实际
可用的模型填写。
破坏性变更:analysis 键已从 strictObject schema 删除,任何仍带该键的
policy.jsonc 会使整份配置被拒(fail-closed 到全 ask)。这是刻意的变更,已由
config-loader 测试固定,防止将来无声放宽。
测试:
- 新增 auto-review-model.test.ts(7 例),覆盖此前完全缺失的模型解析:真实
命中、未配置、provider 未知、模型未发布,以及不再合成替代模型。
- 重写 auto-review-runtime.test.ts(6 例):authorizer 注册、未配置时提示
一次、已配置 / disabled / 无 UI / 不在 chain 时静默。
- config-loader.test.ts 新增 2 例:带 analysis 的旧配置被拒且 fail-closed、
autoReview 逐字段合并。
- composition-root.test.ts 的 EXPECTED_HANDLERS 移除 agent_settled。
文档:AGENTS.md、README.md、pi-permission-system 的 README / CHANGELOG /
configuration.md / architecture.md / auto-review UPSTREAM.md。
验证:pi-permission-system 的 typecheck、2577 个测试与 build 全部通过;根 50
个测试通过;schema 重新生成无漂移;新种子通过 schema 校验;5 个脚本 sh -n
通过;git diff --check 无空白问题。未安装 shellcheck;未执行真实 pi install /
update(属外部写操作)。
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
- 上游来源:<https://github.com/pansapiens/pi-ssh>
|
- 上游来源:<https://github.com/pansapiens/pi-ssh>
|
||||||
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`(2026-06-23,`0.7.0`)。
|
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`(2026-06-23,`0.7.0`)。
|
||||||
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`。
|
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`。
|
||||||
- `pi-permission-system/`:统一提供工具、路径、MCP、Skill、子代理转发、独立 JSONC 硬防御、普通策略、内置 Codex Guardian 风格 AutoReview、人工 `defer` 兜底与每 500 次完成裁决后的同会话后台规则分析。
|
- `pi-permission-system/`:统一提供工具、路径、MCP、Skill、子代理转发、独立 JSONC 硬防御、普通策略、内置 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底。
|
||||||
- 权限系统上游来源:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>;初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。
|
- 权限系统上游来源:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>;初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。
|
||||||
- AutoReview 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>;初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。
|
- AutoReview 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>;初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。
|
||||||
- 原 AutoReview 源码已整合进 `pi-permission-system/src/auto-review/`,不再保留或加载第二个顶层扩展;其 MIT 许可与来源记录保留在 `pi-permission-system/LICENSES/` 和 `pi-permission-system/docs/auto-review/`。该目录从明确 tag/commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
- 原 AutoReview 源码已整合进 `pi-permission-system/src/auto-review/`,不再保留或加载第二个顶层扩展;其 MIT 许可与来源记录保留在 `pi-permission-system/LICENSES/` 和 `pi-permission-system/docs/auto-review/`。该目录从明确 tag/commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph 状态,并在全部成功后调用 Pi 官方热重载。
|
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph 状态,并在全部成功后调用 Pi 官方热重载。
|
||||||
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
||||||
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
||||||
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`;只在目标缺失时把 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 原子部署为 owner-only 全局用户配置,旧全局 `config.json` 仅复制迁移为 `policy.jsonc`。项目级 policy 不读取;`update.sh` 只可在当前文件精确匹配已知旧默认种子时备份并迁移到新种子,自定义或无效文件必须保留。
|
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`;只在目标缺失时把 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 原子部署为 owner-only 全局用户配置,旧全局 `config.json` 仅复制迁移为 `policy.jsonc`。项目级 policy 不读取;已存在的 `policy.jsonc` 永不被覆盖,其内容完全由用户维护。
|
||||||
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。
|
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。
|
||||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
|
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
|
||||||
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
|
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
- 上游来源:<https://github.com/chandra447/pi-hermes-memory>
|
- 上游来源:<https://github.com/chandra447/pi-hermes-memory>
|
||||||
- 初始导入快照:`df624056a2e8c68bc89ba0a81e1c120856ec9665`(`v0.9.10`)。
|
- 初始导入快照:`df624056a2e8c68bc89ba0a81e1c120856ec9665`(`v0.9.10`)。
|
||||||
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、上游 `.github` 工作流或构建产物。
|
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、上游 `.github` 工作流或构建产物。
|
||||||
- `pi-minimal-footer/`:基于上游紧凑 footer,保留上下文仪表、订阅用量条和扩展状态,并在 Codex Fast 模式开启时显示 `Fast on`。
|
- `pi-minimal-footer/`:基于上游紧凑 footer,保留上下文仪表、订阅用量条和扩展状态。
|
||||||
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
|
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
|
||||||
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
|
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
|
||||||
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
@@ -56,15 +56,11 @@
|
|||||||
- 上游来源:<https://github.com/smoosex/pi-notify>
|
- 上游来源:<https://github.com/smoosex/pi-notify>
|
||||||
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
|
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
|
||||||
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
- `pi-extension-codex-fast-mode/`:为符合条件的 Codex Responses 请求设置 priority service tier,并把新会话默认值跨会话持久化。
|
|
||||||
- 上游来源:<https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode>
|
|
||||||
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
|
||||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 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。
|
- `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/jjuraszek/pi-condense>,快照 `59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。
|
||||||
- 上游来源二:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-codex-compaction>,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。
|
- 上游来源二:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-codex-compaction>,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。
|
||||||
- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和 `settings.json.piCompaction` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。
|
- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和 `settings.json.piCompaction` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。
|
||||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@firstpick/pi-extension-codex-fast-mode`、`@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` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。
|
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@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` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。
|
||||||
- `install.sh`:先安装根组合包,再交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
- `install.sh`:先安装根组合包,再交互检查 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` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;仅当默认 Git 来源首次升级失败、Pi 的精确中断标记存在、canonical checkout 根目录和 `origin` 均通过身份校验时,才允许对该缓存执行最多 4 次连续 `git clean -fdx`,随后只重试一次 `pi update`,由 Pi 自己恢复依赖和清除标记;自定义来源、标记缺失或身份不匹配必须保持 fail-fast。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配旧版 `3` 组 / `20` 工具或上一版 `5` 组 / `28` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到当前默认值并写入当前 `bundleDefaultsVersion`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;仅当默认 Git 来源首次升级失败、Pi 的精确中断标记存在、canonical checkout 根目录和 `origin` 均通过身份校验时,才允许对该缓存执行最多 4 次连续 `git clean -fdx`,随后只重试一次 `pi update`,由 Pi 自己恢复依赖和清除标记;自定义来源、标记缺失或身份不匹配必须保持 fail-fast。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配旧版 `3` 组 / `20` 工具或上一版 `5` 组 / `28` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到当前默认值并写入当前 `bundleDefaultsVersion`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||||
- `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。
|
- `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。
|
||||||
@@ -96,16 +92,14 @@
|
|||||||
- 已闭合工具结果先同步生成保留工具名、参数、状态、首尾证据和 source key 的有界确定性 stub;大于 `semantic.minInputChars` 的结果再进入单 Session 有界后台语义 worker。后台结果只在安全事件边界提交,陈旧 hash 必须丢弃,Provider 请求不得等待后台摘要。
|
- 已闭合工具结果先同步生成保留工具名、参数、状态、首尾证据和 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 不得被阻断。
|
- 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 切换。配置只从 `settings.json.piCompaction` 读取且不写回,默认 soft/hard/target 比例为 72%/90%/55%。
|
- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。配置只从 `settings.json.piCompaction` 读取且不写回,默认 soft/hard/target 比例为 72%/90%/55%。
|
||||||
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
|
|
||||||
- Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
|
- 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 序列。
|
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
|
||||||
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、文件/系统/网络高风险操作与普通 MCP 调用按唯一全局 policy 判定。项目外路径不再产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
|
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、文件/系统/网络高风险操作与普通 MCP 调用按唯一全局 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` 归一化。
|
- 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` 的请求。
|
- `auto-review` 是 `pi-permission-system` 内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。
|
||||||
- reviewer 返回 `allow` 时自动批准(包括 `path`),返回 `deny` 时直接拒绝;配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。不得在 authorizer chain 外增加第二层内置授权范围限制。
|
- reviewer 返回 `allow` 时自动批准(包括 `path`),返回 `deny` 时直接拒绝;配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。不得在 authorizer chain 外增加第二层内置授权范围限制。
|
||||||
- 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别、未取消的结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
|
- 默认 reviewer 不设 provider/model 常量,也不合成替代模型:自动评审只使用用户在配置文件中显式命名的模型。`autoReview.provider` / `autoReview.model` 任一缺失即 `defer` 到人工审批,并在 `session_start` 提示一次(仅当 `auto-review` 已在 `authorizerChain` 中时);provider 未注册或模型未发布同样 `defer`。reasoning 也完全由配置决定,不设默认常量。90 秒总重试预算和内置 Codex Guardian 风格策略保持不变;只把 active branch 中的直接用户消息与已识别、未取消的结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
|
||||||
- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`。
|
- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,provider/model/reasoning 不设种子默认值,由用户按本机实际可用的模型自行填写;`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`。
|
||||||
- AutoReview 只对完成的 `allow` / `deny` 计数,`defer` 不计;达到 500 次后持久化 pending,并只在 `agent_settled` 拉起无工具、无扩展、无项目上下文的隔离后台 AgentSession。建议必须经过 schema 与确定性安全过滤、以 same-session follow-up 交给主 Agent,并在用户明确确认前禁止自动修改任一策略文件;shutdown 时保留 pending 供下一 Session 继续。
|
|
||||||
- `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。
|
- `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` 在安装流程中逐项询问后才安装或启用。
|
- 终端环境配置只属于便捷脚本: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,不自动改写用户模型配置。
|
- 原生远程压缩限定为 `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,不自动改写用户模型配置。
|
||||||
@@ -123,8 +117,8 @@
|
|||||||
|
|
||||||
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
|
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
|
||||||
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
|
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
|
||||||
- 扩展运行目录中的用户 `policy.jsonc` / `hard-guards.jsonc`、旧 `config.json`、审批 ledger/proposals、日志、构建产物、覆盖率目录和依赖目录属于本地状态,不应提交;`config/pi-permission-policy.jsonc`、`config/pi-permission-hard-guards.jsonc`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 分别是新安装权限种子、Hashline 默认行为和 LSP 后端的组合包权威源配置,必须提交并维护。
|
- 扩展运行目录中的用户 `policy.jsonc` / `hard-guards.jsonc`、旧 `config.json`、日志、构建产物、覆盖率目录和依赖目录属于本地状态,不应提交;`config/pi-permission-policy.jsonc`、`config/pi-permission-hard-guards.jsonc`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 分别是新安装权限种子、Hashline 默认行为和 LSP 后端的组合包权威源配置,必须提交并维护。
|
||||||
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护 Fast mode 等扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
|
- 外部 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` 无需执行,不得为此新增放行条目。
|
- 需要原生构建的依赖只按锁定版本加入根 `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 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
|
||||||
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
||||||
@@ -168,8 +162,7 @@
|
|||||||
- 内置 AutoReview 实现位于 `pi-permission-system/src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。
|
- 内置 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`。
|
- reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`。
|
||||||
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;用户文件和 bundled fallback 都不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
|
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;用户文件和 bundled fallback 都不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
|
||||||
- 500 次分析必须以完成 verdict sequence 为计数口径;`defer` 不写计数。ledger/proposal/state 保持 owner-only、原子状态写入、脱敏且不保存 rationale/transcript/raw payload。后台 Agent 必须 tool-free/context-free,Provider 请求不得等待它,只有 `agent_settled` 可启动;失败或 shutdown 不得误推进 checkpoint。
|
- 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。
|
||||||
- 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、ledger、后台分析、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-hermes-memory` 开发约定
|
## `pi-hermes-memory` 开发约定
|
||||||
@@ -183,16 +176,6 @@
|
|||||||
- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,配置位于 `~/.pi/agent/hermes-memory-config.json`;这些属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除。
|
- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,配置位于 `~/.pi/agent/hermes-memory-config.json`;这些属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除。
|
||||||
- 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。
|
- 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。
|
||||||
|
|
||||||
## `pi-extension-codex-fast-mode` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-extension-codex-fast-mode/index.ts`,测试保留在该目录的 `tests/`,来源记录是 `UPSTREAM.md`。
|
|
||||||
- 同步上游必须从明确 commit 移植并保留 `LICENSE`、作者和来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- 全局默认配置必须位于 Pi agent 目录的 `extensions/pi-extension-codex-fast-mode/config.json`,使用原子写入和 owner-only 权限;无效或不可读配置必须安全回退为关闭且不得静默覆盖。
|
|
||||||
- 新会话使用全局默认值,已有分支的最新有效 custom entry 必须继续优先;修改持久化、分支恢复、provider/API 资格或请求 payload 时必须同步 README、技术文档和测试。
|
|
||||||
- 根包必须通过 `file:./pi-extension-codex-fast-mode` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
|
|
||||||
- 修改该扩展后至少运行包内 `npm test`、`npm run check`、`npm run smoke` 与 `npm pack --dry-run --json`;加载或依赖入口变化还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-compaction` 开发约定
|
## `pi-compaction` 开发约定
|
||||||
|
|
||||||
- 扩展入口是 `pi-compaction/index.ts`,增量索引、投影、后台摘要、服务与 Codex native 实现在 `pi-compaction/src/`,测试位于 `pi-compaction/test/`,双上游来源记录是 `pi-compaction/UPSTREAM.md`。
|
- 扩展入口是 `pi-compaction/index.ts`,增量索引、投影、后台摘要、服务与 Codex native 实现在 `pi-compaction/src/`,测试位于 `pi-compaction/test/`,双上游来源记录是 `pi-compaction/UPSTREAM.md`。
|
||||||
@@ -281,7 +264,7 @@
|
|||||||
|
|
||||||
记忆扩展变化必须额外验证:`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-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、500 次 checkpoint、owner-only 持久化、shutdown resume 与 same-session follow-up。
|
在 `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-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,15 @@
|
|||||||
- `@keenable/pi-search@0.1.2`:提供紧凑的 `keenable_search` / `keenable_fetch`,并加载其搜索 skill;API key 可选。
|
- `@keenable/pi-search@0.1.2`:提供紧凑的 `keenable_search` / `keenable_fetch`,并加载其搜索 skill;API key 可选。
|
||||||
- `pi-mcp-adapter@2.26.0`:通过单个共享 adapter 实例接入机器现有的 CodeGraph 和 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令。
|
- `pi-mcp-adapter@2.26.0`:通过单个共享 adapter 实例接入机器现有的 CodeGraph 和 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令。
|
||||||
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
|
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
|
||||||
- 本仓库维护的 `pi-extension-codex-fast-mode`:基于 `@firstpick/pi-extension-codex-fast-mode@0.1.1`,为 Codex provider 提供跨会话持久化的 `/fast-mode`。
|
|
||||||
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
|
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
|
||||||
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
|
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
|
||||||
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer。
|
||||||
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。
|
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。
|
||||||
- 本仓库维护的 `pi-compaction`:以增量投影统一确定性工具结果压缩、后台语义摘要、effective token 计量、`context_tree_query`、portable checkpoint 与 Codex native compaction。
|
- 本仓库维护的 `pi-compaction`:以增量投影统一确定性工具结果压缩、后台语义摘要、effective token 计量、`context_tree_query`、portable checkpoint 与 Codex native compaction。
|
||||||
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。
|
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。
|
||||||
- 本仓库维护的 `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` 兜底和每 500 次完成裁决后的同会话后台规则分析;工具显隐仍只由 Tool Search 管理。
|
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
|
||||||
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
|
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
|
||||||
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||||
@@ -106,7 +105,7 @@ PI_PACKAGE_SOURCE="$PWD" ./install.sh
|
|||||||
PI_PACKAGE_SOURCE="git:git@bitbucket.org:siakitem/my-pi.git" ./update.sh
|
PI_PACKAGE_SOURCE="git:git@bitbucket.org:siakitem/my-pi.git" ./update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
根 `package.json` 对外部依赖使用精确版本,对本仓库维护的包(包括 Codex Fast mode)使用 `file:` 路径;`package-lock.json` 固定完整依赖树。不要再用项目级 `.pi/settings.json` 重复安装这些扩展,否则同一扩展可能被加载两次。
|
根 `package.json` 对外部依赖使用精确版本,对本仓库维护的包使用 `file:` 路径;`package-lock.json` 固定完整依赖树。不要再用项目级 `.pi/settings.json` 重复安装这些扩展,否则同一扩展可能被加载两次。
|
||||||
|
|
||||||
持久记忆由本仓库维护的 `pi-hermes-memory` 提供。它从官方 `chandra447/pi-hermes-memory`
|
持久记忆由本仓库维护的 `pi-hermes-memory` 提供。它从官方 `chandra447/pi-hermes-memory`
|
||||||
的 `v0.9.10` / `df624056a2e8c68bc89ba0a81e1c120856ec9665` 导入源码到 `pi-hermes-memory/` 并直接加载,
|
的 `v0.9.10` / `df624056a2e8c68bc89ba0a81e1c120856ec9665` 导入源码到 `pi-hermes-memory/` 并直接加载,
|
||||||
@@ -283,18 +282,20 @@ codegraph status
|
|||||||
|
|
||||||
`extensions/permission-system.ts` 只在目标缺失时,以 `0600` 原子创建两份用户可编辑 JSONC:
|
`extensions/permission-system.ts` 只在目标缺失时,以 `0600` 原子创建两份用户可编辑 JSONC:
|
||||||
|
|
||||||
- `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数、AutoReview 与 500 次分析参数。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。
|
- `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。
|
||||||
- `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。
|
- `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。
|
||||||
|
|
||||||
普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。
|
普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。
|
||||||
|
|
||||||
系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。
|
系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。
|
||||||
|
|
||||||
首次安装仍只部署缺失文件。`update.sh` 会额外检查当前全局 `policy.jsonc`:仅当其语义内容精确匹配已知旧默认种子时,先创建 `0600` 时间戳备份,再原子替换为升级后组合包的新种子;当前值、自定义值和无效 JSONC 都不会被猜测覆盖。旧全局 `config.json` 仍只在目标缺失时复制迁移为 `policy.jsonc`。
|
首次安装只部署缺失文件:`extensions/permission-system.ts` 在目标不存在时才把 `config/pi-permission-policy.jsonc` 与 `config/pi-permission-hard-guards.jsonc` 原子写入为 `0600`。已存在的文件永不被覆盖,因此对整个 `policy.jsonc` 的后续修改都由用户自行完成。旧全局 `config.json` 仍只在目标缺失时复制迁移为 `policy.jsonc`。
|
||||||
|
|
||||||
默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。
|
默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。
|
||||||
|
|
||||||
每个 AutoReview `allow` / `deny` 计为一次完成裁决,`defer` 不计数。记录仅保存有界、脱敏的 surface/pattern 指纹、风险与授权等级,不保存 rationale、原始工具参数或 transcript。累计 500 次新裁决后先持久化 pending;下一次 `agent_settled` 拉起无工具、无扩展、无项目上下文的隔离后台 AgentSession。经 schema 和确定性安全过滤后的建议通过 `followUp + triggerTurn` 回到当前对话,由主 Agent 直接向用户呈现。它不会自动修改任何策略文件;必须等用户明确选择并确认。会话在分析前结束时,pending 会在下一个 Session 继续。
|
自动评审模型完全由配置文件决定:`autoReview.provider` 与 `autoReview.model` 都不设内置默认值,扩展也不会合成替代模型。两者任一缺失时,若 `auto-review` 已在 `authorizerChain` 中,`session_start` 会提示一次,之后每个 `ask` 都 `defer` 到人工审批界面;provider 未注册或模型未发布同样 `defer`。`reasoning` 也由配置决定,不设默认常量。
|
||||||
|
|
||||||
|
每个 AutoReview `allow` / `deny` 记入权限 review 日志(`defer` 记为 `errorCategory`),日志只保存有界、脱敏的请求标识与风险/授权等级,不保存 rationale、原始工具参数或 transcript。扩展不汇总历史裁决,也不启动任何后台分析 Agent,更不会自动修改策略文件。
|
||||||
|
|
||||||
SSH 仍复用同一权限入口:`ssh_connect`、远端路径工具默认为 `ask`,`ssh_bash` 映射完整 Bash 策略并设置 `decisionFloor: "ask"`。硬拒绝不能被 reviewer 覆盖;普通 `defer` 才进入人工兜底。
|
SSH 仍复用同一权限入口:`ssh_connect`、远端路径工具默认为 `ask`,`ssh_bash` 映射完整 Bash 策略并设置 `decisionFloor: "ask"`。硬拒绝不能被 reviewer 覆盖;普通 `defer` 才进入人工兜底。
|
||||||
|
|
||||||
@@ -370,8 +371,3 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe
|
|||||||
原生远程压缩同时支持 `openai-codex` + `openai-codex-responses` 和 `sub-api` + `openai-responses`,使用触发时当前会话模型。SubAPI 保留现有模型配置,从 `baseUrl` 调用公共 `/responses` 并使用 `compaction_trigger` 流式协议;优先保留非空显式 `Authorization`,否则使用 API key,也支持仅凭非空 `cf-aig-authorization` 的代理认证。缺失或空白凭据在请求前拒绝。SubAPI 不要求客户端解析 ChatGPT account ID,也不调用独立 `/responses/compact`;Codex 的 JWT 要求保持不变。网关部署须支持 Sub2API 原生压缩 v2;请求失败沿用 Pi 回退,切换模型或 Provider 时使用 portable summary。
|
原生远程压缩同时支持 `openai-codex` + `openai-codex-responses` 和 `sub-api` + `openai-responses`,使用触发时当前会话模型。SubAPI 保留现有模型配置,从 `baseUrl` 调用公共 `/responses` 并使用 `compaction_trigger` 流式协议;优先保留非空显式 `Authorization`,否则使用 API key,也支持仅凭非空 `cf-aig-authorization` 的代理认证。缺失或空白凭据在请求前拒绝。SubAPI 不要求客户端解析 ChatGPT account ID,也不调用独立 `/responses/compact`;Codex 的 JWT 要求保持不变。网关部署须支持 Sub2API 原生压缩 v2;请求失败沿用 Pi 回退,切换模型或 Provider 时使用 portable summary。
|
||||||
|
|
||||||
本实现不读取或迁移旧 `contextPrune`、`context-prune-*` 或旧 Codex native checkpoint;切换后应创建新 Session。它与 Context Mode 职责互补:Context Mode 防止大型原始内容进入会话,Pi Compaction 管理已经进入会话的长期历史。
|
本实现不读取或迁移旧 `contextPrune`、`context-prune-*` 或旧 Codex native checkpoint;切换后应创建新 Session。它与 Context Mode 职责互补:Context Mode 防止大型原始内容进入会话,Pi Compaction 管理已经进入会话的长期历史。
|
||||||
|
|
||||||
### Codex fast mode
|
|
||||||
|
|
||||||
该扩展只在 `openai-codex` provider 的 Responses 请求上加入
|
|
||||||
`service_tier: "priority"`。使用 `/fast-mode on|off` 修改当前分支状态时,也会把全局默认值原子写入 Pi agent 目录下的 `extensions/pi-extension-codex-fast-mode/config.json`;新会话继承该默认值,恢复已有会话时仍由分支内最新记录优先。`/fast-mode status` 同时显示当前分支状态和新会话默认值。配置目录与文件分别使用 `0700` / `0600` 权限;配置缺失或无效时安全回退为关闭。其他 provider 不受影响。
|
|
||||||
|
|||||||
@@ -5,20 +5,11 @@
|
|||||||
"doublePressToConfirm": true,
|
"doublePressToConfirm": true,
|
||||||
|
|
||||||
// AutoReview is the default authority for deterministic review decisions.
|
// AutoReview is the default authority for deterministic review decisions.
|
||||||
|
// Automatic review stays inactive until you name both a provider and a model
|
||||||
|
// here; until then every ask goes to the human permission prompt.
|
||||||
"authorizerChain": ["auto-review"],
|
"authorizerChain": ["auto-review"],
|
||||||
"autoReview": {
|
"autoReview": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"provider": "openai-codex",
|
|
||||||
"model": "codex-auto-review",
|
|
||||||
"reasoning": "low",
|
|
||||||
"timeoutMs": 90000,
|
|
||||||
"includeBaselinePolicy": true
|
|
||||||
},
|
|
||||||
"analysis": {
|
|
||||||
"enabled": true,
|
|
||||||
"threshold": 500,
|
|
||||||
"retainAnalyzedBatches": 2,
|
|
||||||
"timeoutMs": 90000
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"shellTools": {
|
"shellTools": {
|
||||||
|
|||||||
Generated
-16
@@ -9,7 +9,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ff-labs/pi-fff": "0.10.5",
|
"@ff-labs/pi-fff": "0.10.5",
|
||||||
"@firstpick/pi-extension-codex-fast-mode": "file:./pi-extension-codex-fast-mode",
|
|
||||||
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
||||||
"@keenable/pi-search": "0.1.2",
|
"@keenable/pi-search": "0.1.2",
|
||||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||||
@@ -3249,10 +3248,6 @@
|
|||||||
"@sinclair/typebox": "*"
|
"@sinclair/typebox": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@firstpick/pi-extension-codex-fast-mode": {
|
|
||||||
"resolved": "pi-extension-codex-fast-mode",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@google/genai": {
|
"node_modules/@google/genai": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz",
|
||||||
@@ -8816,17 +8811,6 @@
|
|||||||
"typebox": "*"
|
"typebox": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pi-extension-codex-fast-mode": {
|
|
||||||
"name": "@firstpick/pi-extension-codex-fast-mode",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.19.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@earendil-works/pi-coding-agent": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pi-hermes-memory": {
|
"pi-hermes-memory": {
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"pi-ask-user",
|
"pi-ask-user",
|
||||||
"pi-compaction",
|
"pi-compaction",
|
||||||
"pi-chrome",
|
"pi-chrome",
|
||||||
"pi-extension-codex-fast-mode",
|
|
||||||
"pi-hermes-memory",
|
"pi-hermes-memory",
|
||||||
"pi-rtk-optimizer",
|
"pi-rtk-optimizer",
|
||||||
"pi-minimal-footer",
|
"pi-minimal-footer",
|
||||||
@@ -58,7 +57,6 @@
|
|||||||
"./extensions/hashline.ts",
|
"./extensions/hashline.ts",
|
||||||
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
||||||
"./extensions/lsp.ts",
|
"./extensions/lsp.ts",
|
||||||
"./pi-extension-codex-fast-mode/index.ts",
|
|
||||||
"./pi-hermes-memory/src/index.ts",
|
"./pi-hermes-memory/src/index.ts",
|
||||||
"./pi-compaction/index.ts",
|
"./pi-compaction/index.ts",
|
||||||
"./extensions/context-view.ts",
|
"./extensions/context-view.ts",
|
||||||
@@ -78,7 +76,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ff-labs/pi-fff": "0.10.5",
|
"@ff-labs/pi-fff": "0.10.5",
|
||||||
"@firstpick/pi-extension-codex-fast-mode": "file:./pi-extension-codex-fast-mode",
|
|
||||||
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
||||||
"@keenable/pi-search": "0.1.2",
|
"@keenable/pi-search": "0.1.2",
|
||||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
# Development guide: Codex Fast Mode for Pi
|
|
||||||
|
|
||||||
Contributor implementation and validation notes for the locally maintained fork.
|
|
||||||
|
|
||||||
[Back to README](README.md) · [Technical reference](TECHNICAL.md) · [Upstream provenance](UPSTREAM.md)
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
npm run check
|
|
||||||
npm run smoke
|
|
||||||
npm pack --dry-run --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests cover command grammar and completions, branch restoration, cross-session defaults, owner-only atomic config writes, invalid-config fallback, persistence failures, busy mutation guards, status publication, exact provider/API isolation, malformed payload passthrough, non-mutation, and tier overwrite. They make no authenticated or paid model requests.
|
|
||||||
|
|
||||||
## State model
|
|
||||||
|
|
||||||
The effective state is resolved in this order:
|
|
||||||
|
|
||||||
1. Read the global default from `<agentDir>/extensions/pi-extension-codex-fast-mode/config.json`; missing or invalid data defaults to off.
|
|
||||||
2. Reconstruct the latest valid `codex-fast-mode` custom entry on the active session branch.
|
|
||||||
3. Use the branch entry when present; otherwise use the global default.
|
|
||||||
|
|
||||||
A successful mode command appends a session entry when the branch value changes and atomically writes the global default even when the branch already has the requested value. This allows an explicit `/fast-mode on` or `/fast-mode off` to repair or recreate global configuration.
|
|
||||||
|
|
||||||
If the global write fails, the branch state still changes and the user receives a warning. Concurrent Pi processes use atomic replacement; the last successful command determines the default for future sessions.
|
|
||||||
|
|
||||||
## Request isolation
|
|
||||||
|
|
||||||
When enabled, the extension shallow-copies only a plain serialized payload whose provider is exactly `openai-codex` and API is exactly `openai-codex-responses`, then writes `service_tier: "priority"`. It preserves all other top-level fields and does not mutate the original payload.
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
This directory is maintained directly in `my-pi`, is not a submodule, and must not acquire a nested `.git`, `node_modules`, or generated build output. Compare local changes with the snapshot recorded in [UPSTREAM.md](UPSTREAM.md) before porting later upstream work.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Firstpick
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Codex Fast Mode for Pi
|
|
||||||
|
|
||||||
A locally maintained fork of `@firstpick/pi-extension-codex-fast-mode@0.1.1` that adds a cross-session Fast mode default.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- `/fast-mode on` and `/fast-mode off` update the current session and the global default for future sessions.
|
|
||||||
- A new session inherits the global default.
|
|
||||||
- A resumed session branch keeps its own latest recorded setting, overriding the global default.
|
|
||||||
- Only `openai-codex` requests using `openai-codex-responses` receive `service_tier: "priority"`.
|
|
||||||
- Other providers, APIs, payload fields, and authentication remain unchanged.
|
|
||||||
|
|
||||||
The global preference is stored at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<Pi agent dir>/extensions/pi-extension-codex-fast-mode/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Normally this is `~/.pi/agent/extensions/pi-extension-codex-fast-mode/config.json`. The directory and file are restricted to owner-only permissions (`0700` and `0600`). Missing, unreadable, or invalid configuration fails safely to Fast mode off.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- `/fast-mode` toggles Fast mode and saves the new global default.
|
|
||||||
- `/fast-mode on` enables it and makes new sessions default to on.
|
|
||||||
- `/fast-mode off` disables it and makes new sessions default to off.
|
|
||||||
- `/fast-mode status` reports the active branch setting and the default for new sessions.
|
|
||||||
|
|
||||||
Mode changes are rejected while Pi is busy; status remains available.
|
|
||||||
|
|
||||||
## Installation in my-pi
|
|
||||||
|
|
||||||
The root bundle loads this directory directly and declares it as a local `file:` dependency. Do not also install the upstream npm package, or Pi may load the command twice.
|
|
||||||
|
|
||||||
## Source and technical details
|
|
||||||
|
|
||||||
See [UPSTREAM.md](UPSTREAM.md) for provenance, [TECHNICAL.md](TECHNICAL.md) for runtime behavior, and [DEVELOPMENT.md](DEVELOPMENT.md) for validation. The upstream MIT license is retained in [LICENSE](LICENSE).
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Technical reference: Codex Fast Mode for Pi
|
|
||||||
|
|
||||||
Advanced behavior, configuration, compatibility, and troubleshooting for the locally maintained fork.
|
|
||||||
|
|
||||||
[Back to README](README.md) · [Development guide](DEVELOPMENT.md) · [Upstream provenance](UPSTREAM.md)
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- `/fast-mode` — toggle the active branch and global default.
|
|
||||||
- `/fast-mode on` — enable the active branch and make future sessions default to on.
|
|
||||||
- `/fast-mode off` — disable the active branch and make future sessions default to off.
|
|
||||||
- `/fast-mode status` — show both the active branch setting and global default.
|
|
||||||
|
|
||||||
Mutation is refused while Pi is busy. Status remains available.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
The extension stores the global default in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<Pi agent dir>/extensions/pi-extension-codex-fast-mode/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The file contains a single boolean setting:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Writes use a temporary file followed by atomic rename. The extension enforces `0700` on the containing directory and `0600` on the file. Missing configuration defaults to off. Malformed or unreadable configuration also fails to off and emits a warning without overwriting the bad file automatically.
|
|
||||||
|
|
||||||
Pi custom session entries remain branch-scoped. On session start or tree navigation, the latest valid branch entry overrides the global default. Consequently, resumed historical branches retain their own decision while genuinely new sessions inherit the latest global setting.
|
|
||||||
|
|
||||||
## Eligibility and credit use
|
|
||||||
|
|
||||||
Fast mode applies only when the selected model has provider `openai-codex` and API `openai-codex-responses`. For eligible plain-object payloads, the extension returns a shallow copy with `service_tier: "priority"`; unsupported providers, APIs, disabled state, and malformed payloads pass through unchanged.
|
|
||||||
|
|
||||||
Fast mode expresses request intent and cannot guarantee that the upstream service accepts or provides priority processing. OpenAI controls account and model eligibility, credit multipliers, and current service terms.
|
|
||||||
|
|
||||||
## Privacy and limitations
|
|
||||||
|
|
||||||
The extension does not inspect credentials, change authentication, choose a model, or make a separate network request. The only additional local data is the boolean global preference. If multiple Pi processes change the setting concurrently, the last successful atomic write becomes the default for future sessions.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 上游来源
|
|
||||||
|
|
||||||
- 上游仓库:<https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode>
|
|
||||||
- 导入包:`@firstpick/pi-extension-codex-fast-mode@0.1.1`
|
|
||||||
- 初始源码快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(2026-08-18)
|
|
||||||
- 已发布的 npm 源码 commit:`239829d747deae4d9c0f039421ab29440f40f1a2`;在导入快照时,本目录内唯一更晚的上游改动是包版本从 `0.1.0` 升到 `0.1.1`。
|
|
||||||
- 许可证:MIT;上游 `LICENSE` 文件原样保留。
|
|
||||||
|
|
||||||
## 本地维护
|
|
||||||
|
|
||||||
本目录从上游 Git 仓库导入,并由 `my-pi` 直接维护。它不是 submodule,也不保留上游 `.git` 状态、依赖或构建产物。
|
|
||||||
|
|
||||||
本地改动新增了一个 owner-only、原子写入的全局 Fast mode 偏好。新会话继承该偏好,而已有的 Pi 自定义 Session 记录继续保留分支特定历史。根组合包直接加载本源码,并使用本地 `file:` 依赖替代 npm 包。
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import {
|
|
||||||
getAgentDir,
|
|
||||||
type ExtensionAPI,
|
|
||||||
type ExtensionCommandContext,
|
|
||||||
type ExtensionContext,
|
|
||||||
} from "@earendil-works/pi-coding-agent";
|
|
||||||
|
|
||||||
export const FAST_MODE_STATUS_KEY = "codex-fast-mode";
|
|
||||||
export const FAST_MODE_STATE_ENTRY_TYPE = "codex-fast-mode";
|
|
||||||
export const FAST_MODE_SERVICE_TIER = "priority";
|
|
||||||
export const FAST_MODE_CONFIG_DIRECTORY = "pi-extension-codex-fast-mode";
|
|
||||||
|
|
||||||
const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
|
||||||
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
||||||
|
|
||||||
export type FastModeState = {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FastModeModel = {
|
|
||||||
provider?: unknown;
|
|
||||||
api?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FastModeCommand = "toggle" | "on" | "off" | "status" | "invalid";
|
|
||||||
|
|
||||||
export type FastModeConfigLoadResult = {
|
|
||||||
state: FastModeState;
|
|
||||||
warning?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FastModeConfigSaveResult =
|
|
||||||
| { success: true }
|
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
export type FastModeExtensionDependencies = {
|
|
||||||
configPath?: string;
|
|
||||||
loadGlobalState?: (path: string) => FastModeConfigLoadResult;
|
|
||||||
saveGlobalState?: (path: string, state: FastModeState) => FastModeConfigSaveResult;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Returns true only for object records that can safely receive a shallow request rewrite. */
|
|
||||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
if (value === null || typeof value !== "object") return false;
|
|
||||||
const prototype = Object.getPrototypeOf(value);
|
|
||||||
return prototype === Object.prototype || prototype === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fast mode is limited to Pi's subscription-backed Codex Responses provider. */
|
|
||||||
export function isFastModeEligibleModel(model: FastModeModel | undefined): boolean {
|
|
||||||
return model?.provider === "openai-codex" && model.api === "openai-codex-responses";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies Fast-mode request intent without mutating the provider's serialized payload.
|
|
||||||
* Undefined is intentional: Pi then retains the original payload unchanged.
|
|
||||||
*/
|
|
||||||
export function transformFastModeRequest(
|
|
||||||
enabled: boolean,
|
|
||||||
model: FastModeModel | undefined,
|
|
||||||
payload: unknown,
|
|
||||||
): Record<string, unknown> | undefined {
|
|
||||||
if (!enabled || !isFastModeEligibleModel(model) || !isPlainObject(payload)) return undefined;
|
|
||||||
return { ...payload, service_tier: FAST_MODE_SERVICE_TIER };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves the owner-only global preference file beneath Pi's agent directory. */
|
|
||||||
export function getFastModeConfigPath(agentDir: string): string {
|
|
||||||
return join(agentDir, "extensions", FAST_MODE_CONFIG_DIRECTORY, "config.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the cross-session default. Missing or invalid configuration fails safely to off. */
|
|
||||||
export function loadGlobalFastModeState(path: string): FastModeConfigLoadResult {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
||||||
if (!isPlainObject(parsed) || typeof parsed.enabled !== "boolean") {
|
|
||||||
return {
|
|
||||||
state: { enabled: false },
|
|
||||||
warning: `Ignoring invalid Fast mode config at ${path}; expected { "enabled": boolean }.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { state: { enabled: parsed.enabled } };
|
|
||||||
} catch (error) {
|
|
||||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
||||||
return { state: { enabled: false } };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: { enabled: false },
|
|
||||||
warning: `Could not read Fast mode config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Atomically writes the cross-session default with owner-only permissions. */
|
|
||||||
export function saveGlobalFastModeState(path: string, state: FastModeState): FastModeConfigSaveResult {
|
|
||||||
const directory = dirname(path);
|
|
||||||
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
||||||
try {
|
|
||||||
mkdirSync(directory, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
||||||
chmodSync(directory, OWNER_ONLY_DIRECTORY_MODE);
|
|
||||||
writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
||||||
encoding: "utf8",
|
|
||||||
flag: "wx",
|
|
||||||
mode: OWNER_ONLY_FILE_MODE,
|
|
||||||
});
|
|
||||||
chmodSync(temporaryPath, OWNER_ONLY_FILE_MODE);
|
|
||||||
renameSync(temporaryPath, path);
|
|
||||||
chmodSync(path, OWNER_ONLY_FILE_MODE);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
try {
|
|
||||||
unlinkSync(temporaryPath);
|
|
||||||
} catch {
|
|
||||||
// The temporary file may not have been created.
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reconstructs the latest branch snapshot, falling back to the cross-session default. */
|
|
||||||
export function reconstructFastModeState(
|
|
||||||
entries: readonly unknown[],
|
|
||||||
defaultEnabled = false,
|
|
||||||
): FastModeState {
|
|
||||||
let enabled = defaultEnabled;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!isPlainObject(entry)) continue;
|
|
||||||
if (entry.type !== "custom" || entry.customType !== FAST_MODE_STATE_ENTRY_TYPE) continue;
|
|
||||||
if (!isPlainObject(entry.data) || typeof entry.data.enabled !== "boolean") continue;
|
|
||||||
enabled = entry.data.enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { enabled };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseFastModeCommand(args: string): FastModeCommand {
|
|
||||||
const normalized = args.trim().toLowerCase();
|
|
||||||
if (!normalized) return "toggle";
|
|
||||||
if (normalized === "on" || normalized === "off" || normalized === "status") return normalized;
|
|
||||||
return "invalid";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fastModeArgumentCompletions(prefix: string) {
|
|
||||||
const normalized = prefix.trim().toLowerCase();
|
|
||||||
return ["on", "off", "status"]
|
|
||||||
.filter((value) => value.startsWith(normalized))
|
|
||||||
.map((value) => ({ value, label: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBusy(ctx: Pick<ExtensionCommandContext, "isIdle" | "hasPendingMessages">): boolean {
|
|
||||||
return !ctx.isIdle() || ctx.hasPendingMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishStatus(ctx: Pick<ExtensionContext, "ui">, enabled: boolean): void {
|
|
||||||
ctx.ui.setStatus(FAST_MODE_STATUS_KEY, enabled ? "on" : "off");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStatus(enabled: boolean, globalEnabled: boolean): string {
|
|
||||||
const state = enabled ? "on" : "off";
|
|
||||||
const globalState = globalEnabled ? "on" : "off";
|
|
||||||
return `Fast mode: ${state}. New sessions default to ${globalState}. It only requests priority service for openai-codex/openai-codex-responses.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function codexFastModeExtension(
|
|
||||||
pi: ExtensionAPI,
|
|
||||||
dependencies: FastModeExtensionDependencies = {},
|
|
||||||
): void {
|
|
||||||
const configPath = dependencies.configPath ?? getFastModeConfigPath(getAgentDir());
|
|
||||||
const loadGlobalState = dependencies.loadGlobalState ?? loadGlobalFastModeState;
|
|
||||||
const saveGlobalState = dependencies.saveGlobalState ?? saveGlobalFastModeState;
|
|
||||||
let enabled = false;
|
|
||||||
let globalEnabled = false;
|
|
||||||
let lastConfigWarning: string | undefined;
|
|
||||||
|
|
||||||
const restoreState = (ctx: ExtensionContext): void => {
|
|
||||||
const loaded = loadGlobalState(configPath);
|
|
||||||
globalEnabled = loaded.state.enabled;
|
|
||||||
enabled = reconstructFastModeState(ctx.sessionManager.getBranch(), globalEnabled).enabled;
|
|
||||||
publishStatus(ctx, enabled);
|
|
||||||
|
|
||||||
if (loaded.warning && loaded.warning !== lastConfigWarning) {
|
|
||||||
ctx.ui.notify(loaded.warning, "warning");
|
|
||||||
}
|
|
||||||
lastConfigWarning = loaded.warning;
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistSessionState = (): void => {
|
|
||||||
pi.appendEntry<FastModeState>(FAST_MODE_STATE_ENTRY_TYPE, { enabled });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setEnabled = (ctx: ExtensionCommandContext, nextEnabled: boolean): void => {
|
|
||||||
const stateChanged = enabled !== nextEnabled;
|
|
||||||
const saveResult = saveGlobalState(configPath, { enabled: nextEnabled });
|
|
||||||
if (saveResult.success) {
|
|
||||||
globalEnabled = nextEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateChanged) {
|
|
||||||
enabled = nextEnabled;
|
|
||||||
persistSessionState();
|
|
||||||
}
|
|
||||||
|
|
||||||
publishStatus(ctx, enabled);
|
|
||||||
if (stateChanged) {
|
|
||||||
ctx.ui.notify(enabled
|
|
||||||
? "Fast mode enabled. Supported Codex requests will use priority service, and new sessions will inherit this setting."
|
|
||||||
: "Fast mode disabled. Supported Codex requests will keep their existing service tier, and new sessions will inherit this setting.", "info");
|
|
||||||
} else {
|
|
||||||
ctx.ui.notify(`Fast mode is already ${enabled ? "on" : "off"}. The global default was refreshed.`, "info");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!saveResult.success) {
|
|
||||||
ctx.ui.notify(`The current session was updated, but the Fast mode global default could not be saved: ${saveResult.error}`, "warning");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
|
||||||
restoreState(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_tree", (_event, ctx) => {
|
|
||||||
restoreState(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("before_provider_request", (event, ctx) => {
|
|
||||||
return transformFastModeRequest(enabled, ctx.model, event.payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("fast-mode", {
|
|
||||||
description: "Toggle persistent Codex subscription Fast mode. Usage: /fast-mode [on|off|status]",
|
|
||||||
getArgumentCompletions: fastModeArgumentCompletions,
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
const command = parseFastModeCommand(args);
|
|
||||||
|
|
||||||
if (command === "status") {
|
|
||||||
publishStatus(ctx, enabled);
|
|
||||||
ctx.ui.notify(formatStatus(enabled, globalEnabled), "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === "invalid") {
|
|
||||||
ctx.ui.notify("Usage: /fast-mode [on|off|status]", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBusy(ctx)) {
|
|
||||||
ctx.ui.notify("Fast mode cannot be changed while the session is busy. Run /fast-mode status to inspect it.", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnabled(ctx, command === "toggle" ? !enabled : command === "on");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@firstpick/pi-extension-codex-fast-mode",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"description": "Persistent Codex subscription Fast mode for Pi requests.",
|
|
||||||
"type": "module",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode#readme",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/Firstp1ck/pi-coding-agent-forge.git",
|
|
||||||
"directory": "pi-extension-codex-fast-mode"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"pi-package",
|
|
||||||
"pi",
|
|
||||||
"pi-coding-agent",
|
|
||||||
"extension",
|
|
||||||
"openai-codex",
|
|
||||||
"fast-mode"
|
|
||||||
],
|
|
||||||
"pi": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
||||||
"check": "node --experimental-strip-types --check index.ts",
|
|
||||||
"smoke": "node --experimental-strip-types --input-type=module -e \"import('./index.ts')\""
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@earendil-works/pi-coding-agent": "*"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"index.ts",
|
|
||||||
"README.md",
|
|
||||||
"DEVELOPMENT.md",
|
|
||||||
"TECHNICAL.md",
|
|
||||||
"UPSTREAM.md",
|
|
||||||
"LICENSE"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.19.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
||||||
import codexFastModeExtension, {
|
|
||||||
FAST_MODE_SERVICE_TIER,
|
|
||||||
FAST_MODE_STATE_ENTRY_TYPE,
|
|
||||||
FAST_MODE_STATUS_KEY,
|
|
||||||
fastModeArgumentCompletions,
|
|
||||||
getFastModeConfigPath,
|
|
||||||
isFastModeEligibleModel,
|
|
||||||
isPlainObject,
|
|
||||||
loadGlobalFastModeState,
|
|
||||||
parseFastModeCommand,
|
|
||||||
reconstructFastModeState,
|
|
||||||
saveGlobalFastModeState,
|
|
||||||
transformFastModeRequest,
|
|
||||||
type FastModeState,
|
|
||||||
} from "../index.ts";
|
|
||||||
|
|
||||||
type StatusUpdate = { key: string; value: string | undefined };
|
|
||||||
type Notification = { message: string; level: string };
|
|
||||||
type Handler = (event: any, ctx: any) => unknown;
|
|
||||||
|
|
||||||
function createHarness(options: {
|
|
||||||
entries?: unknown[];
|
|
||||||
busy?: boolean;
|
|
||||||
pending?: boolean;
|
|
||||||
globalEnabled?: boolean;
|
|
||||||
saveError?: string;
|
|
||||||
} = {}) {
|
|
||||||
const handlers = new Map<string, Handler>();
|
|
||||||
const commands = new Map<string, any>();
|
|
||||||
const entries = options.entries ?? [];
|
|
||||||
const statusUpdates: StatusUpdate[] = [];
|
|
||||||
const notifications: Notification[] = [];
|
|
||||||
const appendCalls: Array<{ customType: string; data: unknown }> = [];
|
|
||||||
const loadCalls: string[] = [];
|
|
||||||
const saveCalls: Array<{ path: string; state: FastModeState }> = [];
|
|
||||||
let busy = options.busy ?? false;
|
|
||||||
let pending = options.pending ?? false;
|
|
||||||
let globalEnabled = options.globalEnabled ?? false;
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
model: { provider: "openai-codex", api: "openai-codex-responses" },
|
|
||||||
sessionManager: { getBranch: () => entries },
|
|
||||||
isIdle: () => !busy,
|
|
||||||
hasPendingMessages: () => pending,
|
|
||||||
ui: {
|
|
||||||
setStatus(key: string, value: string | undefined) {
|
|
||||||
statusUpdates.push({ key, value });
|
|
||||||
},
|
|
||||||
notify(message: string, level: string) {
|
|
||||||
notifications.push({ message, level });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as ExtensionCommandContext;
|
|
||||||
|
|
||||||
codexFastModeExtension({
|
|
||||||
on(event: string, handler: Handler) {
|
|
||||||
handlers.set(event, handler);
|
|
||||||
},
|
|
||||||
registerCommand(name: string, command: unknown) {
|
|
||||||
commands.set(name, command);
|
|
||||||
},
|
|
||||||
appendEntry(customType: string, data: unknown) {
|
|
||||||
appendCalls.push({ customType, data });
|
|
||||||
entries.push({ type: "custom", customType, data });
|
|
||||||
},
|
|
||||||
} as unknown as ExtensionAPI, {
|
|
||||||
configPath: "/test-agent/extensions/pi-extension-codex-fast-mode/config.json",
|
|
||||||
loadGlobalState(path) {
|
|
||||||
loadCalls.push(path);
|
|
||||||
return { state: { enabled: globalEnabled } };
|
|
||||||
},
|
|
||||||
saveGlobalState(path, state) {
|
|
||||||
saveCalls.push({ path, state });
|
|
||||||
if (options.saveError) return { success: false, error: options.saveError };
|
|
||||||
globalEnabled = state.enabled;
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
appendCalls,
|
|
||||||
commands,
|
|
||||||
context,
|
|
||||||
entries,
|
|
||||||
handlers,
|
|
||||||
loadCalls,
|
|
||||||
notifications,
|
|
||||||
saveCalls,
|
|
||||||
setBusy(value: boolean) { busy = value; },
|
|
||||||
setPending(value: boolean) { pending = value; },
|
|
||||||
statusUpdates,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("plain-object guard excludes malformed payloads", () => {
|
|
||||||
assert.equal(isPlainObject({}), true);
|
|
||||||
assert.equal(isPlainObject(Object.create(null)), true);
|
|
||||||
assert.equal(isPlainObject([]), false);
|
|
||||||
assert.equal(isPlainObject(new Date()), false);
|
|
||||||
assert.equal(isPlainObject(null), false);
|
|
||||||
assert.equal(isPlainObject("payload"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("provider eligibility is exact", () => {
|
|
||||||
assert.equal(isFastModeEligibleModel({ provider: "openai-codex", api: "openai-codex-responses" }), true);
|
|
||||||
assert.equal(isFastModeEligibleModel({ provider: "openai", api: "openai-codex-responses" }), false);
|
|
||||||
assert.equal(isFastModeEligibleModel({ provider: "openai-codex", api: "openai-responses" }), false);
|
|
||||||
assert.equal(isFastModeEligibleModel(undefined), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("request transformer is isolated, non-mutating, and overwrites only service_tier", () => {
|
|
||||||
const payload = { model: "gpt-5.6-codex", service_tier: "default", nested: { preserved: true } };
|
|
||||||
const transformed = transformFastModeRequest(true, { provider: "openai-codex", api: "openai-codex-responses" }, payload);
|
|
||||||
|
|
||||||
assert.deepEqual(transformed, {
|
|
||||||
model: "gpt-5.6-codex",
|
|
||||||
service_tier: FAST_MODE_SERVICE_TIER,
|
|
||||||
nested: { preserved: true },
|
|
||||||
});
|
|
||||||
assert.notStrictEqual(transformed, payload);
|
|
||||||
assert.strictEqual(transformed?.nested, payload.nested);
|
|
||||||
assert.equal(payload.service_tier, "default");
|
|
||||||
assert.equal(transformFastModeRequest(false, { provider: "openai-codex", api: "openai-codex-responses" }, payload), undefined);
|
|
||||||
assert.equal(transformFastModeRequest(true, { provider: "openai", api: "openai-codex-responses" }, payload), undefined);
|
|
||||||
assert.equal(transformFastModeRequest(true, { provider: "openai-codex", api: "openai-codex-responses" }, []), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("branch reconstruction prefers the latest valid snapshot over the global default", () => {
|
|
||||||
assert.deepEqual(reconstructFastModeState([], true), { enabled: true });
|
|
||||||
assert.deepEqual(reconstructFastModeState([
|
|
||||||
{ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: true } },
|
|
||||||
{ type: "custom", customType: "other", data: { enabled: false } },
|
|
||||||
{ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: "invalid" } },
|
|
||||||
{ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: false } },
|
|
||||||
], true), { enabled: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("global config round-trips atomically with owner-only permissions", () => {
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), "fast-mode-test-"));
|
|
||||||
const configPath = getFastModeConfigPath(agentDir);
|
|
||||||
try {
|
|
||||||
assert.deepEqual(loadGlobalFastModeState(configPath), { state: { enabled: false } });
|
|
||||||
assert.deepEqual(saveGlobalFastModeState(configPath, { enabled: true }), { success: true });
|
|
||||||
assert.deepEqual(loadGlobalFastModeState(configPath), { state: { enabled: true } });
|
|
||||||
assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { enabled: true });
|
|
||||||
assert.equal(statSync(configPath).mode & 0o777, 0o600);
|
|
||||||
assert.equal(statSync(join(agentDir, "extensions", "pi-extension-codex-fast-mode")).mode & 0o777, 0o700);
|
|
||||||
} finally {
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid or unreadable global config fails safely to off", () => {
|
|
||||||
const directory = mkdtempSync(join(tmpdir(), "fast-mode-invalid-"));
|
|
||||||
const configPath = join(directory, "config.json");
|
|
||||||
try {
|
|
||||||
writeFileSync(configPath, "{ not-json", "utf8");
|
|
||||||
const invalid = loadGlobalFastModeState(configPath);
|
|
||||||
assert.deepEqual(invalid.state, { enabled: false });
|
|
||||||
assert.match(invalid.warning ?? "", /Could not read/u);
|
|
||||||
|
|
||||||
writeFileSync(configPath, JSON.stringify({ enabled: "yes" }), "utf8");
|
|
||||||
const wrongType = loadGlobalFastModeState(configPath);
|
|
||||||
assert.deepEqual(wrongType.state, { enabled: false });
|
|
||||||
assert.match(wrongType.warning ?? "", /expected/u);
|
|
||||||
|
|
||||||
chmodSync(configPath, 0o600);
|
|
||||||
} finally {
|
|
||||||
rmSync(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("command grammar and completions are deterministic", () => {
|
|
||||||
assert.equal(parseFastModeCommand(""), "toggle");
|
|
||||||
assert.equal(parseFastModeCommand(" ON "), "on");
|
|
||||||
assert.equal(parseFastModeCommand("off"), "off");
|
|
||||||
assert.equal(parseFastModeCommand("status"), "status");
|
|
||||||
assert.equal(parseFastModeCommand("on now"), "invalid");
|
|
||||||
assert.deepEqual(fastModeArgumentCompletions("o"), [
|
|
||||||
{ value: "on", label: "on" },
|
|
||||||
{ value: "off", label: "off" },
|
|
||||||
]);
|
|
||||||
assert.deepEqual(fastModeArgumentCompletions("status"), [{ value: "status", label: "status" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a new session inherits the global default while a branch snapshot takes precedence", () => {
|
|
||||||
const inherited = createHarness({ globalEnabled: true });
|
|
||||||
const inheritedStart = inherited.handlers.get("session_start");
|
|
||||||
assert.ok(inheritedStart);
|
|
||||||
inheritedStart!({}, inherited.context as unknown as ExtensionContext);
|
|
||||||
assert.deepEqual(inherited.statusUpdates.at(-1), { key: FAST_MODE_STATUS_KEY, value: "on" });
|
|
||||||
|
|
||||||
const overridden = createHarness({
|
|
||||||
globalEnabled: true,
|
|
||||||
entries: [{ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: false } }],
|
|
||||||
});
|
|
||||||
const overriddenStart = overridden.handlers.get("session_start");
|
|
||||||
assert.ok(overriddenStart);
|
|
||||||
overriddenStart!({}, overridden.context as unknown as ExtensionContext);
|
|
||||||
assert.deepEqual(overridden.statusUpdates.at(-1), { key: FAST_MODE_STATUS_KEY, value: "off" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extension restores state, publishes status, and persists session and global mutations", async () => {
|
|
||||||
const harness = createHarness({
|
|
||||||
entries: [{ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: true } }],
|
|
||||||
});
|
|
||||||
const sessionStart = harness.handlers.get("session_start");
|
|
||||||
const request = harness.handlers.get("before_provider_request");
|
|
||||||
const command = harness.commands.get("fast-mode");
|
|
||||||
assert.ok(sessionStart && request && command);
|
|
||||||
|
|
||||||
sessionStart!({}, harness.context as unknown as ExtensionContext);
|
|
||||||
assert.deepEqual(harness.statusUpdates.at(-1), { key: FAST_MODE_STATUS_KEY, value: "on" });
|
|
||||||
assert.deepEqual(request!({ payload: { service_tier: "standard", keep: true } }, harness.context), {
|
|
||||||
service_tier: FAST_MODE_SERVICE_TIER,
|
|
||||||
keep: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await command.handler("off", harness.context);
|
|
||||||
assert.deepEqual(harness.appendCalls, [{ customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: false } }]);
|
|
||||||
assert.deepEqual(harness.saveCalls.map((call) => call.state), [{ enabled: false }]);
|
|
||||||
assert.deepEqual(harness.statusUpdates.at(-1), { key: FAST_MODE_STATUS_KEY, value: "off" });
|
|
||||||
assert.equal(request!({ payload: { keep: true } }, harness.context), undefined);
|
|
||||||
|
|
||||||
await command.handler("status", harness.context);
|
|
||||||
assert.match(harness.notifications.at(-1)?.message ?? "", /Fast mode: off.*New sessions default to off/u);
|
|
||||||
assert.equal(harness.notifications.at(-1)?.level, "info");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an explicit no-op mode command still refreshes the global default", async () => {
|
|
||||||
const harness = createHarness({ globalEnabled: true });
|
|
||||||
const sessionStart = harness.handlers.get("session_start");
|
|
||||||
const command = harness.commands.get("fast-mode");
|
|
||||||
assert.ok(sessionStart && command);
|
|
||||||
sessionStart!({}, harness.context as unknown as ExtensionContext);
|
|
||||||
|
|
||||||
await command.handler("on", harness.context);
|
|
||||||
assert.equal(harness.appendCalls.length, 0);
|
|
||||||
assert.deepEqual(harness.saveCalls.map((call) => call.state), [{ enabled: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a global save failure leaves the session usable and reports the persistence failure", async () => {
|
|
||||||
const harness = createHarness({ saveError: "read-only filesystem" });
|
|
||||||
const sessionStart = harness.handlers.get("session_start");
|
|
||||||
const command = harness.commands.get("fast-mode");
|
|
||||||
assert.ok(sessionStart && command);
|
|
||||||
sessionStart!({}, harness.context as unknown as ExtensionContext);
|
|
||||||
|
|
||||||
await command.handler("on", harness.context);
|
|
||||||
assert.deepEqual(harness.appendCalls, [{ customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: true } }]);
|
|
||||||
assert.equal(harness.notifications.at(-1)?.level, "warning");
|
|
||||||
assert.match(harness.notifications.at(-1)?.message ?? "", /could not be saved.*read-only filesystem/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extension rejects every mutation while busy but leaves status readable", async () => {
|
|
||||||
const harness = createHarness({ busy: true });
|
|
||||||
const sessionStart = harness.handlers.get("session_start");
|
|
||||||
const command = harness.commands.get("fast-mode");
|
|
||||||
assert.ok(sessionStart && command);
|
|
||||||
sessionStart!({}, harness.context as unknown as ExtensionContext);
|
|
||||||
|
|
||||||
await command.handler("on", harness.context);
|
|
||||||
await command.handler("", harness.context);
|
|
||||||
await command.handler("off", harness.context);
|
|
||||||
assert.equal(harness.appendCalls.length, 0);
|
|
||||||
assert.equal(harness.saveCalls.length, 0);
|
|
||||||
assert.equal(harness.notifications.filter((item) => item.level === "warning").length, 3);
|
|
||||||
|
|
||||||
await command.handler("status", harness.context);
|
|
||||||
assert.match(harness.notifications.at(-1)?.message ?? "", /Fast mode: off/u);
|
|
||||||
assert.equal(harness.notifications.at(-1)?.level, "info");
|
|
||||||
|
|
||||||
harness.setBusy(false);
|
|
||||||
harness.setPending(true);
|
|
||||||
await command.handler("on", harness.context);
|
|
||||||
assert.equal(harness.appendCalls.length, 0);
|
|
||||||
assert.equal(harness.saveCalls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tree navigation reconstructs the active branch state", () => {
|
|
||||||
const harness = createHarness({
|
|
||||||
globalEnabled: true,
|
|
||||||
entries: [{ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: true } }],
|
|
||||||
});
|
|
||||||
const sessionStart = harness.handlers.get("session_start");
|
|
||||||
const sessionTree = harness.handlers.get("session_tree");
|
|
||||||
assert.ok(sessionStart && sessionTree);
|
|
||||||
|
|
||||||
sessionStart!({}, harness.context as unknown as ExtensionContext);
|
|
||||||
harness.entries.push({ type: "custom", customType: FAST_MODE_STATE_ENTRY_TYPE, data: { enabled: false } });
|
|
||||||
sessionTree!({}, harness.context as unknown as ExtensionContext);
|
|
||||||
assert.deepEqual(harness.statusUpdates.at(-1), { key: FAST_MODE_STATUS_KEY, value: "off" });
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,7 @@ Minimal footer for [pi](https://github.com/earendil-works/pi) that replaces the
|
|||||||
- **Subscription usage bars** — rolling window quotas with reset timers for supported providers
|
- **Subscription usage bars** — rolling window quotas with reset timers for supported providers
|
||||||
- **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes
|
- **Auto-refresh** — fetches usage on startup and model switch, then every 5 minutes
|
||||||
- **Git integration** — branch name, dirty state, ahead/behind counts
|
- **Git integration** — branch name, dirty state, ahead/behind counts
|
||||||
- **Extension statuses** — preserves statuses published through `ctx.ui.setStatus()` and shows `Fast on` while Codex Fast mode is enabled
|
- **Extension statuses** — preserves statuses published through `ctx.ui.setStatus()`
|
||||||
|
|
||||||
## Supported providers
|
## Supported providers
|
||||||
|
|
||||||
@@ -70,6 +70,6 @@ Anthropic's OAuth usage endpoint (`/api/oauth/usage`) has been returning persist
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Replaces the default pi footer entirely via `ctx.ui.setFooter()`
|
- Replaces the default pi footer entirely via `ctx.ui.setFooter()`
|
||||||
- The local `my-pi` version reads `footerData.getExtensionStatuses()`; `codex-fast-mode=on` is rendered as an accented `Fast on` segment, while the disabled state is hidden.
|
- The local `my-pi` version reads `footerData.getExtensionStatuses()` and renders each non-empty status as a plain footer segment.
|
||||||
- Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.)
|
- Auth tokens are read from `~/.pi/agent/auth.json` (populated by `/login`) or standard env vars (`ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, etc.)
|
||||||
- Providers without auth simply don't show a usage bar — no errors
|
- Providers without auth simply don't show a usage bar — no errors
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
- 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10`
|
- 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10`
|
||||||
- 导入日期:2026-08-19
|
- 导入日期:2026-08-19
|
||||||
|
|
||||||
本目录由 `my-pi` 直接维护;它不是 submodule,也不保留上游 Git 元数据。本地改动新增了 Pi 扩展状态渲染,包括为 `@firstpick/pi-extension-codex-fast-mode` 显示显式的 `Fast on` 指示。
|
本目录由 `my-pi` 直接维护;它不是 submodule,也不保留上游 Git 元数据。本地改动新增了 Pi 扩展状态渲染(把 `ctx.ui.setStatus()` 发布的每个非空状态渲染为普通 footer 片段)。
|
||||||
|
|||||||
@@ -1059,9 +1059,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (branchStr) locationVariants.push(branchStr);
|
if (branchStr) locationVariants.push(branchStr);
|
||||||
const locationBlock = locationVariants.length > 0 ? fitFooterSegment(width, locationVariants) : "";
|
const locationBlock = locationVariants.length > 0 ? fitFooterSegment(width, locationVariants) : "";
|
||||||
|
|
||||||
const extensionStatusBlocks = collectFooterStatusItems(footerData.getExtensionStatuses()).map((status) =>
|
const extensionStatusBlocks = collectFooterStatusItems(footerData.getExtensionStatuses());
|
||||||
status.accent ? theme.fg("accent", status.text) : status.text
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusBlocks = [
|
const statusBlocks = [
|
||||||
locationBlock,
|
locationBlock,
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
export const FAST_MODE_STATUS_KEY = "codex-fast-mode";
|
/** Collect the non-empty extension statuses published via `ctx.ui.setStatus()`. */
|
||||||
|
|
||||||
export interface FooterStatusItem {
|
|
||||||
text: string;
|
|
||||||
accent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert Pi extension statuses into compact footer segments. */
|
|
||||||
export function collectFooterStatusItems(
|
export function collectFooterStatusItems(
|
||||||
statuses: ReadonlyMap<string, string>,
|
statuses: ReadonlyMap<string, string>,
|
||||||
): FooterStatusItem[] {
|
): string[] {
|
||||||
const items: FooterStatusItem[] = [];
|
const items: string[] = [];
|
||||||
|
|
||||||
for (const [key, value] of statuses) {
|
|
||||||
if (key === FAST_MODE_STATUS_KEY) {
|
|
||||||
if (value.trim().toLowerCase() === "on") {
|
|
||||||
items.push({ text: "Fast on", accent: true });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
for (const [, value] of statuses) {
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
items.push({ text: value, accent: false });
|
items.push(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { collectFooterStatusItems, FAST_MODE_STATUS_KEY } from "../status.ts";
|
import { collectFooterStatusItems } from "../status.ts";
|
||||||
|
|
||||||
test("renders enabled Codex Fast mode as an explicit accented segment", () => {
|
|
||||||
const items = collectFooterStatusItems(new Map([[FAST_MODE_STATUS_KEY, "on"]]));
|
|
||||||
|
|
||||||
assert.deepEqual(items, [{ text: "Fast on", accent: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides disabled Codex Fast mode", () => {
|
|
||||||
const items = collectFooterStatusItems(new Map([[FAST_MODE_STATUS_KEY, "off"]]));
|
|
||||||
|
|
||||||
assert.deepEqual(items, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves other non-empty extension statuses", () => {
|
test("preserves other non-empty extension statuses", () => {
|
||||||
const items = collectFooterStatusItems(new Map([
|
const items = collectFooterStatusItems(new Map([
|
||||||
@@ -22,8 +10,11 @@ test("preserves other non-empty extension statuses", () => {
|
|||||||
["two", "2 tasks"],
|
["two", "2 tasks"],
|
||||||
]));
|
]));
|
||||||
|
|
||||||
assert.deepEqual(items, [
|
assert.deepEqual(items, ["Ready", "2 tasks"]);
|
||||||
{ text: "Ready", accent: false },
|
});
|
||||||
{ text: "2 tasks", accent: false },
|
|
||||||
]);
|
test("returns no segments when every status is blank", () => {
|
||||||
|
const items = collectFooterStatusItems(new Map([["a", ""], ["b", " "]]));
|
||||||
|
|
||||||
|
assert.deepEqual(items, []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Integrate the former standalone AutoReview authorizer into the permission-system composition root; split user-editable ordinary policy and global-only hard guards into independent JSONC files; add sanitized 500-verdict checkpoints and isolated same-session background policy proposals without automatic rule edits.
|
- Make the AutoReview model configuration-only: drop the built-in `openai-codex` / `codex-auto-review` default pair and the synthesized Codex stand-in, so automatic review runs exactly the provider/model named in `policy.jsonc` and defers to the human prompt (with a one-time startup notice) when either is unset, unknown, or unpublished.
|
||||||
|
- Remove AutoReview's 500-verdict count ledger, isolated background analysis AgentSession, and same-session proposal delivery; the reviewer now only decides each ask and writes its verdict to the permission review log.
|
||||||
|
- Integrate the former standalone AutoReview authorizer into the permission-system composition root; split user-editable ordinary policy and global-only hard guards into independent JSONC files.
|
||||||
- Simplify ordinary policy to one global `policy.jsonc`: stop loading project policy files, rank matching patterns by specificity instead of declaration order, remove the `external_directory` gate and delegation envelope, let configured authorizers approve any ordinary-policy ask including `path`, and leave all active-tool visibility changes exclusively to Tool Search.
|
- Simplify ordinary policy to one global `policy.jsonc`: stop loading project policy files, rank matching patterns by specificity instead of declaration order, remove the `external_directory` gate and delegation envelope, let configured authorizers approve any ordinary-policy ask including `path`, and leave all active-tool visibility changes exclusively to Tool Search.
|
||||||
|
|
||||||
- Preserve a registered shell-alias input preview as authorizer evidence and permission review context, so wrappers such as `ssh_bash` can disclose their remote execution target while retaining full Bash policy enforcement.
|
- Preserve a registered shell-alias input preview as authorizer evidence and permission review context, so wrappers such as `ssh_bash` can disclose their remote execution target while retaining full Bash policy enforcement.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|||||||
- **Chooses the most specific matching pattern**, so a specific deny wins over a broad `"*": "allow"` regardless of declaration order
|
- **Chooses the most specific matching pattern**, so a specific deny wins over a broad `"*": "allow"` regardless of declaration order
|
||||||
- **Applies a separate hard-guard policy first** so session grants, per-agent rules, and model review cannot override high-risk denies
|
- **Applies a separate hard-guard policy first** so session grants, per-agent rules, and model review cannot override high-risk denies
|
||||||
- **Controls Bash, paths, MCP, skills, and tool surfaces** while leaving active-tool ownership entirely to Tool Search
|
- **Controls Bash, paths, MCP, skills, and tool surfaces** while leaving active-tool ownership entirely to Tool Search
|
||||||
- **Reviews ordinary `ask` requests automatically** with integrated AutoReview; its `allow` / `deny` verdict is final for ordinary tools and `path`, while `defer` reaches the next authorizer or human terminal
|
- **Reviews ordinary `ask` requests automatically** with integrated AutoReview once you name a review model in config; its `allow` / `deny` verdict is final for ordinary tools and `path`, while `defer` reaches the next authorizer or human terminal
|
||||||
- **Forwards subagent `ask` decisions** to the parent authority and keeps structured audit logs
|
- **Forwards subagent `ask` decisions** to the parent authority and keeps structured audit logs
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|||||||
@@ -496,7 +496,9 @@ Nothing constrains an `Authorizer` to be deterministic. `LocalUserAuthorizer` is
|
|||||||
|
|
||||||
This package now owns the default model reviewer as the named `auto-review` authorizer. It is still downstream of deterministic policy: only an ordinary `ask` enters the authorizer chain, and a hard-guard `deny` never reaches it. The reviewer returns `allow`, `deny`, or `defer`; only `defer` falls through to the terminal human authority. Its transcript boundary admits direct user messages and recognized non-cancelled structured answers, never assistant/tool/compaction content.
|
This package now owns the default model reviewer as the named `auto-review` authorizer. It is still downstream of deterministic policy: only an ordinary `ask` enters the authorizer chain, and a hard-guard `deny` never reaches it. The reviewer returns `allow`, `deny`, or `defer`; only `defer` falls through to the terminal human authority. Its transcript boundary admits direct user messages and recognized non-cancelled structured answers, never assistant/tool/compaction content.
|
||||||
|
|
||||||
The composition root registers the authorizer on the same `LocalPermissionsService` instance it publishes. `installBuiltinAutoReview()` returns lifecycle methods instead of registering a second set of Pi session handlers. The root's single `session_start` and `session_shutdown` handlers coordinate service publication and reviewer state; `turn_start` resets the denial circuit breaker, and `agent_settled` is the safe boundary for optional count-based analysis.
|
The composition root registers the authorizer on the same `LocalPermissionsService` instance it publishes. `installBuiltinAutoReview()` returns lifecycle methods instead of registering a second set of Pi session handlers. The root's single `session_start` and `session_shutdown` handlers coordinate service publication and reviewer state, and `turn_start` resets the denial circuit breaker.
|
||||||
|
|
||||||
|
The review model is configuration-only. `resolveReviewModel()` reads `autoReview.provider` / `autoReview.model` and looks the pair up in the live registry; it neither defaults the pair nor synthesizes a stand-in. An unconfigured, unknown-provider, or unpublished-model pair resolves to a failure category, which the reviewer records and turns into `defer`, so the ask reaches the human prompt instead of being decided by an unintended model.
|
||||||
|
|
||||||
A separate `HardGuardPolicyLoader` supplies a global-only manager to `PermissionResolver`. Resolution is deliberately asymmetric:
|
A separate `HardGuardPolicyLoader` supplies a global-only manager to `PermissionResolver`. Resolution is deliberately asymmetric:
|
||||||
|
|
||||||
@@ -511,8 +513,6 @@ ordinaryManager.check(intent, session rules)
|
|||||||
|
|
||||||
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Ordinary policy, per-agent restrictions, session grants, yolo mode, and model authority cannot loosen a hard deny.
|
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Ordinary policy, per-agent restrictions, session grants, yolo mode, and model authority cannot loosen a hard deny.
|
||||||
|
|
||||||
Completed AutoReview verdicts flow to an owner-only count ledger. The reviewer callback strips rationale/transcript/raw payload and persists bounded fingerprints. Reaching the configured checkpoint marks analysis pending. At `agent_settled`, a tool-free, extension-free, in-memory AgentSession receives only aggregated records. Its strict JSON proposal is validated against the evidence, persisted, and injected into the same session as a follow-up. The checkpoint advances only after a valid proposal is stored; shutdown leaves pending work durable. No analysis path mutates either policy file.
|
|
||||||
|
|
||||||
The public authorizer registry remains available for other named authorities. Configuration order determines chain order, unregistered names are skipped fail-safe, and registration alone grants no authority.
|
The public authorizer registry remains available for other named authorities. Configuration order determines chain order, unregistered names are skipped fail-safe, and registration alone grants no authority.
|
||||||
|
|
||||||
Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal.
|
Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal.
|
||||||
@@ -588,7 +588,7 @@ src/
|
|||||||
├── authority/
|
├── authority/
|
||||||
│ ├── authorizer-selection.ts Configured authorizer resolution
|
│ ├── authorizer-selection.ts Configured authorizer resolution
|
||||||
│ └── authorizer-chain.ts Named authorizer ordering and terminal fallback
|
│ └── authorizer-chain.ts Named authorizer ordering and terminal fallback
|
||||||
├── auto-review/ Integrated reviewer and count-based analysis
|
├── auto-review/ Integrated reviewer
|
||||||
├── service.ts Cross-extension permission service
|
├── service.ts Cross-extension permission service
|
||||||
└── types.ts Shared domain types
|
└── types.ts Shared domain types
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
|
|
||||||
- AutoReview 是 `pi-permission-system` 的内置 authorizer,而不是单独加载的 Pi 扩展。
|
- AutoReview 是 `pi-permission-system` 的内置 authorizer,而不是单独加载的 Pi 扩展。
|
||||||
- 配置从统一的 `policy.jsonc` 读取。
|
- 配置从统一的 `policy.jsonc` 读取。
|
||||||
- 完成的自动裁决进入基于计数的 ledger 和一个隔离的、无工具的后台分析 Agent。
|
- 评审模型完全由配置指定:不保留 provider/model 默认常量,也不合成替代模型;未配置或无法解析时 `defer` 到人工审批。
|
||||||
- 分析建议回传到当前活跃的 Pi Session,且在用户明确确认前绝不修改策略。
|
- 已移除上游的裁决计数 ledger、后台分析 Agent 与建议回传流程;扩展不再汇总历史裁决,也不自动修改策略。
|
||||||
|
|||||||
@@ -64,17 +64,39 @@ There is no separate `external_directory` decision. A path outside the current w
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Trailing commas are not supported. If parsing fails, ordinary policy fails safely to `ask`.
|
> **Note:** Trailing commas are not supported. If parsing fails, ordinary policy fails safely to `ask`.
|
||||||
## Built-in AutoReview and Count-Based Analysis
|
## Built-in AutoReview
|
||||||
|
|
||||||
`autoReview.enabled` controls the built-in authorizer named `auto-review`. It only receives requests that ordinary policy resolved to `ask`; it cannot bypass a hard deny. Add `"auto-review"` to `authorizerChain` to make it the primary case-by-case authority. An `allow` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog.
|
`autoReview.enabled` controls the built-in authorizer named `auto-review`. It only receives requests that ordinary policy resolved to `ask`; it cannot bypass a hard deny. Add `"auto-review"` to `authorizerChain` to make it the primary case-by-case authority. An `allow` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog.
|
||||||
|
|
||||||
The reviewer uses only trusted direct-user transcript evidence and recognized structured answers. Assistant, tool, compaction, and cancelled-answer content cannot authorize an operation. Model lookup, authentication, timeout, malformed output, or internal errors all return `defer`.
|
The reviewer uses only trusted direct-user transcript evidence and recognized structured answers. Assistant, tool, compaction, and cancelled-answer content cannot authorize an operation. Model lookup, authentication, timeout, malformed output, or internal errors all return `defer`.
|
||||||
|
|
||||||
`analysis.threshold` counts completed automatic verdicts, not Pi turns and not deferrals. Each `allow` or `deny` appends one sanitized record under the global extension state directory. Records contain bounded surface/pattern fingerprints and risk/authorization classifications; they omit rationale, raw payload, transcript, and credentials.
|
The review model comes entirely from configuration. There is no default provider/model pair and no synthesized stand-in, so automatic review stays inactive until both `autoReview.provider` and `autoReview.model` name a model your Pi registry actually publishes:
|
||||||
|
|
||||||
When the count since the last accepted checkpoint reaches the threshold (500 by default), the ledger marks analysis pending. At the next `agent_settled` boundary the extension starts an isolated AgentSession with no tools, extensions, skills, prompt templates, themes, or project context. Its JSON proposal is schema-validated and filtered so authorization-dependent, denied, or high-risk evidence cannot become an unconditional allow candidate.
|
```jsonc
|
||||||
|
{
|
||||||
|
"authorizerChain": ["auto-review"],
|
||||||
|
"autoReview": {
|
||||||
|
"enabled": true,
|
||||||
|
"provider": "deepseek",
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"reasoning": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
A successful proposal advances the checkpoint, is retained according to `retainAnalyzedBatches`, and is delivered with `deliverAs: "followUp"` and `triggerTurn: true` to this conversation. The proposal is advisory only. Neither `policy.jsonc` nor `hard-guards.jsonc` is modified until the user explicitly chooses and confirms rules. Shutdown aborts the running worker but leaves the pending checkpoint durable for the next session.
|
| Key | Default | Description |
|
||||||
|
| ---------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `autoReview.enabled` | `true` | Registers the `auto-review` authorizer. It decides nothing until named in `authorizerChain`. |
|
||||||
|
| `autoReview.provider` | — | Pi provider id of the review model. No default; when absent the reviewer defers every ask. |
|
||||||
|
| `autoReview.model` | — | Model id within that provider. No default; when absent the reviewer defers every ask. |
|
||||||
|
| `autoReview.reasoning` | — | Thinking level for a reasoning-capable review model. No default; omit to send no reasoning option. |
|
||||||
|
| `autoReview.timeoutMs` | `90000` | Total retry budget for one review call. |
|
||||||
|
| `autoReview.includeBaselinePolicy` | `true` | Include the built-in Codex Guardian–style policy text. |
|
||||||
|
| `autoReview.additionalPolicy` | — | Operator policy appended to the baseline. Required when `includeBaselinePolicy` is `false`. |
|
||||||
|
|
||||||
|
When either `provider` or `model` is missing and `auto-review` is named in `authorizerChain`, the extension notifies once at `session_start` that automatic review cannot run and every ask will reach the human prompt. An unknown provider or an unpublished model id also defers, with the reason recorded in the permission review log.
|
||||||
|
|
||||||
|
Each `allow` / `deny` is written to the permission review log; the extension keeps no aggregate history, runs no background analysis agent, and never edits either policy file.
|
||||||
|
|
||||||
## Runtime Knobs
|
## Runtime Knobs
|
||||||
|
|
||||||
|
|||||||
@@ -180,31 +180,6 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"analysis": {
|
|
||||||
"description": "Background policy analysis triggered after a configured number of completed automatic reviews.",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"threshold": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 100000
|
|
||||||
},
|
|
||||||
"retainAnalyzedBatches": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 20
|
|
||||||
},
|
|
||||||
"timeoutMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"exclusiveMinimum": 0,
|
|
||||||
"maximum": 300000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"shellTools": {
|
"shellTools": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import {
|
|
||||||
createAgentSession,
|
|
||||||
DefaultResourceLoader,
|
|
||||||
SessionManager,
|
|
||||||
SettingsManager,
|
|
||||||
type ModelRegistry,
|
|
||||||
} from "@earendil-works/pi-coding-agent";
|
|
||||||
import { z } from "zod";
|
|
||||||
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
|
|
||||||
import type { AutoReviewAnalysisBatch, AutoReviewLedgerRecord } from "./ledger.js";
|
|
||||||
import { resolveReviewModel } from "./model.js";
|
|
||||||
|
|
||||||
const candidateSchema = z.strictObject({
|
|
||||||
surface: z.string().min(1).max(100),
|
|
||||||
toolName: z.string().min(1).max(100).optional(),
|
|
||||||
pattern: z.string().min(1).max(240),
|
|
||||||
reason: z.string().min(1).max(1_000),
|
|
||||||
evidenceCount: z.number().int().positive(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const proposalSchema = z.strictObject({
|
|
||||||
summary: z.string().min(1).max(4_000),
|
|
||||||
allowCandidates: z.array(candidateSchema).max(50),
|
|
||||||
denyCandidates: z.array(candidateSchema).max(50),
|
|
||||||
keepReview: z.array(candidateSchema).max(100),
|
|
||||||
hardGuardCandidates: z.array(candidateSchema).max(25),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AutoReviewAnalysisProposal = z.infer<typeof proposalSchema>;
|
|
||||||
|
|
||||||
interface Aggregate {
|
|
||||||
surface: string;
|
|
||||||
toolName?: string;
|
|
||||||
pattern: string;
|
|
||||||
total: number;
|
|
||||||
allow: number;
|
|
||||||
deny: number;
|
|
||||||
authorizationDependent: number;
|
|
||||||
low: number;
|
|
||||||
medium: number;
|
|
||||||
high: number;
|
|
||||||
critical: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateRecords(records: AutoReviewLedgerRecord[]): Aggregate[] {
|
|
||||||
const groups = new Map<string, Aggregate>();
|
|
||||||
for (const record of records) {
|
|
||||||
const key = record.fingerprint;
|
|
||||||
const current = groups.get(key) ?? {
|
|
||||||
surface: record.surface,
|
|
||||||
...(record.toolName ? { toolName: record.toolName } : {}),
|
|
||||||
pattern: record.normalizedPattern,
|
|
||||||
total: 0,
|
|
||||||
allow: 0,
|
|
||||||
deny: 0,
|
|
||||||
authorizationDependent: 0,
|
|
||||||
low: 0,
|
|
||||||
medium: 0,
|
|
||||||
high: 0,
|
|
||||||
critical: 0,
|
|
||||||
};
|
|
||||||
current.total += 1;
|
|
||||||
current[record.outcome] += 1;
|
|
||||||
current[record.riskLevel] += 1;
|
|
||||||
if (record.authorizationDependent) current.authorizationDependent += 1;
|
|
||||||
groups.set(key, current);
|
|
||||||
}
|
|
||||||
return [...groups.values()]
|
|
||||||
.sort((left, right) => right.total - left.total)
|
|
||||||
.slice(0, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(text: string): unknown {
|
|
||||||
try { return JSON.parse(text); }
|
|
||||||
catch {
|
|
||||||
const start = text.indexOf("{");
|
|
||||||
const end = text.lastIndexOf("}");
|
|
||||||
if (start < 0 || end <= start) throw new Error("analysis response was not JSON");
|
|
||||||
return JSON.parse(text.slice(start, end + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assistantText(messages: readonly unknown[]): string {
|
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
||||||
const message = messages[index] as { role?: string; content?: unknown };
|
|
||||||
if (message?.role !== "assistant") continue;
|
|
||||||
if (typeof message.content === "string") return message.content;
|
|
||||||
if (Array.isArray(message.content)) {
|
|
||||||
return message.content
|
|
||||||
.flatMap((block) => {
|
|
||||||
if (typeof block === "string") return [block];
|
|
||||||
if (typeof block === "object" && block !== null && "text" in block) {
|
|
||||||
const text = (block as { text?: unknown }).text;
|
|
||||||
return typeof text === "string" ? [text] : [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("analysis agent returned no assistant text");
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyOf(value: { surface: string; toolName?: string; pattern: string }): string {
|
|
||||||
return `${value.surface}\u0000${value.toolName ?? ""}\u0000${value.pattern}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateProposal(
|
|
||||||
proposal: AutoReviewAnalysisProposal,
|
|
||||||
aggregates: Aggregate[],
|
|
||||||
): AutoReviewAnalysisProposal {
|
|
||||||
const evidence = new Map(aggregates.map((item) => [keyOf(item), item]));
|
|
||||||
const validateList = (
|
|
||||||
values: AutoReviewAnalysisProposal["allowCandidates"],
|
|
||||||
kind: "allow" | "deny" | "review" | "hard",
|
|
||||||
) => values.filter((candidate) => {
|
|
||||||
const item = evidence.get(keyOf(candidate));
|
|
||||||
if (!item || candidate.evidenceCount > item.total) return false;
|
|
||||||
if (kind === "allow") {
|
|
||||||
return item.deny === 0 && item.authorizationDependent === 0 && item.high === 0 && item.critical === 0;
|
|
||||||
}
|
|
||||||
if (kind === "deny") return item.allow === 0;
|
|
||||||
if (kind === "hard") return item.allow === 0 && item.high + item.critical > 0;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...proposal,
|
|
||||||
allowCandidates: validateList(proposal.allowCandidates, "allow"),
|
|
||||||
denyCandidates: validateList(proposal.denyCandidates, "deny"),
|
|
||||||
keepReview: validateList(proposal.keepReview, "review"),
|
|
||||||
hardGuardCandidates: validateList(proposal.hardGuardCandidates, "hard"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runAutoReviewAnalysisAgent(options: {
|
|
||||||
batch: AutoReviewAnalysisBatch;
|
|
||||||
registry: Pick<ModelRegistry, "find" | "getAll" | "getApiKeyAndHeaders" | "getProvider">;
|
|
||||||
autoReview: AutoReviewConfig;
|
|
||||||
analysis: AutoReviewAnalysisConfig;
|
|
||||||
stateDir: string;
|
|
||||||
agentDir: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}): Promise<AutoReviewAnalysisProposal> {
|
|
||||||
const resolved = resolveReviewModel(options.registry, options.autoReview);
|
|
||||||
if (!resolved.ok) throw new Error(`analysis model unavailable: ${resolved.category}`);
|
|
||||||
const aggregates = aggregateRecords(options.batch.records);
|
|
||||||
const systemPrompt = `You are a read-only permission-policy analyst. Analyze aggregated automatic-review decisions and return one JSON object only. Never suggest an unconditional allow when authorizationDependent is non-zero, any denial exists, or high/critical risk exists. Do not modify files or execute tools. Candidate keys must exactly copy surface, toolName (when present), and pattern from the evidence. The schema is: {summary:string,allowCandidates:Candidate[],denyCandidates:Candidate[],keepReview:Candidate[],hardGuardCandidates:Candidate[]}; Candidate is {surface:string,toolName?:string,pattern:string,reason:string,evidenceCount:integer}.`;
|
|
||||||
|
|
||||||
const loader = new DefaultResourceLoader({
|
|
||||||
cwd: options.stateDir,
|
|
||||||
agentDir: options.agentDir,
|
|
||||||
noExtensions: true,
|
|
||||||
noSkills: true,
|
|
||||||
noPromptTemplates: true,
|
|
||||||
noThemes: true,
|
|
||||||
noContextFiles: true,
|
|
||||||
systemPrompt,
|
|
||||||
appendSystemPrompt: [],
|
|
||||||
});
|
|
||||||
await loader.reload();
|
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
|
||||||
cwd: options.stateDir,
|
|
||||||
agentDir: options.agentDir,
|
|
||||||
model: resolved.value.model,
|
|
||||||
thinkingLevel: options.autoReview.reasoning,
|
|
||||||
noTools: "all",
|
|
||||||
resourceLoader: loader,
|
|
||||||
sessionManager: SessionManager.inMemory(),
|
|
||||||
settingsManager: SettingsManager.inMemory(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => session.abort(), options.analysis.timeoutMs);
|
|
||||||
const abort = () => session.abort();
|
|
||||||
options.signal?.addEventListener("abort", abort, { once: true });
|
|
||||||
try {
|
|
||||||
await session.prompt(JSON.stringify({
|
|
||||||
batch: {
|
|
||||||
fromSequence: options.batch.fromSequence,
|
|
||||||
toSequence: options.batch.toSequence,
|
|
||||||
policyHash: options.batch.policyHash,
|
|
||||||
},
|
|
||||||
aggregates,
|
|
||||||
}));
|
|
||||||
const parsed = proposalSchema.parse(parseJsonObject(assistantText(session.state.messages)));
|
|
||||||
return validateProposal(parsed, aggregates);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
options.signal?.removeEventListener("abort", abort);
|
|
||||||
session.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const DEFAULT_PROVIDER = "openai-codex";
|
/** Total retry budget for one review call. */
|
||||||
export const DEFAULT_MODEL = "codex-auto-review";
|
|
||||||
export const DEFAULT_TIMEOUT_MS = 90_000;
|
export const DEFAULT_TIMEOUT_MS = 90_000;
|
||||||
export const DEFAULT_ANALYSIS_THRESHOLD = 500;
|
|
||||||
export const DEFAULT_RETAIN_ANALYZED_BATCHES = 2;
|
|
||||||
|
|
||||||
export const REASONING_LEVELS = [
|
export const REASONING_LEVELS = [
|
||||||
"off",
|
"off",
|
||||||
@@ -36,49 +33,31 @@ export const autoReviewConfigFileSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const analysisConfigFileSchema = z.strictObject({
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
threshold: z.number().int().min(1).max(100_000).optional(),
|
|
||||||
retainAnalyzedBatches: z.number().int().min(0).max(20).optional(),
|
|
||||||
timeoutMs: z.number().int().positive().max(300_000).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AutoReviewConfigFile = z.infer<typeof autoReviewConfigFileSchema>;
|
export type AutoReviewConfigFile = z.infer<typeof autoReviewConfigFileSchema>;
|
||||||
export type AutoReviewAnalysisConfigFile = z.infer<typeof analysisConfigFileSchema>;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective reviewer configuration.
|
||||||
|
*
|
||||||
|
* `provider` and `model` have no built-in default and no synthesized fallback:
|
||||||
|
* the review model is whatever the operator names in the config file. When
|
||||||
|
* either is absent the reviewer cannot run and defers to the human prompt.
|
||||||
|
*/
|
||||||
export interface AutoReviewConfig {
|
export interface AutoReviewConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider: string;
|
provider?: string;
|
||||||
model: string;
|
model?: string;
|
||||||
reasoning: (typeof REASONING_LEVELS)[number];
|
reasoning?: (typeof REASONING_LEVELS)[number];
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
includeBaselinePolicy: boolean;
|
includeBaselinePolicy: boolean;
|
||||||
additionalPolicy?: string;
|
additionalPolicy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoReviewAnalysisConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
threshold: number;
|
|
||||||
retainAnalyzedBatches: number;
|
|
||||||
timeoutMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
|
export const DEFAULT_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
provider: DEFAULT_PROVIDER,
|
|
||||||
model: DEFAULT_MODEL,
|
|
||||||
reasoning: "low",
|
|
||||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
includeBaselinePolicy: true,
|
includeBaselinePolicy: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG: AutoReviewAnalysisConfig = {
|
|
||||||
enabled: true,
|
|
||||||
threshold: DEFAULT_ANALYSIS_THRESHOLD,
|
|
||||||
retainAnalyzedBatches: DEFAULT_RETAIN_ANALYZED_BATCHES,
|
|
||||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function normalizeAutoReviewConfig(
|
export function normalizeAutoReviewConfig(
|
||||||
value?: AutoReviewConfigFile,
|
value?: AutoReviewConfigFile,
|
||||||
): AutoReviewConfig {
|
): AutoReviewConfig {
|
||||||
@@ -87,12 +66,3 @@ export function normalizeAutoReviewConfig(
|
|||||||
...value,
|
...value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAutoReviewAnalysisConfig(
|
|
||||||
value?: AutoReviewAnalysisConfigFile,
|
|
||||||
): AutoReviewAnalysisConfig {
|
|
||||||
return {
|
|
||||||
...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
|
|
||||||
...value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import {
|
|
||||||
appendFileSync,
|
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
renameSync,
|
|
||||||
unlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type { PromptPermissionDetails } from "../service.js";
|
|
||||||
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
|
|
||||||
import type { ReviewAssessment } from "./verdict.js";
|
|
||||||
|
|
||||||
const DIRECTORY_MODE = 0o700;
|
|
||||||
const FILE_MODE = 0o600;
|
|
||||||
const MAX_PATTERN_LENGTH = 240;
|
|
||||||
|
|
||||||
interface LedgerState {
|
|
||||||
version: 1;
|
|
||||||
completedSequence: number;
|
|
||||||
lastAnalyzedSequence: number;
|
|
||||||
proposalSequence: number;
|
|
||||||
analysisPending: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoReviewLedgerRecord {
|
|
||||||
version: 1;
|
|
||||||
sequence: number;
|
|
||||||
timestamp: string;
|
|
||||||
requestId: string;
|
|
||||||
source: string;
|
|
||||||
agentName: string;
|
|
||||||
surface: string;
|
|
||||||
toolName?: string;
|
|
||||||
normalizedPattern: string;
|
|
||||||
fingerprint: string;
|
|
||||||
outcome: "allow" | "deny";
|
|
||||||
riskLevel: ReviewAssessment["riskLevel"];
|
|
||||||
userAuthorization: ReviewAssessment["userAuthorization"];
|
|
||||||
authorizationDependent: boolean;
|
|
||||||
policyRevision: string;
|
|
||||||
policyHash: string;
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoReviewAnalysisBatch {
|
|
||||||
fromSequence: number;
|
|
||||||
toSequence: number;
|
|
||||||
records: AutoReviewLedgerRecord[];
|
|
||||||
policyHash: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoredAnalysisProposal {
|
|
||||||
version: 1;
|
|
||||||
proposalId: string;
|
|
||||||
createdAt: string;
|
|
||||||
fromSequence: number;
|
|
||||||
toSequence: number;
|
|
||||||
policyHash: string;
|
|
||||||
proposal: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialState(): LedgerState {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
completedSequence: 0,
|
|
||||||
lastAnalyzedSequence: 0,
|
|
||||||
proposalSequence: 0,
|
|
||||||
analysisPending: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hash(value: unknown): string {
|
|
||||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function bounded(value: string): string {
|
|
||||||
return value.length <= MAX_PATTERN_LENGTH
|
|
||||||
? value
|
|
||||||
: `${value.slice(0, MAX_PATTERN_LENGTH - 1)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAFE_SUBCOMMAND_COMMANDS = new Set([
|
|
||||||
"bun", "cargo", "docker", "git", "go", "kubectl", "npm", "pnpm",
|
|
||||||
"sudo", "systemctl", "yarn",
|
|
||||||
]);
|
|
||||||
const SAFE_PATH_PREFIXES = new Set([
|
|
||||||
"app", "apps", "config", "docs", "etc", "lib", "opt", "packages",
|
|
||||||
"private", "src", "test", "tests", "tmp", "Users", "var", "workspace",
|
|
||||||
"workspaces",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function normalizedExecutable(value: string): string {
|
|
||||||
const executable = value.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "";
|
|
||||||
return /^[A-Za-z0-9._+-]{1,64}$/u.test(executable) ? executable : "<command>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCommand(command: string): string {
|
|
||||||
const words = command.trim().replace(/\s+/g, " ").split(" ").filter(Boolean);
|
|
||||||
if (words.length === 0) return "<empty-command>";
|
|
||||||
const executable = normalizedExecutable(words[0]!);
|
|
||||||
const subcommand = words[1];
|
|
||||||
if (
|
|
||||||
subcommand !== undefined &&
|
|
||||||
SAFE_SUBCOMMAND_COMMANDS.has(executable) &&
|
|
||||||
/^[A-Za-z0-9._+-]{1,64}$/u.test(subcommand)
|
|
||||||
) {
|
|
||||||
return bounded(`${executable} ${subcommand} *`);
|
|
||||||
}
|
|
||||||
return bounded(words.length === 1 ? executable : `${executable} *`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(value: string): string {
|
|
||||||
const normalized = value.trim().replace(/\\/g, "/");
|
|
||||||
if (normalized.length === 0) return "<empty-path>";
|
|
||||||
if (normalized === "~" || normalized.startsWith("~/")) return "~/*";
|
|
||||||
const absolute = normalized.startsWith("/");
|
|
||||||
const segments = normalized.split("/").filter(Boolean);
|
|
||||||
const first = segments[0];
|
|
||||||
if (first && SAFE_PATH_PREFIXES.has(first)) {
|
|
||||||
return bounded(`${absolute ? "/" : ""}${first}/*`);
|
|
||||||
}
|
|
||||||
if (!absolute && segments.length === 1) {
|
|
||||||
const extension = normalized.match(/\.[A-Za-z0-9]{1,10}$/u)?.[0];
|
|
||||||
if (extension) return `*${extension.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
return absolute ? "/<path>/*" : "<relative>/*";
|
|
||||||
}
|
|
||||||
|
|
||||||
function derivePattern(details: PromptPermissionDetails): string {
|
|
||||||
if (typeof details.command === "string") return normalizeCommand(details.command);
|
|
||||||
if (typeof details.path === "string") return normalizePath(details.path);
|
|
||||||
const values = details.accessIntent?.matchValues;
|
|
||||||
if (Array.isArray(values) && typeof values[0] === "string") return normalizePath(values[0]);
|
|
||||||
if (details.toolName) return bounded(details.toolName);
|
|
||||||
return bounded(details.surface ?? "unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJson<T>(path: string): T | undefined {
|
|
||||||
if (!existsSync(path)) return undefined;
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AutoReviewLedger {
|
|
||||||
private readonly ledgerDir: string;
|
|
||||||
private readonly proposalsDir: string;
|
|
||||||
private readonly statePath: string;
|
|
||||||
private readonly decisionsPath: string;
|
|
||||||
private state: LedgerState;
|
|
||||||
|
|
||||||
constructor(rootDir: string) {
|
|
||||||
this.ledgerDir = join(rootDir, "ledger");
|
|
||||||
this.proposalsDir = join(rootDir, "proposals");
|
|
||||||
this.statePath = join(rootDir, "state.json");
|
|
||||||
this.decisionsPath = join(this.ledgerDir, "decisions.jsonl");
|
|
||||||
for (const dir of [rootDir, this.ledgerDir, this.proposalsDir]) {
|
|
||||||
mkdirSync(dir, { recursive: true, mode: DIRECTORY_MODE });
|
|
||||||
try { chmodSync(dir, DIRECTORY_MODE); } catch { /* best effort on non-POSIX */ }
|
|
||||||
}
|
|
||||||
this.state = readJson<LedgerState>(this.statePath) ?? initialState();
|
|
||||||
this.persistState();
|
|
||||||
}
|
|
||||||
|
|
||||||
recordDecision(event: {
|
|
||||||
details: PromptPermissionDetails;
|
|
||||||
assessment: ReviewAssessment;
|
|
||||||
policyRevision: string;
|
|
||||||
config: AutoReviewConfig;
|
|
||||||
analysis: AutoReviewAnalysisConfig;
|
|
||||||
}): AutoReviewLedgerRecord {
|
|
||||||
const sequence = this.state.completedSequence + 1;
|
|
||||||
const pattern = derivePattern(event.details);
|
|
||||||
const surface = bounded(event.details.accessIntent?.surface ?? event.details.surface ?? "unknown");
|
|
||||||
const toolName = event.details.toolName ? bounded(event.details.toolName) : undefined;
|
|
||||||
const record: AutoReviewLedgerRecord = {
|
|
||||||
version: 1,
|
|
||||||
sequence,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
requestId: bounded(event.details.requestId),
|
|
||||||
source: bounded(event.details.source),
|
|
||||||
agentName: bounded(event.details.agentName ?? "main"),
|
|
||||||
surface,
|
|
||||||
...(toolName ? { toolName } : {}),
|
|
||||||
normalizedPattern: pattern,
|
|
||||||
fingerprint: hash({ surface, toolName, pattern }),
|
|
||||||
outcome: event.assessment.outcome,
|
|
||||||
riskLevel: event.assessment.riskLevel,
|
|
||||||
userAuthorization: event.assessment.userAuthorization,
|
|
||||||
authorizationDependent: event.assessment.userAuthorization !== "unknown",
|
|
||||||
policyRevision: bounded(event.policyRevision),
|
|
||||||
policyHash: hash(event.config),
|
|
||||||
provider: bounded(event.config.provider),
|
|
||||||
model: bounded(event.config.model),
|
|
||||||
};
|
|
||||||
appendFileSync(this.decisionsPath, `${JSON.stringify(record)}\n`, {
|
|
||||||
encoding: "utf8",
|
|
||||||
mode: FILE_MODE,
|
|
||||||
});
|
|
||||||
try { chmodSync(this.decisionsPath, FILE_MODE); } catch { /* best effort */ }
|
|
||||||
|
|
||||||
this.state.completedSequence = sequence;
|
|
||||||
if (
|
|
||||||
event.analysis.enabled &&
|
|
||||||
sequence - this.state.lastAnalyzedSequence >= event.analysis.threshold
|
|
||||||
) {
|
|
||||||
this.state.analysisPending = true;
|
|
||||||
}
|
|
||||||
this.persistState();
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasPendingAnalysis(): boolean {
|
|
||||||
return this.state.analysisPending;
|
|
||||||
}
|
|
||||||
|
|
||||||
prepareBatch(config: AutoReviewAnalysisConfig): AutoReviewAnalysisBatch | undefined {
|
|
||||||
const records = this.readRecords();
|
|
||||||
const fromSequence = this.state.lastAnalyzedSequence + 1;
|
|
||||||
const eligible = records.filter((record) => record.sequence >= fromSequence);
|
|
||||||
if (eligible.length < config.threshold) return undefined;
|
|
||||||
const selected = eligible.slice(0, config.threshold);
|
|
||||||
const policyHash = selected[0]?.policyHash ?? "";
|
|
||||||
return {
|
|
||||||
fromSequence,
|
|
||||||
toSequence: selected.at(-1)!.sequence,
|
|
||||||
records: selected,
|
|
||||||
policyHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
saveProposal(batch: AutoReviewAnalysisBatch, proposal: unknown): StoredAnalysisProposal {
|
|
||||||
const next = this.state.proposalSequence + 1;
|
|
||||||
const proposalId = `proposal-${String(next).padStart(6, "0")}`;
|
|
||||||
const stored: StoredAnalysisProposal = {
|
|
||||||
version: 1,
|
|
||||||
proposalId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
fromSequence: batch.fromSequence,
|
|
||||||
toSequence: batch.toSequence,
|
|
||||||
policyHash: batch.policyHash,
|
|
||||||
proposal,
|
|
||||||
};
|
|
||||||
this.writeAtomic(join(this.proposalsDir, `${proposalId}.json`), stored);
|
|
||||||
this.state.proposalSequence = next;
|
|
||||||
this.state.lastAnalyzedSequence = batch.toSequence;
|
|
||||||
this.state.analysisPending =
|
|
||||||
this.state.completedSequence - this.state.lastAnalyzedSequence > 0;
|
|
||||||
this.persistState();
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
|
|
||||||
compact(config: AutoReviewAnalysisConfig): void {
|
|
||||||
const keep = Math.max(config.threshold, config.threshold * config.retainAnalyzedBatches);
|
|
||||||
const records = this.readRecords();
|
|
||||||
const retained = records.slice(-keep);
|
|
||||||
const content = retained.map((record) => JSON.stringify(record)).join("\n");
|
|
||||||
this.writeAtomic(this.decisionsPath, content ? `${content}\n` : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private readRecords(): AutoReviewLedgerRecord[] {
|
|
||||||
if (!existsSync(this.decisionsPath)) return [];
|
|
||||||
return readFileSync(this.decisionsPath, "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.flatMap((line) => {
|
|
||||||
try { return [JSON.parse(line) as AutoReviewLedgerRecord]; }
|
|
||||||
catch { return []; }
|
|
||||||
})
|
|
||||||
.sort((left, right) => left.sequence - right.sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
private persistState(): void {
|
|
||||||
this.writeAtomic(this.statePath, this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeAtomic(path: string, value: unknown): void {
|
|
||||||
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
||||||
const content = typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`;
|
|
||||||
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
|
|
||||||
try {
|
|
||||||
renameSync(temporaryPath, path);
|
|
||||||
try { chmodSync(path, FILE_MODE); } catch { /* best effort */ }
|
|
||||||
} catch (error) {
|
|
||||||
try { unlinkSync(temporaryPath); } catch { /* best effort */ }
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +1,48 @@
|
|||||||
import type { AutoReviewConfig } from './config.js'
|
import type { AutoReviewConfig } from './config.js'
|
||||||
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
|
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
|
||||||
import type { ModelRegistry } from '@earendil-works/pi-coding-agent'
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent'
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from './config.js'
|
|
||||||
|
|
||||||
export type ReviewModelRegistry = Pick<ModelRegistry, 'find' | 'getAll' | 'getApiKeyAndHeaders' | 'getProvider'>
|
export type ReviewModelRegistry = Pick<ModelRegistry, 'find' | 'getAll' | 'getApiKeyAndHeaders' | 'getProvider'>
|
||||||
|
|
||||||
interface ResolvedReviewModel {
|
export interface ResolvedReviewModel {
|
||||||
model: Model<Api>
|
model: Model<Api>
|
||||||
provider: Provider<Api>
|
provider: Provider<Api>
|
||||||
synthesized: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolveReviewModelFailure =
|
||||||
|
| 'model-unconfigured'
|
||||||
|
| 'provider-unresolved'
|
||||||
|
| 'model-unresolved'
|
||||||
|
|
||||||
export type ResolveReviewModelResult =
|
export type ResolveReviewModelResult =
|
||||||
| { ok: true; value: ResolvedReviewModel }
|
| { ok: true; value: ResolvedReviewModel }
|
||||||
| {
|
| { ok: false; category: ResolveReviewModelFailure }
|
||||||
ok: false
|
|
||||||
category: 'provider-unresolved' | 'model-unresolved'
|
/**
|
||||||
|
* Resolve the reviewer model from operator configuration alone.
|
||||||
|
*
|
||||||
|
* There is no built-in provider/model pair and no synthesized stand-in: the
|
||||||
|
* reviewer runs exactly the model named in `autoReview.provider` /
|
||||||
|
* `autoReview.model`. A configuration that names neither, names an unknown
|
||||||
|
* provider, or names a model the provider does not publish resolves to a
|
||||||
|
* failure, and the reviewer defers the ask to the human prompt.
|
||||||
|
*/
|
||||||
|
export function resolveReviewModel(registry: ReviewModelRegistry, config: AutoReviewConfig): ResolveReviewModelResult {
|
||||||
|
const providerId = config.provider
|
||||||
|
const modelId = config.model
|
||||||
|
if (providerId === undefined || modelId === undefined) {
|
||||||
|
return { ok: false, category: 'model-unconfigured' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCodexTemplate(registry: ReviewModelRegistry, provider: Provider<Api>): Model<Api> | undefined {
|
const provider = registry.getProvider(providerId)
|
||||||
return (
|
|
||||||
registry.getAll().find(model => model.provider === DEFAULT_PROVIDER && model.api === 'openai-codex-responses') ??
|
|
||||||
provider.getModels().find(model => model.api === 'openai-codex-responses')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveReviewModel(registry: ReviewModelRegistry, config: AutoReviewConfig): ResolveReviewModelResult {
|
|
||||||
const provider = registry.getProvider(config.provider)
|
|
||||||
if (provider === undefined) {
|
if (provider === undefined) {
|
||||||
return { ok: false, category: 'provider-unresolved' }
|
return { ok: false, category: 'provider-unresolved' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const registeredModel = registry.find(config.provider, config.model)
|
const model = registry.find(providerId, modelId)
|
||||||
if (registeredModel !== undefined) {
|
if (model === undefined) {
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: { model: registeredModel, provider, synthesized: false },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.provider !== DEFAULT_PROVIDER || config.model !== DEFAULT_MODEL) {
|
|
||||||
return { ok: false, category: 'model-unresolved' }
|
return { ok: false, category: 'model-unresolved' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = findCodexTemplate(registry, provider)
|
return { ok: true, value: { model, provider } }
|
||||||
if (template === undefined) {
|
|
||||||
return { ok: false, category: 'model-unresolved' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
model: {
|
|
||||||
...template,
|
|
||||||
id: DEFAULT_MODEL,
|
|
||||||
name: 'Codex Auto Review',
|
|
||||||
reasoning: true,
|
|
||||||
input: ['text'],
|
|
||||||
},
|
|
||||||
provider,
|
|
||||||
synthesized: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const FAILURE_EVENT = 'auto_review.failure'
|
|||||||
const CIRCUIT_OPEN_EVENT = 'auto_review.circuit_open'
|
const CIRCUIT_OPEN_EVENT = 'auto_review.circuit_open'
|
||||||
|
|
||||||
type FailureCategory =
|
type FailureCategory =
|
||||||
|
| 'model-unconfigured'
|
||||||
| 'provider-unresolved'
|
| 'provider-unresolved'
|
||||||
| 'model-unresolved'
|
| 'model-unresolved'
|
||||||
| 'auth-unresolved'
|
| 'auth-unresolved'
|
||||||
@@ -35,12 +36,6 @@ export interface ReviewerRuntime {
|
|||||||
sessionManager: Pick<SessionManager, 'getBranch'>
|
sessionManager: Pick<SessionManager, 'getBranch'>
|
||||||
circuitBreaker: DenialCircuitBreaker
|
circuitBreaker: DenialCircuitBreaker
|
||||||
sessionSignal?: AbortSignal
|
sessionSignal?: AbortSignal
|
||||||
onDecision?: (event: {
|
|
||||||
details: PromptPermissionDetails
|
|
||||||
assessment: ReviewAssessment
|
|
||||||
durationMs: number
|
|
||||||
policyRevision: string
|
|
||||||
}) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewerDependencies {
|
export interface ReviewerDependencies {
|
||||||
@@ -154,7 +149,7 @@ function buildStreamOptions(
|
|||||||
if (auth.env !== undefined) {
|
if (auth.env !== undefined) {
|
||||||
options.env = auth.env
|
options.env = auth.env
|
||||||
}
|
}
|
||||||
if (reasoning && runtime.config.reasoning !== 'off') {
|
if (reasoning && runtime.config.reasoning !== undefined && runtime.config.reasoning !== 'off') {
|
||||||
options.reasoning = runtime.config.reasoning
|
options.reasoning = runtime.config.reasoning
|
||||||
}
|
}
|
||||||
return options
|
return options
|
||||||
@@ -358,16 +353,6 @@ export function createPermissionReviewer(
|
|||||||
durationMs,
|
durationMs,
|
||||||
...contextDiagnostics,
|
...contextDiagnostics,
|
||||||
})
|
})
|
||||||
try {
|
|
||||||
runtime.onDecision?.({
|
|
||||||
details,
|
|
||||||
assessment,
|
|
||||||
durationMs,
|
|
||||||
policyRevision: POLICY_REVISION,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Audit/analysis persistence must never change the permission verdict.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assessment.outcome === 'allow') {
|
if (assessment.outcome === 'allow') {
|
||||||
runtime.circuitBreaker.recordNonDenial()
|
runtime.circuitBreaker.recordNonDenial()
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
ExtensionAPI,
|
ExtensionContext,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { ConfigReader } from "../config-store";
|
import type { ConfigReader } from "../config-store";
|
||||||
import { getAnalysisStateDir } from "../config-paths";
|
|
||||||
import type { PermissionsService } from "../service";
|
import type { PermissionsService } from "../service";
|
||||||
import type { ReviewLogger } from "../session-logger";
|
import { DEFAULT_AUTO_REVIEW_CONFIG } from "./config.js";
|
||||||
import { runAutoReviewAnalysisAgent } from "./analysis-agent.js";
|
|
||||||
import {
|
|
||||||
DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
|
|
||||||
DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
} from "./config.js";
|
|
||||||
import { DenialCircuitBreaker } from "./circuit-breaker.js";
|
import { DenialCircuitBreaker } from "./circuit-breaker.js";
|
||||||
import { AutoReviewLedger } from "./ledger.js";
|
|
||||||
import { createPermissionReviewer } from "./reviewer.js";
|
import { createPermissionReviewer } from "./reviewer.js";
|
||||||
|
|
||||||
const AUTHORIZER_NAME = "auto-review";
|
const AUTHORIZER_NAME = "auto-review";
|
||||||
const ANALYSIS_RETRY_BACKOFF_MS = 60_000;
|
|
||||||
|
|
||||||
interface SessionRuntime {
|
interface SessionRuntime {
|
||||||
registry: ModelRegistry;
|
registry: ModelRegistry;
|
||||||
@@ -26,40 +18,38 @@ interface SessionRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoReviewLifecycleController {
|
export interface AutoReviewLifecycleController {
|
||||||
handleSessionStart(context: {
|
handleSessionStart(ctx: ExtensionContext): void;
|
||||||
modelRegistry: ModelRegistry;
|
|
||||||
sessionManager: Pick<SessionManager, "getBranch">;
|
|
||||||
}): void;
|
|
||||||
handleTurnStart(): void;
|
handleTurnStart(): void;
|
||||||
handleAgentSettled(): Promise<void>;
|
|
||||||
handleSessionShutdown(): void;
|
handleSessionShutdown(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Warn once per session when AutoReview is active but names no usable model. */
|
||||||
|
function warnWhenUnconfigured(config: ConfigReader, ctx: ExtensionContext): void {
|
||||||
|
const active = config.current();
|
||||||
|
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
||||||
|
if (!autoReview.enabled) return;
|
||||||
|
if (!(active.authorizerChain ?? []).includes(AUTHORIZER_NAME)) return;
|
||||||
|
if (autoReview.provider !== undefined && autoReview.model !== undefined) return;
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.notify(
|
||||||
|
"AutoReview is enabled but autoReview.provider / autoReview.model are not both set, so automatic review cannot run. Every ask will go to the human permission prompt. Set both in the global policy.jsonc to enable automatic review.",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function installBuiltinAutoReview(options: {
|
export function installBuiltinAutoReview(options: {
|
||||||
pi: ExtensionAPI;
|
|
||||||
service: PermissionsService;
|
service: PermissionsService;
|
||||||
config: ConfigReader;
|
config: ConfigReader;
|
||||||
logger: ReviewLogger;
|
|
||||||
agentDir: string;
|
|
||||||
/** Test seam; production always uses the isolated AgentSession implementation. */
|
|
||||||
runAnalysis?: typeof runAutoReviewAnalysisAgent;
|
|
||||||
}): AutoReviewLifecycleController {
|
}): AutoReviewLifecycleController {
|
||||||
const { pi, service, config, logger, agentDir } = options;
|
const { service, config } = options;
|
||||||
const analyze = options.runAnalysis ?? runAutoReviewAnalysisAgent;
|
|
||||||
const circuitBreaker = new DenialCircuitBreaker();
|
const circuitBreaker = new DenialCircuitBreaker();
|
||||||
const stateDir = getAnalysisStateDir(agentDir);
|
|
||||||
const ledger = new AutoReviewLedger(stateDir);
|
|
||||||
let runtime: SessionRuntime | undefined;
|
let runtime: SessionRuntime | undefined;
|
||||||
let analysisRunning = false;
|
|
||||||
let analysisGeneration = 0;
|
|
||||||
let nextAnalysisAttemptAt = 0;
|
|
||||||
|
|
||||||
service.registerAuthorizer(
|
service.registerAuthorizer(
|
||||||
AUTHORIZER_NAME,
|
AUTHORIZER_NAME,
|
||||||
async (details, query, log) => {
|
async (details, query, log) => {
|
||||||
const active = config.current();
|
const active = config.current();
|
||||||
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
||||||
const analysis = active.analysis ?? DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG;
|
|
||||||
if (!autoReview.enabled || runtime === undefined) return { kind: "defer" };
|
if (!autoReview.enabled || runtime === undefined) return { kind: "defer" };
|
||||||
const reviewer = createPermissionReviewer({
|
const reviewer = createPermissionReviewer({
|
||||||
config: autoReview,
|
config: autoReview,
|
||||||
@@ -67,110 +57,28 @@ export function installBuiltinAutoReview(options: {
|
|||||||
sessionManager: runtime.sessionManager,
|
sessionManager: runtime.sessionManager,
|
||||||
circuitBreaker,
|
circuitBreaker,
|
||||||
sessionSignal: runtime.controller.signal,
|
sessionSignal: runtime.controller.signal,
|
||||||
onDecision: (event) => {
|
|
||||||
ledger.recordDecision({
|
|
||||||
details: event.details,
|
|
||||||
assessment: event.assessment,
|
|
||||||
policyRevision: event.policyRevision,
|
|
||||||
config: autoReview,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return reviewer(details, query, log);
|
return reviewer(details, query, log);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const runPendingAnalysis = async (): Promise<void> => {
|
|
||||||
if (analysisRunning || runtime === undefined || !ledger.hasPendingAnalysis()) return;
|
|
||||||
if (Date.now() < nextAnalysisAttemptAt) return;
|
|
||||||
const active = config.current();
|
|
||||||
const autoReview = active.autoReview ?? DEFAULT_AUTO_REVIEW_CONFIG;
|
|
||||||
const analysis = active.analysis ?? DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG;
|
|
||||||
if (!analysis.enabled) return;
|
|
||||||
|
|
||||||
const batch = ledger.prepareBatch(analysis);
|
|
||||||
if (!batch) return;
|
|
||||||
const activeRuntime = runtime;
|
|
||||||
const generation = ++analysisGeneration;
|
|
||||||
const isCurrent = (): boolean =>
|
|
||||||
runtime === activeRuntime &&
|
|
||||||
analysisGeneration === generation &&
|
|
||||||
!activeRuntime.controller.signal.aborted;
|
|
||||||
analysisRunning = true;
|
|
||||||
try {
|
|
||||||
const proposal = await analyze({
|
|
||||||
batch,
|
|
||||||
registry: activeRuntime.registry,
|
|
||||||
autoReview,
|
|
||||||
analysis,
|
|
||||||
stateDir,
|
|
||||||
agentDir,
|
|
||||||
signal: activeRuntime.controller.signal,
|
|
||||||
});
|
|
||||||
if (!isCurrent()) return;
|
|
||||||
pi.sendMessage(
|
|
||||||
{
|
|
||||||
customType: "permission-policy-analysis-ready",
|
|
||||||
content: [
|
|
||||||
`Permission policy analysis is ready for automatic review decisions ${batch.fromSequence}-${batch.toSequence}.`,
|
|
||||||
"Present these suggestions to the user in this conversation. Do not edit either policy file until the user explicitly selects and confirms rules.",
|
|
||||||
JSON.stringify(proposal),
|
|
||||||
].join("\n\n"),
|
|
||||||
display: true,
|
|
||||||
details: {
|
|
||||||
fromSequence: batch.fromSequence,
|
|
||||||
toSequence: batch.toSequence,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ deliverAs: "followUp", triggerTurn: true },
|
|
||||||
);
|
|
||||||
if (!isCurrent()) return;
|
|
||||||
const stored = ledger.saveProposal(batch, proposal);
|
|
||||||
ledger.compact(analysis);
|
|
||||||
logger.review("auto_review.analysis_completed", {
|
|
||||||
proposalId: stored.proposalId,
|
|
||||||
fromSequence: stored.fromSequence,
|
|
||||||
toSequence: stored.toSequence,
|
|
||||||
allowCandidates: proposal.allowCandidates.length,
|
|
||||||
denyCandidates: proposal.denyCandidates.length,
|
|
||||||
hardGuardCandidates: proposal.hardGuardCandidates.length,
|
|
||||||
});
|
|
||||||
nextAnalysisAttemptAt = 0;
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCurrent()) return;
|
|
||||||
nextAnalysisAttemptAt = Date.now() + ANALYSIS_RETRY_BACKOFF_MS;
|
|
||||||
logger.review("auto_review.analysis_failed", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (analysisGeneration === generation) analysisRunning = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleSessionStart(context): void {
|
handleSessionStart(ctx): void {
|
||||||
runtime?.controller.abort();
|
runtime?.controller.abort();
|
||||||
analysisGeneration += 1;
|
|
||||||
analysisRunning = false;
|
|
||||||
runtime = {
|
runtime = {
|
||||||
registry: context.modelRegistry,
|
registry: ctx.modelRegistry,
|
||||||
sessionManager: context.sessionManager,
|
sessionManager: ctx.sessionManager,
|
||||||
controller: new AbortController(),
|
controller: new AbortController(),
|
||||||
};
|
};
|
||||||
circuitBreaker.resetTurn();
|
circuitBreaker.resetTurn();
|
||||||
|
warnWhenUnconfigured(config, ctx);
|
||||||
},
|
},
|
||||||
handleTurnStart(): void {
|
handleTurnStart(): void {
|
||||||
circuitBreaker.resetTurn();
|
circuitBreaker.resetTurn();
|
||||||
},
|
},
|
||||||
async handleAgentSettled(): Promise<void> {
|
|
||||||
await runPendingAnalysis();
|
|
||||||
},
|
|
||||||
handleSessionShutdown(): void {
|
handleSessionShutdown(): void {
|
||||||
runtime?.controller.abort();
|
runtime?.controller.abort();
|
||||||
runtime = undefined;
|
runtime = undefined;
|
||||||
analysisGeneration += 1;
|
|
||||||
analysisRunning = false;
|
|
||||||
circuitBreaker.resetTurn();
|
circuitBreaker.resetTurn();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -254,13 +254,13 @@ export function mergeUnifiedConfigs(
|
|||||||
) as ShellToolsConfig;
|
) as ShellToolsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in AutoReview and analyzer objects merge field-by-field so a project
|
// The built-in AutoReview object merges field-by-field so a project may
|
||||||
// may narrow runtime settings without erasing global defaults.
|
// narrow runtime settings without erasing global defaults.
|
||||||
for (const key of ["autoReview", "analysis"] as const) {
|
{
|
||||||
const baseValue = base[key];
|
const baseValue = base.autoReview;
|
||||||
const overrideValue = override[key];
|
const overrideValue = override.autoReview;
|
||||||
if (baseValue !== undefined || overrideValue !== undefined) {
|
if (baseValue !== undefined || overrideValue !== undefined) {
|
||||||
merged[key] = { ...baseValue, ...overrideValue };
|
merged.autoReview = { ...baseValue, ...overrideValue };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ export function getHardGuardConfigPath(agentDir: string): string {
|
|||||||
return join(getGlobalConfigDir(agentDir), "hard-guards.jsonc");
|
return join(getGlobalConfigDir(agentDir), "hard-guards.jsonc");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnalysisStateDir(agentDir: string): string {
|
|
||||||
return join(getGlobalConfigDir(agentDir), "analysis");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function getGlobalLogsDir(agentDir: string): string {
|
export function getGlobalLogsDir(agentDir: string): string {
|
||||||
return join(getGlobalConfigDir(agentDir), "logs");
|
return join(getGlobalConfigDir(agentDir), "logs");
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { autoReviewConfigFileSchema } from "./auto-review/config.ts";
|
||||||
analysisConfigFileSchema,
|
|
||||||
autoReviewConfigFileSchema,
|
|
||||||
} from "./auto-review/config.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for the permission-system config file shape.
|
* Single source of truth for the permission-system config file shape.
|
||||||
@@ -251,10 +248,6 @@ export const unifiedConfigSchema = z
|
|||||||
description:
|
description:
|
||||||
"Built-in model-assisted reviewer used for static-policy review decisions.",
|
"Built-in model-assisted reviewer used for static-policy review decisions.",
|
||||||
}),
|
}),
|
||||||
analysis: analysisConfigFileSchema.optional().meta({
|
|
||||||
description:
|
|
||||||
"Background policy analysis triggered after a configured number of completed automatic reviews.",
|
|
||||||
}),
|
|
||||||
shellTools: shellToolsSchema.optional(),
|
shellTools: shellToolsSchema.optional(),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { mkdirSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
normalizeAutoReviewAnalysisConfig,
|
|
||||||
normalizeAutoReviewConfig,
|
normalizeAutoReviewConfig,
|
||||||
type AutoReviewAnalysisConfig,
|
|
||||||
type AutoReviewConfig,
|
type AutoReviewConfig,
|
||||||
} from "./auto-review/config.js";
|
} from "./auto-review/config.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -38,8 +36,6 @@ export interface PermissionSystemExtensionConfig {
|
|||||||
authorizerChain?: string[];
|
authorizerChain?: string[];
|
||||||
/** Built-in model reviewer configuration. */
|
/** Built-in model reviewer configuration. */
|
||||||
autoReview?: AutoReviewConfig;
|
autoReview?: AutoReviewConfig;
|
||||||
/** Count-based background policy analysis configuration. */
|
|
||||||
analysis?: AutoReviewAnalysisConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
||||||
@@ -82,9 +78,6 @@ export function normalizePermissionSystemConfig(
|
|||||||
if (raw.autoReview !== undefined) {
|
if (raw.autoReview !== undefined) {
|
||||||
result.autoReview = normalizeAutoReviewConfig(raw.autoReview);
|
result.autoReview = normalizeAutoReviewConfig(raw.autoReview);
|
||||||
}
|
}
|
||||||
if (raw.analysis !== undefined) {
|
|
||||||
result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis);
|
|
||||||
}
|
|
||||||
if (raw.forwardingTimeoutMs !== undefined) {
|
if (raw.forwardingTimeoutMs !== undefined) {
|
||||||
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
|
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,11 +243,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const autoReview = installBuiltinAutoReview({
|
const autoReview = installBuiltinAutoReview({
|
||||||
pi,
|
|
||||||
service: permissionsService,
|
service: permissionsService,
|
||||||
config: configStore,
|
config: configStore,
|
||||||
logger,
|
|
||||||
agentDir,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
||||||
@@ -316,9 +313,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|||||||
lifecycle.handleResourcesDiscover(event, ctx),
|
lifecycle.handleResourcesDiscover(event, ctx),
|
||||||
);
|
);
|
||||||
pi.on("turn_start", () => autoReview.handleTurnStart());
|
pi.on("turn_start", () => autoReview.handleTurnStart());
|
||||||
pi.on("agent_settled", () => {
|
|
||||||
void autoReview.handleAgentSettled();
|
|
||||||
});
|
|
||||||
pi.on("session_shutdown", async () => {
|
pi.on("session_shutdown", async () => {
|
||||||
autoReview.handleSessionShutdown();
|
autoReview.handleSessionShutdown();
|
||||||
await lifecycle.handleSessionShutdown();
|
await lifecycle.handleSessionShutdown();
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
|
|
||||||
DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
} from "#src/auto-review/config";
|
|
||||||
import { AutoReviewLedger } from "#src/auto-review/ledger";
|
|
||||||
import type { PromptPermissionDetails } from "#src/service";
|
|
||||||
|
|
||||||
const roots: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function details(requestId: string): PromptPermissionDetails {
|
|
||||||
return {
|
|
||||||
requestId,
|
|
||||||
source: "tool",
|
|
||||||
surface: "bash",
|
|
||||||
toolName: "bash",
|
|
||||||
command: "npm install example",
|
|
||||||
agentName: "main",
|
|
||||||
} as unknown as PromptPermissionDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AutoReviewLedger", () => {
|
|
||||||
it("marks analysis pending only after the configured number of completed verdicts", () => {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), "auto-review-ledger-"));
|
|
||||||
roots.push(root);
|
|
||||||
const ledger = new AutoReviewLedger(root);
|
|
||||||
const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 2 };
|
|
||||||
const assessment = {
|
|
||||||
outcome: "allow" as const,
|
|
||||||
riskLevel: "low" as const,
|
|
||||||
userAuthorization: "high" as const,
|
|
||||||
rationale: "directly authorized",
|
|
||||||
};
|
|
||||||
|
|
||||||
ledger.recordDecision({
|
|
||||||
details: details("request-1"),
|
|
||||||
assessment,
|
|
||||||
policyRevision: "policy-a",
|
|
||||||
config: DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
expect(ledger.hasPendingAnalysis()).toBe(false);
|
|
||||||
expect(ledger.prepareBatch(analysis)).toBeUndefined();
|
|
||||||
|
|
||||||
ledger.recordDecision({
|
|
||||||
details: details("request-2"),
|
|
||||||
assessment,
|
|
||||||
policyRevision: "policy-a",
|
|
||||||
config: DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
expect(ledger.hasPendingAnalysis()).toBe(true);
|
|
||||||
const batch = ledger.prepareBatch(analysis);
|
|
||||||
expect(batch?.fromSequence).toBe(1);
|
|
||||||
expect(batch?.toSequence).toBe(2);
|
|
||||||
expect(batch?.records).toHaveLength(2);
|
|
||||||
expect(batch?.records[0]).not.toHaveProperty("rationale");
|
|
||||||
expect(batch?.records[0].normalizedPattern).toBe("npm install *");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists owner-only state and resumes a pending batch", () => {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), "auto-review-ledger-"));
|
|
||||||
roots.push(root);
|
|
||||||
const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 1 };
|
|
||||||
const ledger = new AutoReviewLedger(root);
|
|
||||||
ledger.recordDecision({
|
|
||||||
details: details("request-1"),
|
|
||||||
assessment: {
|
|
||||||
outcome: "deny",
|
|
||||||
riskLevel: "high",
|
|
||||||
userAuthorization: "unknown",
|
|
||||||
rationale: "unsafe",
|
|
||||||
},
|
|
||||||
policyRevision: "policy-b",
|
|
||||||
config: DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resumed = new AutoReviewLedger(root);
|
|
||||||
expect(resumed.hasPendingAnalysis()).toBe(true);
|
|
||||||
expect(resumed.prepareBatch(analysis)?.records[0].outcome).toBe("deny");
|
|
||||||
expect(statSync(join(root, "state.json")).mode & 0o777).toBe(0o600);
|
|
||||||
expect(readFileSync(join(root, "ledger", "decisions.jsonl"), "utf8")).not.toContain("unsafe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not retain command arguments, secret path segments, or rationale", () => {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), "auto-review-ledger-"));
|
|
||||||
roots.push(root);
|
|
||||||
const ledger = new AutoReviewLedger(root);
|
|
||||||
const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 10 };
|
|
||||||
const assessment = {
|
|
||||||
outcome: "allow" as const,
|
|
||||||
riskLevel: "medium" as const,
|
|
||||||
userAuthorization: "high" as const,
|
|
||||||
rationale: "secret-rationale",
|
|
||||||
};
|
|
||||||
const commandRecord = ledger.recordDecision({
|
|
||||||
details: {
|
|
||||||
requestId: "request-command",
|
|
||||||
source: "tool",
|
|
||||||
agentName: "main",
|
|
||||||
surface: "bash",
|
|
||||||
toolName: "bash",
|
|
||||||
command: "curl https://token.example/?api_key=secret-value",
|
|
||||||
} as unknown as PromptPermissionDetails,
|
|
||||||
assessment,
|
|
||||||
policyRevision: "policy-c",
|
|
||||||
config: DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
const pathRecord = ledger.recordDecision({
|
|
||||||
details: {
|
|
||||||
requestId: "request-path",
|
|
||||||
source: "tool",
|
|
||||||
agentName: "main",
|
|
||||||
surface: "path",
|
|
||||||
toolName: "read",
|
|
||||||
path: "clients/acme-secret/token.txt",
|
|
||||||
} as unknown as PromptPermissionDetails,
|
|
||||||
assessment,
|
|
||||||
policyRevision: "policy-c",
|
|
||||||
config: DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(commandRecord.normalizedPattern).toBe("curl *");
|
|
||||||
expect(pathRecord.normalizedPattern).toBe("<relative>/*");
|
|
||||||
const persisted = readFileSync(join(root, "ledger", "decisions.jsonl"), "utf8");
|
|
||||||
expect(persisted).not.toContain("secret-value");
|
|
||||||
expect(persisted).not.toContain("acme-secret");
|
|
||||||
expect(persisted).not.toContain("secret-rationale");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
|
||||||
|
import { resolveReviewModel, type ReviewModelRegistry } from "#src/auto-review/model";
|
||||||
|
import { DEFAULT_AUTO_REVIEW_CONFIG, type AutoReviewConfig } from "#src/auto-review/config";
|
||||||
|
|
||||||
|
const model: Model<Api> = {
|
||||||
|
id: "reviewer-model",
|
||||||
|
name: "Reviewer Model",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "vendor",
|
||||||
|
baseUrl: "https://example.invalid",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 1000,
|
||||||
|
maxTokens: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
function registryWith(providerId: string | undefined): ReviewModelRegistry {
|
||||||
|
const provider = { id: providerId ?? "vendor", getModels: () => [model] } as unknown as Provider<Api>;
|
||||||
|
return {
|
||||||
|
find: (candidateProvider, modelId) =>
|
||||||
|
candidateProvider === providerId && modelId === model.id ? model : undefined,
|
||||||
|
getAll: () => [model],
|
||||||
|
getProvider: (candidate) => (candidate === providerId ? provider : undefined),
|
||||||
|
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "key" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(overrides: Partial<AutoReviewConfig>): AutoReviewConfig {
|
||||||
|
return { ...DEFAULT_AUTO_REVIEW_CONFIG, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveReviewModel", () => {
|
||||||
|
it("resolves the operator-named model from the registry", () => {
|
||||||
|
const result = resolveReviewModel(
|
||||||
|
registryWith("vendor"),
|
||||||
|
config({ provider: "vendor", model: "reviewer-model" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw new Error("expected success");
|
||||||
|
expect(result.value.model.id).toBe("reviewer-model");
|
||||||
|
expect(result.value.provider.id).toBe("vendor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports model-unconfigured when no provider is named", () => {
|
||||||
|
const result = resolveReviewModel(registryWith("vendor"), config({ model: "reviewer-model" }));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, category: "model-unconfigured" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports model-unconfigured when no model is named", () => {
|
||||||
|
const result = resolveReviewModel(registryWith("vendor"), config({ provider: "vendor" }));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, category: "model-unconfigured" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports model-unconfigured for the default config with no named pair", () => {
|
||||||
|
const result = resolveReviewModel(registryWith("vendor"), DEFAULT_AUTO_REVIEW_CONFIG);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, category: "model-unconfigured" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports provider-unresolved for an unknown provider", () => {
|
||||||
|
const result = resolveReviewModel(
|
||||||
|
registryWith("vendor"),
|
||||||
|
config({ provider: "missing", model: "reviewer-model" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, category: "provider-unresolved" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports model-unresolved for a model the provider does not publish", () => {
|
||||||
|
const result = resolveReviewModel(
|
||||||
|
registryWith("vendor"),
|
||||||
|
config({ provider: "vendor", model: "missing-model" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, category: "model-unresolved" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never synthesizes a model the provider does not publish", () => {
|
||||||
|
// The reviewer used to fall back to a synthesized Codex stand-in. It must
|
||||||
|
// now refuse instead, so the ask defers to the human prompt.
|
||||||
|
const result = resolveReviewModel(
|
||||||
|
registryWith("vendor"),
|
||||||
|
config({ provider: "vendor", model: "codex-auto-review" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,61 +1,15 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ExtensionAPI, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionContext, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
|
||||||
import {
|
import { DEFAULT_AUTO_REVIEW_CONFIG, type AutoReviewConfig } from "#src/auto-review/config";
|
||||||
DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG,
|
|
||||||
DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
} from "#src/auto-review/config";
|
|
||||||
import { AutoReviewLedger } from "#src/auto-review/ledger";
|
|
||||||
import { installBuiltinAutoReview } from "#src/auto-review/runtime";
|
import { installBuiltinAutoReview } from "#src/auto-review/runtime";
|
||||||
import { getAnalysisStateDir } from "#src/config-paths";
|
|
||||||
import type { ConfigReader } from "#src/config-store";
|
import type { ConfigReader } from "#src/config-store";
|
||||||
import type { PermissionsService, PromptPermissionDetails } from "#src/service";
|
import type { PermissionsService } from "#src/service";
|
||||||
import type { ReviewLogger } from "#src/session-logger";
|
|
||||||
|
|
||||||
type AnalysisRunner = NonNullable<
|
function harness(autoReview: AutoReviewConfig, hasUI = true) {
|
||||||
Parameters<typeof installBuiltinAutoReview>[0]["runAnalysis"]
|
const notify = vi.fn();
|
||||||
>;
|
|
||||||
|
|
||||||
const roots: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function seedPending(agentDir: string) {
|
|
||||||
const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 1 };
|
|
||||||
const ledger = new AutoReviewLedger(getAnalysisStateDir(agentDir));
|
|
||||||
ledger.recordDecision({
|
|
||||||
details: {
|
|
||||||
requestId: "request-1",
|
|
||||||
source: "tool",
|
|
||||||
agentName: "main",
|
|
||||||
surface: "bash",
|
|
||||||
toolName: "bash",
|
|
||||||
command: "npm install example",
|
|
||||||
} as unknown as PromptPermissionDetails,
|
|
||||||
assessment: {
|
|
||||||
outcome: "allow",
|
|
||||||
riskLevel: "low",
|
|
||||||
userAuthorization: "high",
|
|
||||||
rationale: "directly authorized",
|
|
||||||
},
|
|
||||||
policyRevision: "policy-a",
|
|
||||||
config: DEFAULT_AUTO_REVIEW_CONFIG,
|
|
||||||
analysis,
|
|
||||||
});
|
|
||||||
return { analysis, ledger };
|
|
||||||
}
|
|
||||||
|
|
||||||
function harness(agentDir: string, analysis: typeof DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, runAnalysis: Parameters<typeof installBuiltinAutoReview>[0]["runAnalysis"]) {
|
|
||||||
const sendMessage = vi.fn();
|
|
||||||
const registerAuthorizer = vi.fn(() => () => {});
|
const registerAuthorizer = vi.fn(() => () => {});
|
||||||
const review = vi.fn();
|
|
||||||
const controller = installBuiltinAutoReview({
|
const controller = installBuiltinAutoReview({
|
||||||
pi: { sendMessage } as unknown as ExtensionAPI,
|
|
||||||
service: { registerAuthorizer } as unknown as PermissionsService,
|
service: { registerAuthorizer } as unknown as PermissionsService,
|
||||||
config: {
|
config: {
|
||||||
current: () => ({
|
current: () => ({
|
||||||
@@ -63,118 +17,75 @@ function harness(agentDir: string, analysis: typeof DEFAULT_AUTO_REVIEW_ANALYSIS
|
|||||||
permissionReviewLog: true,
|
permissionReviewLog: true,
|
||||||
yoloMode: false,
|
yoloMode: false,
|
||||||
doublePressToConfirm: true,
|
doublePressToConfirm: true,
|
||||||
autoReview: DEFAULT_AUTO_REVIEW_CONFIG,
|
authorizerChain: ["auto-review"],
|
||||||
analysis,
|
autoReview,
|
||||||
}),
|
}),
|
||||||
} as ConfigReader,
|
} as ConfigReader,
|
||||||
logger: { review } as unknown as ReviewLogger,
|
|
||||||
agentDir,
|
|
||||||
runAnalysis,
|
|
||||||
});
|
});
|
||||||
controller.handleSessionStart({
|
const ctx = {
|
||||||
|
hasUI,
|
||||||
|
ui: { notify },
|
||||||
modelRegistry: {} as ModelRegistry,
|
modelRegistry: {} as ModelRegistry,
|
||||||
sessionManager: { getBranch: () => [] } as unknown as Pick<SessionManager, "getBranch">,
|
sessionManager: { getBranch: () => [] } as unknown as Pick<SessionManager, "getBranch">,
|
||||||
});
|
} as unknown as ExtensionContext;
|
||||||
return { controller, sendMessage, registerAuthorizer, review };
|
|
||||||
|
controller.handleSessionStart(ctx);
|
||||||
|
return { controller, notify, registerAuthorizer };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("built-in AutoReview lifecycle", () => {
|
describe("built-in AutoReview lifecycle", () => {
|
||||||
it("analyzes a persisted pending checkpoint at agent_settled and follows up in the same session", async () => {
|
it("registers the auto-review authorizer", () => {
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), "auto-review-runtime-"));
|
const { registerAuthorizer } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG });
|
||||||
roots.push(agentDir);
|
|
||||||
const { analysis } = seedPending(agentDir);
|
|
||||||
const runAnalysis = vi.fn<AnalysisRunner>(async () => ({
|
|
||||||
summary: "No unconditional policy changes recommended.",
|
|
||||||
allowCandidates: [],
|
|
||||||
denyCandidates: [],
|
|
||||||
keepReview: [],
|
|
||||||
hardGuardCandidates: [],
|
|
||||||
}));
|
|
||||||
const { controller, sendMessage, registerAuthorizer } = harness(agentDir, analysis, runAnalysis);
|
|
||||||
|
|
||||||
await controller.handleAgentSettled();
|
|
||||||
|
|
||||||
expect(registerAuthorizer).toHaveBeenCalledWith("auto-review", expect.any(Function));
|
expect(registerAuthorizer).toHaveBeenCalledWith("auto-review", expect.any(Function));
|
||||||
expect(runAnalysis).toHaveBeenCalledTimes(1);
|
|
||||||
expect(runAnalysis.mock.calls[0]?.[0].batch).toMatchObject({ fromSequence: 1, toSequence: 1 });
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ customType: "permission-policy-analysis-ready", display: true }),
|
|
||||||
{ deliverAs: "followUp", triggerTurn: true },
|
|
||||||
);
|
|
||||||
expect(new AutoReviewLedger(getAnalysisStateDir(agentDir)).hasPendingAnalysis()).toBe(false);
|
|
||||||
|
|
||||||
await controller.handleAgentSettled();
|
|
||||||
expect(runAnalysis).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the checkpoint pending when background analysis fails", async () => {
|
it("warns once at session start when provider and model are unset", () => {
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), "auto-review-runtime-"));
|
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG });
|
||||||
roots.push(agentDir);
|
|
||||||
const { analysis } = seedPending(agentDir);
|
|
||||||
const runAnalysis = vi.fn<AnalysisRunner>(async () => {
|
|
||||||
throw new Error("analysis unavailable");
|
|
||||||
});
|
|
||||||
const { controller, sendMessage, review } = harness(agentDir, analysis, runAnalysis);
|
|
||||||
|
|
||||||
await controller.handleAgentSettled();
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
|
expect(notify.mock.calls[0]?.[1]).toBe("warning");
|
||||||
expect(sendMessage).not.toHaveBeenCalled();
|
expect(notify.mock.calls[0]?.[0]).toMatch(/provider.*model/iu);
|
||||||
expect(review).toHaveBeenCalledWith("auto_review.analysis_failed", { error: "analysis unavailable" });
|
|
||||||
expect(new AutoReviewLedger(getAnalysisStateDir(agentDir)).hasPendingAnalysis()).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("discards a stale result after session replacement without advancing the checkpoint", async () => {
|
it("stays silent when both provider and model are configured", () => {
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), "auto-review-runtime-"));
|
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG, provider: "vendor", model: "m" });
|
||||||
roots.push(agentDir);
|
|
||||||
const { analysis } = seedPending(agentDir);
|
|
||||||
let resolveAnalysis!: (proposal: Awaited<ReturnType<AnalysisRunner>>) => void;
|
|
||||||
const runAnalysis = vi.fn<AnalysisRunner>(() => new Promise((resolve) => {
|
|
||||||
resolveAnalysis = resolve;
|
|
||||||
}));
|
|
||||||
const { controller, sendMessage, review } = harness(agentDir, analysis, runAnalysis);
|
|
||||||
|
|
||||||
const pending = controller.handleAgentSettled();
|
expect(notify).not.toHaveBeenCalled();
|
||||||
expect(runAnalysis).toHaveBeenCalledTimes(1);
|
});
|
||||||
controller.handleSessionShutdown();
|
|
||||||
|
it("stays silent when AutoReview is disabled", () => {
|
||||||
|
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG, enabled: false });
|
||||||
|
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify without a UI", () => {
|
||||||
|
const { notify } = harness({ ...DEFAULT_AUTO_REVIEW_CONFIG }, false);
|
||||||
|
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent when auto-review is not in the authorizer chain", () => {
|
||||||
|
const notify = vi.fn();
|
||||||
|
const controller = installBuiltinAutoReview({
|
||||||
|
service: { registerAuthorizer: vi.fn(() => () => {}) } as unknown as PermissionsService,
|
||||||
|
config: {
|
||||||
|
current: () => ({
|
||||||
|
debugLog: false,
|
||||||
|
permissionReviewLog: true,
|
||||||
|
yoloMode: false,
|
||||||
|
doublePressToConfirm: true,
|
||||||
|
authorizerChain: [],
|
||||||
|
autoReview: { ...DEFAULT_AUTO_REVIEW_CONFIG },
|
||||||
|
}),
|
||||||
|
} as ConfigReader,
|
||||||
|
});
|
||||||
controller.handleSessionStart({
|
controller.handleSessionStart({
|
||||||
modelRegistry: {} as ModelRegistry,
|
hasUI: true,
|
||||||
sessionManager: { getBranch: () => [] } as unknown as Pick<SessionManager, "getBranch">,
|
ui: { notify },
|
||||||
});
|
} as unknown as ExtensionContext);
|
||||||
resolveAnalysis({
|
|
||||||
summary: "stale proposal",
|
|
||||||
allowCandidates: [],
|
|
||||||
denyCandidates: [],
|
|
||||||
keepReview: [],
|
|
||||||
hardGuardCandidates: [],
|
|
||||||
});
|
|
||||||
await pending;
|
|
||||||
|
|
||||||
expect(sendMessage).not.toHaveBeenCalled();
|
expect(notify).not.toHaveBeenCalled();
|
||||||
expect(review).not.toHaveBeenCalledWith("auto_review.analysis_completed", expect.anything());
|
|
||||||
expect(new AutoReviewLedger(getAnalysisStateDir(agentDir)).hasPendingAnalysis()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not advance the checkpoint when same-session delivery fails", async () => {
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), "auto-review-runtime-"));
|
|
||||||
roots.push(agentDir);
|
|
||||||
const { analysis } = seedPending(agentDir);
|
|
||||||
const runAnalysis = vi.fn<AnalysisRunner>(async () => ({
|
|
||||||
summary: "proposal",
|
|
||||||
allowCandidates: [],
|
|
||||||
denyCandidates: [],
|
|
||||||
keepReview: [],
|
|
||||||
hardGuardCandidates: [],
|
|
||||||
}));
|
|
||||||
const { controller, sendMessage, review } = harness(agentDir, analysis, runAnalysis);
|
|
||||||
sendMessage.mockImplementation(() => {
|
|
||||||
throw new Error("session delivery unavailable");
|
|
||||||
});
|
|
||||||
|
|
||||||
await controller.handleAgentSettled();
|
|
||||||
|
|
||||||
expect(review).toHaveBeenCalledWith("auto_review.analysis_failed", {
|
|
||||||
error: "session delivery unavailable",
|
|
||||||
});
|
|
||||||
expect(new AutoReviewLedger(getAnalysisStateDir(agentDir)).hasPendingAnalysis()).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const SERVING_REGISTRY_KEY = Symbol.for(
|
|||||||
|
|
||||||
/** Events the factory must register a handler for. */
|
/** Events the factory must register a handler for. */
|
||||||
const EXPECTED_HANDLERS = [
|
const EXPECTED_HANDLERS = [
|
||||||
"agent_settled",
|
|
||||||
"before_agent_start",
|
"before_agent_start",
|
||||||
"input",
|
"input",
|
||||||
"resources_discover",
|
"resources_discover",
|
||||||
|
|||||||
@@ -131,6 +131,34 @@ describe("loadUnifiedConfig", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects the removed analysis key so a stale config cannot silently widen", () => {
|
||||||
|
const configPath = join(tempDir, "config.json");
|
||||||
|
writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ analysis: { threshold: 500 }, permission: { "*": "ask" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = loadUnifiedConfig(configPath);
|
||||||
|
expect(result.issues.some((issue) => issue.includes("analysis"))).toBe(true);
|
||||||
|
// Fail-closed: the whole scope config is dropped, so `ask` is the effective
|
||||||
|
// default again rather than any rule the stale file meant to contribute.
|
||||||
|
expect(result.config).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges the autoReview object field-by-field", () => {
|
||||||
|
const merged = mergeUnifiedConfigs(
|
||||||
|
{ autoReview: { enabled: true, provider: "vendor", model: "m" } },
|
||||||
|
{ autoReview: { reasoning: "low" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.autoReview).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
provider: "vendor",
|
||||||
|
model: "m",
|
||||||
|
reasoning: "low",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("strips JSONC comments before parsing", () => {
|
it("strips JSONC comments before parsing", () => {
|
||||||
const configPath = join(tempDir, "config.json");
|
const configPath = join(tempDir, "config.json");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { constants } from "node:fs";
|
|
||||||
import {
|
|
||||||
chmod,
|
|
||||||
open,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
unlink,
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import { basename, dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
/** Semantic hash of the previously installed personal policy snapshot. */
|
|
||||||
export const LEGACY_PERMISSION_POLICY_HASHES = new Set([
|
|
||||||
"9abf020e80fceae8c52de3e8b758822dfc86cb90e9083381e2569dde1e29400a",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function stripJsonComments(text) {
|
|
||||||
let output = "";
|
|
||||||
let inString = false;
|
|
||||||
let escaped = false;
|
|
||||||
let lineComment = false;
|
|
||||||
let blockComment = false;
|
|
||||||
|
|
||||||
for (let index = 0; index < text.length; index += 1) {
|
|
||||||
const character = text[index];
|
|
||||||
const next = text[index + 1];
|
|
||||||
if (lineComment) {
|
|
||||||
if (character === "\n") {
|
|
||||||
lineComment = false;
|
|
||||||
output += character;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (blockComment) {
|
|
||||||
if (character === "*" && next === "/") {
|
|
||||||
blockComment = false;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (inString) {
|
|
||||||
output += character;
|
|
||||||
if (escaped) escaped = false;
|
|
||||||
else if (character === "\\") escaped = true;
|
|
||||||
else if (character === '"') inString = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === '"') {
|
|
||||||
inString = true;
|
|
||||||
output += character;
|
|
||||||
} else if (character === "/" && next === "/") {
|
|
||||||
lineComment = true;
|
|
||||||
index += 1;
|
|
||||||
} else if (character === "/" && next === "*") {
|
|
||||||
blockComment = true;
|
|
||||||
index += 1;
|
|
||||||
} else {
|
|
||||||
output += character;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripTrailingCommas(text) {
|
|
||||||
let output = "";
|
|
||||||
let inString = false;
|
|
||||||
let escaped = false;
|
|
||||||
for (let index = 0; index < text.length; index += 1) {
|
|
||||||
const character = text[index];
|
|
||||||
if (inString) {
|
|
||||||
output += character;
|
|
||||||
if (escaped) escaped = false;
|
|
||||||
else if (character === "\\") escaped = true;
|
|
||||||
else if (character === '"') inString = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === '"') {
|
|
||||||
inString = true;
|
|
||||||
output += character;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === ",") {
|
|
||||||
let lookahead = index + 1;
|
|
||||||
while (/\s/.test(text[lookahead] ?? "")) lookahead += 1;
|
|
||||||
if (text[lookahead] === "}" || text[lookahead] === "]") continue;
|
|
||||||
}
|
|
||||||
output += character;
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJsonc(text, label = "policy.jsonc") {
|
|
||||||
try {
|
|
||||||
return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`${label} is invalid JSONC: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortJson(value) {
|
|
||||||
if (Array.isArray(value)) return value.map(sortJson);
|
|
||||||
if (value !== null && typeof value === "object") {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.keys(value)
|
|
||||||
.sort()
|
|
||||||
.map((key) => [key, sortJson(value[key])]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionPolicySemanticHash(text) {
|
|
||||||
const parsed = parseJsonc(text);
|
|
||||||
const canonical = JSON.stringify(sortJson(parsed));
|
|
||||||
return createHash("sha256").update(canonical).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function backupTimestamp(now) {
|
|
||||||
return now.toISOString().replace(/[:.]/g, "-");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readFileSnapshot(path) {
|
|
||||||
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
||||||
const handle = await open(path, constants.O_RDONLY | noFollow);
|
|
||||||
try {
|
|
||||||
const info = await handle.stat();
|
|
||||||
if (!info.isFile()) throw new Error(`policy path is not a regular file: ${path}`);
|
|
||||||
return { content: await handle.readFile("utf8"), dev: info.dev, ino: info.ino };
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeExclusive(path, content) {
|
|
||||||
const handle = await open(
|
|
||||||
path,
|
|
||||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
||||||
0o600,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await handle.writeFile(content, "utf8");
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
await chmod(path, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function migratePermissionPolicy(
|
|
||||||
policyPath,
|
|
||||||
seedPath,
|
|
||||||
options = {},
|
|
||||||
) {
|
|
||||||
const now = options.now ?? new Date();
|
|
||||||
const legacyHashes = new Set(
|
|
||||||
options.legacyHashes ?? LEGACY_PERMISSION_POLICY_HASHES,
|
|
||||||
);
|
|
||||||
let original;
|
|
||||||
try {
|
|
||||||
original = await readFileSnapshot(policyPath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.code === "ENOENT") return { status: "missing" };
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedContent = await readFile(seedPath, "utf8");
|
|
||||||
parseJsonc(seedContent, "bundled permission policy seed");
|
|
||||||
const currentHash = permissionPolicySemanticHash(original.content);
|
|
||||||
const seedHash = permissionPolicySemanticHash(seedContent);
|
|
||||||
if (currentHash === seedHash) {
|
|
||||||
await chmod(policyPath, 0o600);
|
|
||||||
return { status: "current" };
|
|
||||||
}
|
|
||||||
if (!legacyHashes.has(currentHash)) return { status: "custom" };
|
|
||||||
|
|
||||||
const backupPath = `${policyPath}.bak.${backupTimestamp(now)}`;
|
|
||||||
const temporaryPath = join(
|
|
||||||
dirname(policyPath),
|
|
||||||
`.${basename(policyPath)}.tmp.${process.pid}.${backupTimestamp(now)}`,
|
|
||||||
);
|
|
||||||
await writeExclusive(backupPath, original.content);
|
|
||||||
|
|
||||||
let temporaryCreated = false;
|
|
||||||
try {
|
|
||||||
await writeExclusive(temporaryPath, seedContent);
|
|
||||||
temporaryCreated = true;
|
|
||||||
if (options.beforeCommit) await options.beforeCommit();
|
|
||||||
const current = await readFileSnapshot(policyPath);
|
|
||||||
if (
|
|
||||||
current.dev !== original.dev ||
|
|
||||||
current.ino !== original.ino ||
|
|
||||||
current.content !== original.content
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"permission policy changed during migration; original file was preserved",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await rename(temporaryPath, policyPath);
|
|
||||||
temporaryCreated = false;
|
|
||||||
await chmod(policyPath, 0o600);
|
|
||||||
return { status: "migrated", backupPath };
|
|
||||||
} finally {
|
|
||||||
if (temporaryCreated) await unlink(temporaryPath).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const [policyPath, seedPath] = process.argv.slice(2);
|
|
||||||
if (!policyPath || !seedPath) {
|
|
||||||
console.error(
|
|
||||||
"usage: migrate-permission-policy.mjs <policy.jsonc> <bundled-seed.jsonc>",
|
|
||||||
);
|
|
||||||
process.exitCode = 2;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await migratePermissionPolicy(policyPath, seedPath);
|
|
||||||
console.log(
|
|
||||||
result.status === "migrated"
|
|
||||||
? `migrated:${result.backupPath}`
|
|
||||||
: result.status,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMainModule() {
|
|
||||||
return process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMainModule()) await main();
|
|
||||||
@@ -43,10 +43,6 @@ test("all package extensions load together without global registration conflicts
|
|||||||
packageJson.pi.extensions.includes("./pi-minimal-footer/index.ts"),
|
packageJson.pi.extensions.includes("./pi-minimal-footer/index.ts"),
|
||||||
"the bundle must load the locally maintained minimal footer",
|
"the bundle must load the locally maintained minimal footer",
|
||||||
);
|
);
|
||||||
assert.ok(
|
|
||||||
packageJson.pi.extensions.includes("./pi-extension-codex-fast-mode/index.ts"),
|
|
||||||
"the bundle must load the locally maintained Codex Fast mode extension",
|
|
||||||
);
|
|
||||||
assert.equal(packageJson.dependencies["pi-compaction"], "file:./pi-compaction");
|
assert.equal(packageJson.dependencies["pi-compaction"], "file:./pi-compaction");
|
||||||
assert.ok(packageJson.files.includes("pi-compaction"), "the packed bundle must include maintained pi-compaction source");
|
assert.ok(packageJson.files.includes("pi-compaction"), "the packed bundle must include maintained pi-compaction source");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -126,14 +122,6 @@ test("all package extensions load together without global registration conflicts
|
|||||||
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||||
"ask_user_question must register before Tool Search builds its catalog",
|
"ask_user_question must register before Tool Search builds its catalog",
|
||||||
);
|
);
|
||||||
assert.equal(
|
|
||||||
packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"],
|
|
||||||
"file:./pi-extension-codex-fast-mode",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
packageJson.files.includes("pi-extension-codex-fast-mode"),
|
|
||||||
"the packed bundle must include the locally maintained Codex Fast mode source",
|
|
||||||
);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
packageJson.pi.extensions.includes("./pi-notify/src/index.ts"),
|
packageJson.pi.extensions.includes("./pi-notify/src/index.ts"),
|
||||||
"the bundle must load the locally maintained Kitty notification extension",
|
"the bundle must load the locally maintained Kitty notification extension",
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
LEGACY_PERMISSION_POLICY_HASHES,
|
|
||||||
migratePermissionPolicy,
|
|
||||||
permissionPolicySemanticHash,
|
|
||||||
} from "../scripts/migrate-permission-policy.mjs";
|
|
||||||
|
|
||||||
const knownLegacyHash =
|
|
||||||
"9abf020e80fceae8c52de3e8b758822dfc86cb90e9083381e2569dde1e29400a";
|
|
||||||
|
|
||||||
async function fixture(t: test.TestContext) {
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), "permission-policy-migration-"));
|
|
||||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
||||||
const policyPath = join(directory, "policy.jsonc");
|
|
||||||
const seedPath = join(directory, "seed.jsonc");
|
|
||||||
const seed = `{
|
|
||||||
// bundled policy
|
|
||||||
"permission": {
|
|
||||||
"*": "ask",
|
|
||||||
"read": "allow"
|
|
||||||
}
|
|
||||||
}\n`;
|
|
||||||
await writeFile(seedPath, seed, { mode: 0o600 });
|
|
||||||
return { directory, policyPath, seedPath, seed };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("recognizes the exact previously installed policy snapshot", () => {
|
|
||||||
assert.equal(LEGACY_PERMISSION_POLICY_HASHES.has(knownLegacyHash), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("semantic hashes ignore JSONC comments, whitespace, and object key order", () => {
|
|
||||||
const left = `{
|
|
||||||
// comment
|
|
||||||
"permission": { "read": "allow", "*": "ask" },
|
|
||||||
"analysis": { "enabled": true }
|
|
||||||
}`;
|
|
||||||
const right = `{"analysis":{"enabled":true},"permission":{"*":"ask","read":"allow"}}`;
|
|
||||||
assert.equal(permissionPolicySemanticHash(left), permissionPolicySemanticHash(right));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("migrates only an exact legacy semantic snapshot and creates private files", async (t) => {
|
|
||||||
const { directory, policyPath, seedPath, seed } = await fixture(t);
|
|
||||||
const legacy = `{
|
|
||||||
// formatting does not matter
|
|
||||||
"permission": { "*": "allow", "path": { "**/.env": "deny" } }
|
|
||||||
}\n`;
|
|
||||||
await writeFile(policyPath, legacy, { mode: 0o644 });
|
|
||||||
|
|
||||||
const result = await migratePermissionPolicy(policyPath, seedPath, {
|
|
||||||
legacyHashes: new Set([permissionPolicySemanticHash(legacy)]),
|
|
||||||
now: new Date("2026-09-02T03:04:05.000Z"),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.status, "migrated");
|
|
||||||
assert.equal(await readFile(policyPath, "utf8"), seed);
|
|
||||||
assert.equal((await stat(policyPath)).mode & 0o777, 0o600);
|
|
||||||
assert.ok(result.backupPath);
|
|
||||||
assert.equal(await readFile(result.backupPath!, "utf8"), legacy);
|
|
||||||
assert.equal((await stat(result.backupPath!)).mode & 0o777, 0o600);
|
|
||||||
assert.equal(
|
|
||||||
(await readdir(directory)).some((name) => name.includes(".tmp.")),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("preserves customized policy content", async (t) => {
|
|
||||||
const { policyPath, seedPath } = await fixture(t);
|
|
||||||
const custom = `{"permission":{"*":"deny"}}\n`;
|
|
||||||
await writeFile(policyPath, custom, { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = await migratePermissionPolicy(policyPath, seedPath);
|
|
||||||
|
|
||||||
assert.deepEqual(result, { status: "custom" });
|
|
||||||
assert.equal(await readFile(policyPath, "utf8"), custom);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats the bundled seed as current and repairs its mode", async (t) => {
|
|
||||||
const { policyPath, seedPath, seed } = await fixture(t);
|
|
||||||
await writeFile(policyPath, seed, { mode: 0o644 });
|
|
||||||
|
|
||||||
const result = await migratePermissionPolicy(policyPath, seedPath);
|
|
||||||
|
|
||||||
assert.deepEqual(result, { status: "current" });
|
|
||||||
assert.equal((await stat(policyPath)).mode & 0o777, 0o600);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns missing without creating a policy", async (t) => {
|
|
||||||
const { policyPath, seedPath } = await fixture(t);
|
|
||||||
assert.deepEqual(await migratePermissionPolicy(policyPath, seedPath), {
|
|
||||||
status: "missing",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid current JSONC without replacing it", async (t) => {
|
|
||||||
const { policyPath, seedPath } = await fixture(t);
|
|
||||||
const invalid = "{ invalid\n";
|
|
||||||
await writeFile(policyPath, invalid, { mode: 0o600 });
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
migratePermissionPolicy(policyPath, seedPath),
|
|
||||||
/invalid JSONC/,
|
|
||||||
);
|
|
||||||
assert.equal(await readFile(policyPath, "utf8"), invalid);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aborts if the policy changes before commit", async (t) => {
|
|
||||||
const { directory, policyPath, seedPath } = await fixture(t);
|
|
||||||
const legacy = `{"permission":{"*":"allow"}}\n`;
|
|
||||||
const concurrent = `{"permission":{"*":"deny"}}\n`;
|
|
||||||
await writeFile(policyPath, legacy, { mode: 0o600 });
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
migratePermissionPolicy(policyPath, seedPath, {
|
|
||||||
legacyHashes: new Set([permissionPolicySemanticHash(legacy)]),
|
|
||||||
beforeCommit: () => writeFile(policyPath, concurrent, "utf8"),
|
|
||||||
}),
|
|
||||||
/changed during migration/,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(await readFile(policyPath, "utf8"), concurrent);
|
|
||||||
const entries = await readdir(directory);
|
|
||||||
assert.equal(entries.some((name) => name.includes(".bak.")), true);
|
|
||||||
assert.equal(entries.some((name) => name.includes(".tmp.")), false);
|
|
||||||
});
|
|
||||||
@@ -172,10 +172,6 @@ async function updateFixture(t: test.TestContext, piExitStatus = 0) {
|
|||||||
join(repositoryRoot, "config", "pi-permission-policy.jsonc"),
|
join(repositoryRoot, "config", "pi-permission-policy.jsonc"),
|
||||||
join(updatedBundle, "config", "pi-permission-policy.jsonc"),
|
join(updatedBundle, "config", "pi-permission-policy.jsonc"),
|
||||||
);
|
);
|
||||||
await copyFile(
|
|
||||||
join(repositoryRoot, "scripts", "migrate-permission-policy.mjs"),
|
|
||||||
join(updatedBundle, "scripts", "migrate-permission-policy.mjs"),
|
|
||||||
);
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(bin, "pi"),
|
join(bin, "pi"),
|
||||||
`#!/bin/sh\nprintf 'pi %s\\n' "$*" >> "$MOCK_PI_LOG"\nexit ${piExitStatus}\n`,
|
`#!/bin/sh\nprintf 'pi %s\\n' "$*" >> "$MOCK_PI_LOG"\nexit ${piExitStatus}\n`,
|
||||||
|
|||||||
@@ -142,51 +142,6 @@ migrate_tool_search_settings() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate_permission_policy() {
|
|
||||||
policy_file=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/extensions/pi-permission-system/policy.jsonc
|
|
||||||
if [ ! -e "$policy_file" ]; then
|
|
||||||
say "- Permission System policy.jsonc 尚不存在;扩展将在下次加载时部署当前种子。"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ -z "${updated_bundle_dir:-}" ] || \
|
|
||||||
[ ! -f "$updated_bundle_dir/scripts/migrate-permission-policy.mjs" ] || \
|
|
||||||
[ ! -f "$updated_bundle_dir/config/pi-permission-policy.jsonc" ]; then
|
|
||||||
warn "无法定位升级后组合包中的 Permission System 策略迁移文件,已保留现有配置。"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ! command -v node >/dev/null 2>&1; then
|
|
||||||
warn "缺少 node,无法安全迁移 Permission System 策略,已保留现有配置。"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
permission_migration=$(node \
|
|
||||||
"$updated_bundle_dir/scripts/migrate-permission-policy.mjs" \
|
|
||||||
"$policy_file" \
|
|
||||||
"$updated_bundle_dir/config/pi-permission-policy.jsonc") || {
|
|
||||||
warn "Permission System 策略迁移失败,已保留现有配置。"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
case $permission_migration in
|
|
||||||
migrated:*)
|
|
||||||
permission_backup=${permission_migration#migrated:}
|
|
||||||
say "✓ Permission System 策略已切换到当前种子;旧文件备份:$permission_backup"
|
|
||||||
;;
|
|
||||||
current)
|
|
||||||
say "- Permission System 策略已是当前种子。"
|
|
||||||
;;
|
|
||||||
custom)
|
|
||||||
say "- Permission System 策略包含自定义内容,已原样保留。"
|
|
||||||
;;
|
|
||||||
missing)
|
|
||||||
say "- Permission System policy.jsonc 尚不存在;扩展将在下次加载时部署当前种子。"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
warn "Permission System 策略迁移返回未知状态,已停止受管同步。"
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_zsh_paths() {
|
resolve_zsh_paths() {
|
||||||
oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh}
|
oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh}
|
||||||
zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom}
|
zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom}
|
||||||
@@ -484,7 +439,6 @@ else
|
|||||||
fi
|
fi
|
||||||
select_updated_bundle_config || exit 1
|
select_updated_bundle_config || exit 1
|
||||||
migrate_tool_search_settings || record_update_failure "Tool Search 用户配置"
|
migrate_tool_search_settings || record_update_failure "Tool Search 用户配置"
|
||||||
migrate_permission_policy || record_update_failure "Permission System 用户策略"
|
|
||||||
|
|
||||||
say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。"
|
say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。"
|
||||||
update_kitty || record_update_failure "Kitty"
|
update_kitty || record_update_failure "Kitty"
|
||||||
|
|||||||
Reference in New Issue
Block a user