@andares/pi-toolkits 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,9 +12,9 @@
12
12
  [![pi >= 1.0](https://img.shields.io/badge/pi-%3E%3D1.0.0%20%3C1.12.0-blueviolet)](https://github.com/earendil-works/pi)
13
13
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
14
14
 
15
- In a nutshell: six ready-to-use capabilities — **read-only consult mode** `ask`, **model favorites** `favorites`, **prompt stash** `stash`, **third-party code review** `third-review`, **dedicated compaction model** `compact-model` and **session startup automation** `autostart` — with more to come.
15
+ In a nutshell: seven ready-to-use capabilities — **read-only consult mode** `ask`, **session-scoped boundary permission** `ask-perm`, **model favorites** `favorites`, **prompt stash** `stash`, **third-party code review** `third-review`, **dedicated compaction model** `compact-model` and **session startup automation** `autostart` — with more to come.
16
16
 
17
- **Requirements**: pi `>=1.0.0 <1.12.0` — the four most recently released minor lines (1.0.x / 1.1.x / 1.2.x / 1.3.x) are supported best-effort, and the upper bound encodes the forward probe: on every host release we check up to **8 minor lines** beyond the newest one (now 1.4 … 1.11) and never claim past that cap. **Measured natural compatibility: 1.3.0** (the newest release at the time of writing — nothing newer exists to probe yet). A single command, `pnpm check:compat`, derives that version set from the registry and runs, per version, in a sandbox under the system temp dir with the whole `@earendil-works/*` family pinned to the version under test: install → `tsc --noEmit` → full test suite → keybinding audit → hash-compare of the host `dist` files this package patches or mirrors. Results over 1.0.0 → 1.3.0 (all 8 published versions): typecheck, 289 tests and the keybinding audit pass on every one; the tracked host files drift only in `core/agent-session.js` (1.3.0 changed the tool-restore path; `cycleModel` untouched), `core/keybindings.js` and `core/compaction/utils.js` (both changed in 1.2.0, table semantics for our keys unchanged), and `extensions/types.d.ts` (additive) — `ModelSelectorComponent`, `core/compaction/compaction.js` and `core/usage-totals.js` are byte-identical across the whole window. Smoke-loading on real pi 1.3.0 produces no warnings. Note: pi ≥ 0.99 hosts require `@earendil-works/pi-tui` to be declared in `peerDependencies` as `"*"` (provided by the host); this package declares it as required.
17
+ **Requirements**: pi `>=1.0.0 <1.12.0` — the four most recently released minor lines (1.0.x / 1.1.x / 1.2.x / 1.3.x) are supported best-effort, and the upper bound encodes the forward probe: on every host release we check up to **8 minor lines** beyond the newest one (now 1.4 … 1.11) and never claim past that cap. **Measured natural compatibility: 1.3.0** (the newest release at the time of writing — nothing newer exists to probe yet). A single command, `pnpm check:compat`, derives that version set from the registry and runs, per version, in a sandbox under the system temp dir with the whole `@earendil-works/*` family pinned to the version under test: install → `tsc --noEmit` → full test suite → keybinding audit → hash-compare of the host `dist` files this package patches or mirrors. Results over 1.0.0 → 1.3.0 (all 8 published versions): typecheck, 397 tests and the keybinding audit pass on every one; the tracked host files drift only in `core/agent-session.js` (1.3.0 changed the tool-restore path; `cycleModel` untouched), `core/keybindings.js` and `core/compaction/utils.js` (both changed in 1.2.0, table semantics for our keys unchanged), and `extensions/types.d.ts` (additive) — `ModelSelectorComponent`, `core/compaction/compaction.js` and `core/usage-totals.js` are byte-identical across the whole window. Smoke-loading on real pi 1.3.0 produces no warnings. Note: pi ≥ 0.99 hosts require `@earendil-works/pi-tui` to be declared in `peerDependencies` as `"*"` (provided by the host); this package declares it as required.
18
18
 
19
19
  ---
20
20
 
@@ -48,6 +48,7 @@ After installing, verify in any pi session:
48
48
  - [Installation](#install)
49
49
  - [Features at a glance](#features)
50
50
  - [🛡️ ask — read-only consult mode](#ask)
51
+ - [🔐 permission — session-scoped boundary checks](#permission)
51
52
  - [⭐ favorites — model favorites](#favorites)
52
53
  - [📥 stash — prompt stash](#stash)
53
54
  - [🔎 review — third-party code review](#review)
@@ -66,6 +67,7 @@ After installing, verify in any pi session:
66
67
  | Feature | Entry point | One-line description |
67
68
  | --- | --- | --- |
68
69
  | 🛡️ **ask** read-only consult mode | `/ask` | enter read-only Q&A mode: `write`/`edit`/`powershell` hard-disabled, `bash` runs in a read-only sandbox, `grep`/`find`/`ls` activated |
70
+ | 🔐 **permission** session-scoped boundary checks | `/ask-perm` | four in/out × read/write modes, off by default and never persisted; outside writes ask, credentials are denied, destructive commands always ask; composes with `/ask` |
69
71
  | ⭐ **favorites** model favorites | `ctrl+A` / `ctrl+J` inside `/model` | favorite the models you use most (marked in bold bright yellow), `ctrl+p` cycles only among favorites |
70
72
  | 📥 **stash** prompt stash | `ctrl+alt+y` | a stash slot for one prompt: press to swap, press twice to stash and clear the input box |
71
73
  | 🔎 **review** third-party code review | `/third-review` | summon the configured review model to check + fix the task just finished, focusing on "is this round's requirement implemented / is anything missing / is anything wrong" |
@@ -92,6 +94,55 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
92
94
 
93
95
  ---
94
96
 
97
+ ## 🔐 permission — session-scoped boundary checks
98
+
99
+ <a name="permission"></a>
100
+
101
+ `/ask-perm` turns on boundary checks for the **current session only**. Every piece of state — the on/off flag, the four modes, the session grants — lives in the extension's memory. Nothing is written to disk, the feature is off by default, and it leaves no status behind when disabled. Toggling it on can never silently follow you into the next session.
102
+
103
+ **Enable**
104
+
105
+ - `/ask-perm` — opens the mode panel. The first row applies on Enter; the four rows cycle with space. Cursor starts at the top, so `/ask-perm` + Enter enables the defaults.
106
+ - `/ask-perm allow allow allow ask` — positional form (`in-read in-write out-read out-write`), enables immediately without a dialog. Missing arguments fall back to the defaults. This is the form autostart uses.
107
+ - `/ask-perm` again — turns it off.
108
+
109
+ **Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
110
+
111
+ | Scope | reads | writes / exec |
112
+ | --- | --- | --- |
113
+ | inside the project (working directory + `/add-dir` directories) | `in read` | `in write` |
114
+ | outside the project | `out read` | `out write` |
115
+
116
+ `exec` is folded into `write`: any command that changes state counts as a write.
117
+
118
+ **What is inspected**
119
+
120
+ - **bash** is decomposed into simple commands — `&&`, `||`, `;`, pipes, `$(…)`, backticks, `bash -c`, `eval`, the common indirection wrappers (`sudo`, `env`, `xargs`, `find -exec`, …), heredocs — and judged per command. `cd` folds into the commands after it; the strictest result wins, so `cd /tmp && rm x` cannot ride on the first segment.
121
+ - **path-bearing tools** (`read` / `write` / `edit` / `grep` / `find` / `ls`) are judged by their own path; every other tool is judged by the working directory.
122
+ - **unresolvable input** — a non-literal `cd` or `git -C`, an unknown shell variable in a path, an unterminated heredoc or substitution — is asked about regardless of mode (fail closed), never guessed.
123
+ - **`git -C <dir>`** is resolved like a local `cd`: relative path arguments (and the repository) are judged against `<dir>`, not the working directory.
124
+
125
+ **Answers**: `a` allow once · `s` allow this directory for the session · `d` deny · `f` deny this directory for the session. Session grants are kept in memory as `(read|write, directory)`, matched by containment, and cleared when the mode is turned off or the session ends. A session grant outranks the destructive table — once you have allowed an operation for the session, repeating it does not ask again. A command the parser cannot resolve is granted by its exact text instead, so repeating the same opaque command does not ask again.
126
+
127
+ **Fixed safety tables** (independent of the four modes):
128
+
129
+ - **credentials are denied in every mode** — `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.pi/agent/auth*.json`, `.env` files (templates excepted). Without this, `out read = allow` would hand out `~/.ssh/id_rsa`.
130
+ - **destructive commands always ask** — `rm` of `/`, the home directory, or the working directory; `git reset --hard`; `git clean -f`; `git push --force`; package `publish`; `dd` to a raw device; `mkfs*`; shutdown/reboot; `chmod -R 777`; anything through `sudo`. Scope alone cannot express "destructive".
131
+
132
+ **Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists.
133
+
134
+ **Enable it for every session** (the state itself is never persisted — this is the opt-in):
135
+
136
+ ```toml
137
+ # <project>/.pi/autostart.toml, or <agent-dir>/autostart.toml for all projects
138
+ [command]
139
+ run = ["/ask-perm allow allow allow ask"]
140
+ ```
141
+
142
+ > **Implementation notes**: state is reset on `session_start` and cleared on `session_shutdown`; the footer shows `perm i:A/A o:A/ask` while on (`A` allow, `a` ask, `D` deny). Subagent sessions start with their own state and do not inherit the parent's mode — when child sessions must be gated too, enable it through the project's autostart file. POSIX hosts (Linux / macOS / WSL) are the tested target; native Windows Git-Bash path spellings are approximated, not verified. bash decomposition uses `shell-quote`; unknown variables expand to a sentinel that makes the unit ask instead of silently becoming an empty path. Added directories are read from pi-add-dir's session entry (`add-dir:state`). Heuristic, not a sandbox: project-internal destructive commands are covered by the fixed table, not by a sandbox. One known blind spot: a *read* command's bare filename operand (`cat notes.txt`) is not collected as a target, so with `out read = ask` only absolute-path-shaped read operands are questioned — reads never write, so the four modes' write side is unaffected.
143
+
144
+ ---
145
+
95
146
  ## ⭐ favorites — model favorites
96
147
 
97
148
  <a name="favorites"></a>
@@ -247,6 +298,15 @@ run = [
247
298
 
248
299
  No file = feature disabled, zero overhead, silently skipped.
249
300
 
301
+ **Two sources, executed in order, never merged**:
302
+
303
+ | Source | Path | Trust gate |
304
+ | --- | --- | --- |
305
+ | global (operator defaults) | `<agent-dir>/autostart.toml` (default `~/.pi/agent/autostart.toml`) | none — the file belongs to the operator |
306
+ | project | `<project root>/.pi/autostart.toml` | requires project trust |
307
+
308
+ The global list runs first, so a project can override an earlier setting (for example a later `/ask-perm` with different arguments). Use the global file only when you want the same commands in every project; per-project files remain the recommended default.
309
+
250
310
  **When it runs**:
251
311
 
252
312
  | session_start reason | Replayed | Why |
@@ -288,6 +348,7 @@ pnpm check:compat # verify the declared pi range: last 4 minor lines + 8-
288
348
  src/
289
349
  ├── index.ts # entry point: aggregates feature modules
290
350
  ├── lib/ # shared infrastructure (tool-set snapshot/restore,
351
+ │ # gate bus composing tool_call policies,
291
352
  │ # model-key resolution shared by review & compact)
292
353
  └── features/ # feature modules — one directory per feature
293
354
  ├── ask/ # ask mode: command + state machine, bash sandbox,
@@ -299,6 +360,9 @@ src/
299
360
  │ # tests
300
361
  ├── favorites/ # model favorites: selector + cycle patches,
301
362
  │ # persisted store, tests
363
+ ├── permission/ # session-scoped boundary permission: modes, panel,
364
+ │ # bash units, policy, session grants, prompt,
365
+ │ # tests
302
366
  ├── review/ # third-party review: input/command triggers,
303
367
  │ # review-model config store, prompt, tests
304
368
  └── stash/ # prompt stash: hotkey state machine + tests
package/README.zh-CN.md CHANGED
@@ -12,9 +12,9 @@
12
12
  [![pi >= 1.0](https://img.shields.io/badge/pi-%3E%3D1.0.0%20%3C1.12.0-blueviolet)](https://github.com/earendil-works/pi)
13
13
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
14
14
 
15
- 一句话介绍:目前包含六个开箱即用的功能——**只读咨询模式** `ask`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
15
+ 一句话介绍:目前包含七个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
16
16
 
17
- **版本要求**:pi `>=1.0.0 <1.12.0` —— 最近 4 个中版本线(1.0.x / 1.1.x / 1.2.x / 1.3.x)尽力支持,上界编码「向后探针」的硬上限:宿主每发一版,我们最多向后查 **8 条中版本线**(当前 1.4 … 1.11),再晚不宣称。**实测自然兼容最高 1.3.0**(写此文时的最新版本,更新的还没发布、无物可探)。一条命令 `pnpm check:compat` 从 registry 推导目标集合,逐版本在系统临时目录沙箱中(把整个 `@earendil-works/*` 家族钉在待测版本)跑:安装 → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch/mirror 的宿主 `dist` 文件做哈希比对。1.0.0 → 1.3.0 全部 8 个已发布版本:typecheck、289 个测试、键位审计全绿;被跟踪的宿主文件仅在 `core/agent-session.js`(1.3.0 改了 tool-restore 路径,`cycleModel` 未动)、`core/keybindings.js` 与 `core/compaction/utils.js`(二者都是 1.2.0 变的,我们按键的解析结果不变)、`extensions/types.d.ts`(纯增量)上有差异 —— `ModelSelectorComponent`、`core/compaction/compaction.js` 与 `core/usage-totals.js` 在窗口内字节级不变。真实 pi 1.3.0 冒烟加载无警告。注:pi ≥ 0.99 的宿主要求 `@earendil-works/pi-tui` 声明为 peerDependencies `"*"`(由宿主提供),本包已按要求声明。
17
+ **版本要求**:pi `>=1.0.0 <1.12.0` —— 最近 4 个中版本线(1.0.x / 1.1.x / 1.2.x / 1.3.x)尽力支持,上界编码「向后探针」的硬上限:宿主每发一版,我们最多向后查 **8 条中版本线**(当前 1.4 … 1.11),再晚不宣称。**实测自然兼容最高 1.3.0**(写此文时的最新版本,更新的还没发布、无物可探)。一条命令 `pnpm check:compat` 从 registry 推导目标集合,逐版本在系统临时目录沙箱中(把整个 `@earendil-works/*` 家族钉在待测版本)跑:安装 → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch/mirror 的宿主 `dist` 文件做哈希比对。1.0.0 → 1.3.0 全部 8 个已发布版本:typecheck、397 个测试、键位审计全绿;被跟踪的宿主文件仅在 `core/agent-session.js`(1.3.0 改了 tool-restore 路径,`cycleModel` 未动)、`core/keybindings.js` 与 `core/compaction/utils.js`(二者都是 1.2.0 变的,我们按键的解析结果不变)、`extensions/types.d.ts`(纯增量)上有差异 —— `ModelSelectorComponent`、`core/compaction/compaction.js` 与 `core/usage-totals.js` 在窗口内字节级不变。真实 pi 1.3.0 冒烟加载无警告。注:pi ≥ 0.99 的宿主要求 `@earendil-works/pi-tui` 声明为 peerDependencies `"*"`(由宿主提供),本包已按要求声明。
18
18
 
19
19
  ---
20
20
 
@@ -48,6 +48,7 @@ pi -e ./src/index.ts
48
48
  - [安装](#install)
49
49
  - [功能总览](#features)
50
50
  - [🛡️ ask — 只读咨询模式](#ask)
51
+ - [🔐 permission — 会话级边界权限](#permission)
51
52
  - [⭐ favorites — 模型收藏](#favorites)
52
53
  - [📥 stash — 提示词暂存](#stash)
53
54
  - [🔎 review — 第三方代码评审](#review)
@@ -66,6 +67,7 @@ pi -e ./src/index.ts
66
67
  | 功能 | 入口 | 一句话说明 |
67
68
  | --- | --- | --- |
68
69
  | 🛡️ **ask** 只读咨询模式 | `/ask` | 进入只读问答模式,`write`/`edit`/`powershell` 硬禁用,`bash` 只读沙箱,临时启用 `grep`/`find`/`ls` |
70
+ | 🔐 **permission** 会话级边界权限 | `/ask-perm` | 项目内/外 × 读/写四格模式,默认关闭且从不落盘;项目外写询问、凭据路径硬拒、破坏性命令必问;与 `/ask` 并行组合 |
69
71
  | ⭐ **favorites** 模型收藏 | `/model` 内 `ctrl+A` / `ctrl+J` | 收藏常用模型(加粗亮黄标记),`ctrl+p` 仅在收藏间循环 |
70
72
  | 📥 **stash** 提示词暂存 | `ctrl+alt+y` | 一段提示词的暂存槽,按键交换 / 连按两次暂存并清空输入框 |
71
73
  | 🔎 **review** 第三方代码评审 | `/third-review` | 召唤配置的评审模型,对刚完成的任务做查+修,重点检查「本轮需求实现 / 是否有遗漏 / 是否有错误」 |
@@ -92,6 +94,55 @@ pi -e ./src/index.ts
92
94
 
93
95
  ---
94
96
 
97
+ ## 🔐 permission — 会话级边界权限
98
+
99
+ <a name="permission"></a>
100
+
101
+ `/ask-perm` 只在**当前会话**开启边界检查。开关状态、四项模式、会话授权全部只存在扩展内存里:不落盘、默认关闭、关闭后不留任何状态。开启一次不会悄悄跟到下一个会话。
102
+
103
+ **开启**
104
+
105
+ - `/ask-perm` —— 打开模式面板。顶行按回车即应用,四行用空格循环、↑↓ 移动;光标默认在最上面,所以「输入指令 + 回车」就是按默认值开启
106
+ - `/ask-perm allow allow allow ask` —— 位置参数形式(`in-read in-write out-read out-write`),不弹窗直接开启;没给全的参数回落到默认值。这也是 autostart 使用的形式
107
+ - 再输入一次 `/ask-perm` —— 关闭
108
+
109
+ **默认值**:项目内读/写全放行,项目外读放行,项目外写询问。
110
+
111
+ | 作用域 | 读 | 写 / 执行 |
112
+ | --- | --- | --- |
113
+ | 项目内(工作目录 + `/add-dir` 加入的目录) | `in read` | `in write` |
114
+ | 项目外 | `out read` | `out write` |
115
+
116
+ `exec` 折进 `write`:任何产生变更的执行都算写。
117
+
118
+ **检查方式**
119
+
120
+ - **bash** 先拆成简单命令 —— `&&`、`||`、`;`、管道、`$(…)`、反引号、`bash -c`、`eval`、常见间接包装(`sudo`、`env`、`xargs`、`find -exec`…)、heredoc —— 再逐条判定;`cd` 向前折叠,取最严格的结果,所以 `cd /tmp && rm x` 不会靠第一段蒙混过关
121
+ - **带路径的工具**(`read` / `write` / `edit` / `grep` / `find` / `ls`)按自己的路径判定;其它工具按工作目录判定
122
+ - **无法解析的输入** —— 非字面量 `cd` 或 `git -C`、路径里的未知 shell 变量、未闭合的 heredoc/命令替换 —— 一律询问,不看模式(fail closed),绝不猜测
123
+ - **`git -C <dir>`** 按局部 `cd` 处理:相对路径参数(和仓库本身)按 `<dir>` 判定,而不是工作目录
124
+
125
+ **回答**:`a` 本次允许 · `s` 本会话允许该目录 · `d` 拒绝 · `f` 本会话拒绝该目录。会话授权在内存中按 `(读|写, 目录)` 保存、按包含关系匹配,关闭模式或会话结束时清空;**会话授权优先于破坏性表**——你按了 `s` 之后,重复同一操作不会再弹;解析不了完整路径的命令则按**完整命令文本**授权,重复同一条命令不会再问。
126
+
127
+ **固定安全表**(与四格模式无关):
128
+
129
+ - **凭据路径任何模式都拒绝** —— `~/.ssh`、`~/.gnupg`、`~/.aws`、`~/.pi/agent/auth*.json`、`.env` 文件(模板除外)。没有这一条,默认的「项目外读放行」就等于把 `~/.ssh/id_rsa` 交出去
130
+ - **破坏性命令永远询问** —— 对 `/`、家目录或工作目录的 `rm`;`git reset --hard`;`git clean -f`;`git push --force`;包 `publish`;`dd` 写裸设备;`mkfs*`;关机/重启;`chmod -R 777`;任何经 `sudo` 的命令。作用域本身表达不了「破坏性」
131
+
132
+ **与 `/ask` 的组合**:两者是同一总线上的独立闸门,各自只回答自己的问题 —— ask 回答「会不会产生变更」,permission 回答「有没有越过边界」,任一拦截都生效。所以 ask 模式下凭据仍然读不到,permission 在只读沙箱下也照常工作;两个功能互不需要知道对方存在。
133
+
134
+ **每个会话自动开启**(状态本身不落盘,这就是持久化的替身):
135
+
136
+ ```toml
137
+ # <项目>/.pi/autostart.toml;想对所有项目生效则用 <agent-dir>/autostart.toml
138
+ [command]
139
+ run = ["/ask-perm allow allow allow ask"]
140
+ ```
141
+
142
+ > **实现说明**:状态在 `session_start` 重置、`session_shutdown` 清空;开启时 footer 显示 `perm i:A/A o:A/ask`(`A` allow、`a` ask、`D` deny)。子代理会话有自己的状态、不继承父会话的模式;需要子会话也受控时,通过项目 autostart 再开启一次。实测目标是 POSIX 宿主(Linux/macOS/WSL);原生 Windows Git-Bash 的路径拼写只做了近似,未验证。bash 拆解使用 `shell-quote`;未知变量会展开成哨兵而不是空字符串,使该命令转为询问而不是被悄悄当成空路径。add-dir 目录从 pi-add-dir 的会话条目 `add-dir:state` 读取。启发式,不是沙箱:项目内的破坏性操作由固定表兜底,而不是靠沙箱。已知盲区一处:**读命令的裸文件名参数**(`cat notes.txt`)不被收集为目标,所以把 `out read` 调成 `ask` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
143
+
144
+ ---
145
+
95
146
  ## ⭐ favorites — 模型收藏
96
147
 
97
148
  <a name="favorites"></a>
@@ -247,6 +298,15 @@ run = [
247
298
 
248
299
  无文件 = 功能未启用,零开销、静默跳过。
249
300
 
301
+ **两个来源,按序执行,不做合并**:
302
+
303
+ | 来源 | 路径 | project trust |
304
+ | --- | --- | --- |
305
+ | 全局(操作者默认) | `<agent-dir>/autostart.toml`(默认 `~/.pi/agent/autostart.toml`) | 不需要 —— 文件属于操作者本人 |
306
+ | 项目 | `<项目根>/.pi/autostart.toml` | 需要 |
307
+
308
+ 全局列表先执行,项目列表后执行,因此项目可以覆盖此前的设置(例如后面再执行一条不同参数的 `/ask-perm`)。只有希望所有项目都用同一套命令时才用全局文件;仍然建议以项目级配置为主。
309
+
250
310
  **执行时机**:
251
311
 
252
312
  | session_start reason | 是否重放 | 原因 |
@@ -288,6 +348,7 @@ pnpm check:compat # 校验声明的 pi 区间:最近 4 条中版本线 +
288
348
  src/
289
349
  ├── index.ts # 入口:聚合各功能模块
290
350
  ├── lib/ # 共享基础设施(工具集快照/恢复、
351
+ │ # 组合 tool_call 策略的闸门总线、
291
352
  │ # review 与 compact 共用的模型 key 解析)
292
353
  └── features/ # 功能模块 —— 一个功能一个目录
293
354
  ├── ask/ # ask 模式:命令 + 状态机、bash 沙箱、
@@ -298,6 +359,8 @@ src/
298
359
  │ # /compact-model、配置存储、prompt、测试
299
360
  ├── favorites/ # 模型收藏:选择器与循环 patch、
300
361
  │ # 持久化存储、测试
362
+ ├── permission/ # 会话级边界权限:模式、面板、bash 拆解、
363
+ │ # 策略、会话授权、确认对话框、测试
301
364
  ├── review/ # 第三方评审:输入/指令触发、
302
365
  │ # 评审模型配置存储、提示词、测试
303
366
  └── stash/ # 提示词暂存:热键状态机 + 测试
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.8.0",
4
- "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), model favorites, prompt stash, third-party code review, and a dedicated compaction model",
3
+ "version": "0.9.0",
4
+ "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -25,7 +25,10 @@
25
25
  "compaction",
26
26
  "compact-model",
27
27
  "toolkit",
28
- "autostart"
28
+ "autostart",
29
+ "permission",
30
+ "approval",
31
+ "boundary"
29
32
  ],
30
33
  "peerDependencies": {
31
34
  "@earendil-works/pi-coding-agent": ">=1.0.0 <1.12.0",
@@ -33,6 +36,7 @@
33
36
  },
34
37
  "dependencies": {
35
38
  "@dreki-gg/pi-command-sandbox": "^0.4.3",
39
+ "shell-quote": "^1.12.0",
36
40
  "smol-toml": "^1.9.0"
37
41
  },
38
42
  "devDependencies": {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * ask mode over the gate bus.
3
+ *
4
+ * The feature's command/state behaviour predates the bus; what this covers is
5
+ * the migration itself: while ask mode is on, its gate blocks the tools it
6
+ * confiscates and non-read-only bash, and it stops speaking once the mode is
7
+ * off.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ ToolCallEvent,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { GateHub, ToolGate } from "../../lib/gates.js";
16
+ import { registerAsk } from "./index.js";
17
+
18
+ function event(toolName: string, input: Record<string, unknown> = {}): ToolCallEvent {
19
+ return {
20
+ type: "tool_call",
21
+ toolCallId: "call-1",
22
+ toolName,
23
+ input,
24
+ } as unknown as ToolCallEvent;
25
+ }
26
+
27
+ function makeHarness() {
28
+ const captured: ToolGate[] = [];
29
+ const hub = {
30
+ register(gate: ToolGate) {
31
+ captured.push(gate);
32
+ },
33
+ handle: async () => undefined,
34
+ install: () => {},
35
+ } as unknown as GateHub;
36
+
37
+ let activeTools = ["read", "write", "edit", "bash"];
38
+ let commandHandler: ((args: string, ctx: ExtensionContext) => unknown) | undefined;
39
+ const pi = {
40
+ on: () => {},
41
+ registerCommand(
42
+ name: string,
43
+ options: { handler: (args: string, ctx: ExtensionContext) => unknown },
44
+ ) {
45
+ if (name === "ask") commandHandler = options.handler;
46
+ },
47
+ getActiveTools: () => [...activeTools],
48
+ getAllTools: () => [
49
+ { name: "grep" },
50
+ { name: "find" },
51
+ { name: "ls" },
52
+ ],
53
+ setActiveTools: (names: string[]) => {
54
+ activeTools = [...names];
55
+ },
56
+ } as unknown as ExtensionAPI;
57
+
58
+ const statuses: Array<[string, string | undefined]> = [];
59
+ const notes: string[] = [];
60
+ const ctx = {
61
+ hasUI: true,
62
+ mode: "tui",
63
+ ui: {
64
+ notify: (message: string) => notes.push(message),
65
+ setStatus: (key: string, value: string | undefined) => statuses.push([key, value]),
66
+ theme: { fg: (_color: string, text: string) => text },
67
+ },
68
+ } as unknown as ExtensionContext;
69
+
70
+ registerAsk(pi, hub);
71
+ const gate = captured[0];
72
+ if (!gate || !commandHandler) throw new Error("ask did not register");
73
+
74
+ return {
75
+ gate,
76
+ ctx,
77
+ activeTools: () => activeTools,
78
+ toggle: async () => {
79
+ await commandHandler?.("", ctx);
80
+ },
81
+ evaluate: async (toolName: string, input: Record<string, unknown> = {}) =>
82
+ gate.evaluate(event(toolName, input), ctx),
83
+ };
84
+ }
85
+
86
+ describe("ask gate", () => {
87
+ it("has no opinion while ask mode is off", async () => {
88
+ const h = makeHarness();
89
+ expect(await h.evaluate("write", { path: "x" })).toBeUndefined();
90
+ });
91
+
92
+ it("blocks confiscated tools and non-read-only bash while on", async () => {
93
+ const h = makeHarness();
94
+ await h.toggle();
95
+ expect(await h.evaluate("write", { path: "x" })).toMatchObject({
96
+ kind: "block",
97
+ });
98
+ expect(await h.evaluate("bash", { command: "rm -rf x" })).toMatchObject({
99
+ kind: "block",
100
+ });
101
+ expect(await h.evaluate("bash", { command: "ls -la" })).toBeUndefined();
102
+ });
103
+
104
+ it("stops blocking after exiting", async () => {
105
+ const h = makeHarness();
106
+ await h.toggle();
107
+ await h.toggle();
108
+ expect(await h.evaluate("write", { path: "x" })).toBeUndefined();
109
+ });
110
+
111
+ it("restores the tool snapshot on exit", async () => {
112
+ const h = makeHarness();
113
+ await h.toggle();
114
+ expect(h.activeTools()).not.toContain("write");
115
+ await h.toggle();
116
+ expect(h.activeTools()).toEqual(["read", "write", "edit", "bash"]);
117
+ });
118
+ });
@@ -8,7 +8,10 @@
8
8
  * - Read-only helper tools (grep/find/ls) are activated for the duration so
9
9
  * the model gets proper search tools; the entry snapshot restores exactly.
10
10
  * - bash stays available but is gated by a read-only sandbox
11
- * (see bash-sandbox.ts) via the tool_call hook.
11
+ * (see bash-sandbox.ts) through the shared gate bus (lib/gates.ts), so
12
+ * another feature's gate (`permission`) runs alongside it rather than
13
+ * replacing it: each feature owns its own concern and the call is blocked
14
+ * when either objects.
12
15
  * - Mode banner appended to the system prompt via before_agent_start
13
16
  * chained systemPrompt (see prompt.ts), listing blocked -> alternative.
14
17
  */
@@ -16,6 +19,7 @@ import type {
16
19
  ExtensionAPI,
17
20
  ExtensionContext,
18
21
  } from "@earendil-works/pi-coding-agent";
22
+ import type { GateHub } from "../../lib/gates.js";
19
23
  import {
20
24
  restoreActiveTools,
21
25
  snapshotActiveTools,
@@ -31,7 +35,7 @@ import { isReadOnlyCommand } from "./bash-sandbox.js";
31
35
  import { appendAskBanner } from "./prompt.js";
32
36
  import { blockedBashReason, blockedToolReason } from "./reasons.js";
33
37
 
34
- export function registerAsk(pi: ExtensionAPI): void {
38
+ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
35
39
  let enabled = false;
36
40
  /** Active tools before ask mode was entered (for exact restore on exit). */
37
41
  let snapshot: string[] = [];
@@ -102,20 +106,34 @@ export function registerAsk(pi: ExtensionAPI): void {
102
106
  // - bash is kept available but only read-only commands pass the sandbox.
103
107
  // The rejection `reason` reaches the model as the tool result, so it names
104
108
  // the nearest allowed alternative (see reasons.ts).
105
- pi.on("tool_call", (event) => {
106
- if (!enabled) return;
109
+ //
110
+ // Registered through the gate bus so the permission gate composes with it:
111
+ // ask answers "does this mutate?", permission answers "does this cross the
112
+ // project boundary?". A denied outside read still applies in ask mode, and
113
+ // a blocked write never reaches permission's dialog because a block
114
+ // short-circuits the bus.
115
+ gates.register({
116
+ name: "ask",
117
+ order: 10,
118
+ evaluate(event) {
119
+ if (!enabled) return undefined;
107
120
 
108
- if (ASK_BLOCKED_TOOLS.has(event.toolName)) {
109
- return { block: true, reason: blockedToolReason(event.toolName) };
110
- }
121
+ if (ASK_BLOCKED_TOOLS.has(event.toolName)) {
122
+ return {
123
+ kind: "block",
124
+ reason: blockedToolReason(event.toolName),
125
+ };
126
+ }
111
127
 
112
- if (event.toolName === "bash") {
113
- const command =
114
- typeof event.input.command === "string" ? event.input.command : "";
115
- if (!isReadOnlyCommand(command)) {
116
- return { block: true, reason: blockedBashReason(command) };
128
+ if (event.toolName === "bash") {
129
+ const command =
130
+ typeof event.input.command === "string" ? event.input.command : "";
131
+ if (!isReadOnlyCommand(command)) {
132
+ return { kind: "block", reason: blockedBashReason(command) };
133
+ }
117
134
  }
118
- }
135
+ return undefined;
136
+ },
119
137
  });
120
138
 
121
139
  // Mode banner: chained append to the current system prompt while enabled.
@@ -16,6 +16,7 @@ import {
16
16
  AUTOSTART_CONFIG_PATH,
17
17
  AutostartParseError,
18
18
  commandNameOf,
19
+ GLOBAL_AUTOSTART_FILENAME,
19
20
  parseAutostartConfig,
20
21
  RUNNABLE_REASONS,
21
22
  } from "./autostart.js";
@@ -138,8 +139,16 @@ interface Harness {
138
139
  async function makeHarness(
139
140
  config: string | null,
140
141
  configAsDir = false,
142
+ globalConfig: string | null = null,
141
143
  ): Promise<Harness> {
142
144
  const cwd = await mkdtemp(join(tmpdir(), "pi-autostart-test-"));
145
+ // The agent dir lives inside the temp project so cleanup covers it; passing
146
+ // it explicitly keeps the real ~/.pi/agent out of the test.
147
+ const agentDir = join(cwd, "agent");
148
+ await mkdir(agentDir, { recursive: true });
149
+ if (globalConfig !== null) {
150
+ await writeFile(join(agentDir, GLOBAL_AUTOSTART_FILENAME), globalConfig, "utf8");
151
+ }
143
152
  if (configAsDir) {
144
153
  // EISDIR setup: the config path exists but is a directory.
145
154
  await mkdir(join(cwd, ".pi"), { recursive: true });
@@ -172,7 +181,7 @@ async function makeHarness(
172
181
  }
173
182
  },
174
183
  } as unknown as ExtensionAPI;
175
- registerAutostart(pi);
184
+ registerAutostart(pi, { agentDir });
176
185
  return {
177
186
  handler,
178
187
  sent,
@@ -209,8 +218,9 @@ afterEach(async () => {
209
218
  async function harness(
210
219
  config: string | null,
211
220
  configAsDir = false,
221
+ globalConfig: string | null = null,
212
222
  ): Promise<Harness> {
213
- const h = await makeHarness(config, configAsDir);
223
+ const h = await makeHarness(config, configAsDir, globalConfig);
214
224
  current = h;
215
225
  return h;
216
226
  }
@@ -312,4 +322,37 @@ describe("registerAutostart", () => {
312
322
  expect(h.notifications).toHaveLength(1);
313
323
  expect(h.notifications[0]?.type).toBe("error");
314
324
  });
325
+
326
+ it("runs the global config before the project config", async () => {
327
+ const h = await harness(
328
+ '[command]\nrun = ["/add-dir ../project"]',
329
+ false,
330
+ '[command]\nrun = ["/ask on"]',
331
+ );
332
+ await h.start("startup");
333
+ expect(h.sent.map((entry) => entry.command)).toEqual([
334
+ "/ask on",
335
+ "/add-dir ../project",
336
+ ]);
337
+ expect(h.notifications).toEqual([]);
338
+ });
339
+
340
+ it("runs the global config in an untrusted project but skips the project one", async () => {
341
+ const h = await harness(
342
+ '[command]\nrun = ["/add-dir ../project"]',
343
+ false,
344
+ '[command]\nrun = ["/ask on"]',
345
+ );
346
+ h.setTrusted(false);
347
+ await h.start("startup");
348
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
349
+ expect(h.notifications).toHaveLength(1);
350
+ expect(h.notifications[0]?.type).toBe("warning");
351
+ });
352
+
353
+ it("keeps running the project config when the global one is absent", async () => {
354
+ const h = await harness('[command]\nrun = ["/ask on"]', false, null);
355
+ await h.start("startup");
356
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
357
+ });
315
358
  });
@@ -18,10 +18,12 @@
18
18
  */
19
19
  import { parse as parseToml } from "smol-toml";
20
20
 
21
- /** Project-relative location of the autostart config (`.pi/` is pi's
22
- * project-config convention, precedent: `.pi/mcp.json`). */
21
+ /** Project-relative location of the project autostart config. */
23
22
  export const AUTOSTART_CONFIG_PATH = ".pi/autostart.toml";
24
23
 
24
+ /** File name of the operator's global autostart config, inside the agent dir. */
25
+ export const GLOBAL_AUTOSTART_FILENAME = "autostart.toml";
26
+
25
27
  /**
26
28
  * session_start reasons that replay autostart commands:
27
29
  * - startup / new: fresh session in the project — replay