@andares/pi-toolkits 0.7.3 → 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.
Files changed (35) hide show
  1. package/README.md +78 -6
  2. package/README.zh-CN.md +77 -6
  3. package/package.json +13 -7
  4. package/src/features/ask/index.test.ts +118 -0
  5. package/src/features/ask/index.ts +31 -13
  6. package/src/features/ask/reasons.ts +2 -2
  7. package/src/features/autostart/autostart.test.ts +45 -2
  8. package/src/features/autostart/autostart.ts +4 -2
  9. package/src/features/autostart/index.ts +100 -48
  10. package/src/features/compact/index.ts +3 -3
  11. package/src/features/compact/prompt.ts +2 -2
  12. package/src/features/favorites/constants.ts +3 -3
  13. package/src/features/favorites/patch-cycle.ts +6 -6
  14. package/src/features/favorites/patch-selector.ts +4 -4
  15. package/src/features/permission/constants.test.ts +60 -0
  16. package/src/features/permission/constants.ts +158 -0
  17. package/src/features/permission/index.test.ts +340 -0
  18. package/src/features/permission/index.ts +333 -0
  19. package/src/features/permission/modes.test.ts +66 -0
  20. package/src/features/permission/modes.ts +82 -0
  21. package/src/features/permission/panel.ts +118 -0
  22. package/src/features/permission/paths.test.ts +111 -0
  23. package/src/features/permission/paths.ts +126 -0
  24. package/src/features/permission/policy.test.ts +237 -0
  25. package/src/features/permission/policy.ts +338 -0
  26. package/src/features/permission/prompt.ts +146 -0
  27. package/src/features/permission/session-rules.ts +99 -0
  28. package/src/features/permission/units.test.ts +205 -0
  29. package/src/features/permission/units.ts +995 -0
  30. package/src/features/stash/stash.ts +1 -1
  31. package/src/index.ts +15 -2
  32. package/src/lib/gates.test.ts +171 -0
  33. package/src/lib/gates.ts +153 -0
  34. package/src/lib/model-config.ts +2 -2
  35. package/src/lib/model-picker.ts +2 -2
package/README.md CHANGED
@@ -9,12 +9,12 @@
9
9
  [![npm version](https://img.shields.io/npm/v/@andares/pi-toolkits?label=npm&logo=npm)](https://www.npmjs.com/package/@andares/pi-toolkits)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
11
  [![Package Manager](https://img.shields.io/badge/package%20manager-pnpm-orange?logo=pnpm)](https://pnpm.io)
12
- [![pi >= 0.99](https://img.shields.io/badge/pi-%3E%3D0.99.0%20%3C2.0.0-blueviolet)](https://github.com/earendil-works/pi)
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 `>=0.99.0 <2.0.0` (covering the four most recently released minor lines — 0.99.x / 1.0.x / 1.1.x / 1.2.x; 0.88–0.98 were never released). Every one of the 10 published versions inside that range (0.99.0 → 1.2.0) was compared against the host `dist`: the extension APIs we use carry no breaking differences (1.2.0 turns `before_agent_start` into a discriminated union and adds `reachesTools`; the fields we read are unchanged), and both runtime patch targets used by favorites — `ModelSelectorComponent` / `AgentSession.cycleModel` — are byte-identical, as are the `core/compaction/compaction.js` and `core/usage-totals.js` sources compact-model mirrors. `typecheck` plus the full test suite pass on each of 0.99.0, 0.99.1, 0.99.2, 1.0.0, 1.0.1, 1.0.2, 1.0.3, 1.0.4, 1.1.0 and 1.2.0, and smoke-loading on real pi 1.2.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>
@@ -196,7 +247,7 @@ pi's `/compact` and auto-compact summarize the context **with the current sessio
196
247
  | auto-compact threshold reached | summarized by the compaction model |
197
248
  | context overflow recovery | summarized by the compaction model, retry behavior unchanged |
198
249
 
199
- **Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across every published version of the supported window, 0.99.0 → 1.2.0 — and only the executing model changes; the conversation serializer itself is imported from the host, so it follows whatever the running host does, e.g. 1.2.0 stops feeding assistant thinking back into the summarization request):
250
+ **Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across every published version of the supported window, 1.0.0 → 1.3.0 — and only the executing model changes; the conversation serializer itself is imported from the host, so it follows whatever the running host does, e.g. 1.2.0 stops feeding assistant thinking back into the summarization request):
200
251
 
201
252
  - **Summary format** — the structured Goal / Progress / Key Decisions checkpoint; when a summary already exists, the iterative-update prompt is used so information carries over instead of being lost
202
253
  - **Split behavior** — the `keepRecentTokens` retention policy, and `firstKeptEntryId` / `tokensBefore` passed back unchanged
@@ -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 |
@@ -263,7 +323,7 @@ No file = feature disabled, zero overhead, silently skipped.
263
323
 
264
324
  **Key constraint: extension commands only**. Commands registered via `registerCommand` such as `/add-dir` (pi-add-dir) and `/cd` (pi-cd) work; built-in commands (`/compact`, `/reload`, `/resume`, …) are dispatched in the TUI layer and cannot be triggered from the extension layer; skill commands (`/skill:name`) are an expansion mechanism rather than an execution mechanism. The target extension you depend on (e.g. pi-add-dir) must be installed first.
265
325
 
266
- > **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 0.99.0–1.2.0, see the host-compatibility discipline in AGENTS.md). Pre-validation uses `pi.getCommands()` (accepting only `source: "extension"`). Depends on `smol-toml` (zero-dependency, ~10KB, TOML 1.0).
326
+ > **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.0.0–1.3.0, see the host-compatibility discipline in AGENTS.md). Pre-validation uses `pi.getCommands()` (accepting only `source: "extension"`). Depends on `smol-toml` (zero-dependency, ~10KB, TOML 1.0).
267
327
 
268
328
  ---
269
329
 
@@ -277,6 +337,9 @@ No file = feature disabled, zero overhead, silently skipped.
277
337
  pnpm install # install deps
278
338
  pnpm typecheck # tsc --noEmit
279
339
  pnpm test # vitest (all feature unit tests)
340
+
341
+ pnpm check:keybindings # audit this plugin's keys against the host's own dispatch table
342
+ pnpm check:compat # verify the declared pi range: last 4 minor lines + 8-line forward probe
280
343
  ```
281
344
 
282
345
  ### Project structure
@@ -285,6 +348,7 @@ pnpm test # vitest (all feature unit tests)
285
348
  src/
286
349
  ├── index.ts # entry point: aggregates feature modules
287
350
  ├── lib/ # shared infrastructure (tool-set snapshot/restore,
351
+ │ # gate bus composing tool_call policies,
288
352
  │ # model-key resolution shared by review & compact)
289
353
  └── features/ # feature modules — one directory per feature
290
354
  ├── ask/ # ask mode: command + state machine, bash sandbox,
@@ -296,6 +360,9 @@ src/
296
360
  │ # tests
297
361
  ├── favorites/ # model favorites: selector + cycle patches,
298
362
  │ # persisted store, tests
363
+ ├── permission/ # session-scoped boundary permission: modes, panel,
364
+ │ # bash units, policy, session grants, prompt,
365
+ │ # tests
299
366
  ├── review/ # third-party review: input/command triggers,
300
367
  │ # review-model config store, prompt, tests
301
368
  └── stash/ # prompt stash: hotkey state machine + tests
@@ -310,9 +377,13 @@ pnpm release patch # 0.1.2 → 0.1.3
310
377
  pnpm release minor # 0.1.2 → 0.2.0 (patch zeroed)
311
378
  pnpm release major # 0.1.2 → 1.0.0 (minor + patch zeroed)
312
379
  pnpm release patch --dry-run # preview without changing anything
380
+ pnpm release:github # create/verify the GitHub Release for the current version
381
+ pnpm release:github --audit # list npm versions vs GitHub Releases vs remote tags
313
382
  ```
314
383
 
315
- `release` requires exactly one of `major | minor | patch`; incrementing a higher level zeroes the lower ones. The chain is: **the working tree must be clean (any uncommitted change aborts before anything happens, guaranteeing tag content == published content)** → `typecheck + test` gate → version bump → git commit + `vX.Y.Z` tag (self-check that tag == HEAD and the tree is clean) → `pnpm publish` (with `prepublishOnly` as a second gate) → push only the current branch + the current tag and verify the remote hashes match (if the remote tag differs, GitHub Release creation is skipped).
384
+ `release` requires exactly one of `major | minor | patch`; incrementing a higher level zeroes the lower ones. The chain is: **the working tree must be clean (any uncommitted change aborts before anything happens, guaranteeing tag content == published content)** → `typecheck + test` gate → version bump → git commit + `vX.Y.Z` tag (self-check that tag == HEAD and the tree is clean) → `pnpm publish` (with `prepublishOnly` as a second gate) → push the current branch → push the tag if the remote lacks it, verify the remote tag hash (with bounded retries, because GitHub's ref view lags right after a push) and create the GitHub Release.
385
+
386
+ If that last step is skipped (no `GITHUB_TOKEN`, a failed push, an HTTP error), the version would otherwise stay Release-less forever: the next release's auto-generated notes diff against the previous *published* Release, so the missing version's commits get folded into the next one and GitHub drifts away from npm. `pnpm release:github` is the resume path — it never bumps the version and never touches npm; `--audit` is the drift detector (`--allow-gaps` downgrades a historical gap to a warning).
316
387
 
317
388
  ---
318
389
 
@@ -328,6 +399,7 @@ PRs and issues welcome. A few conventions:
328
399
  - **Built-in patches** (favorites) must stay **version-guarded and idempotent** — verify the target prototype members exist before patching, guard against double-apply, degrade with a `console.warn` instead of breaking the extension
329
400
  - **Built-in prompt sync** (compact) — the summarization prompts in `src/features/compact/prompt.ts` are copied verbatim from pi's built-in compaction; when upgrading the pi dependency, diff `dist/core/compaction/compaction.js` (and `utils.js`) and update the strings + assembly helpers to match, keeping takeover output byte-compatible with stock
330
401
  - **Keybindings** — before picking a new shortcut, check pi defaults + reserved keys, terminal control chars, and OS/terminal-emulator grabs; prefer `ctrl+alt+<letter>` for cross-platform safety. Do not stop at "it works on my machine in the main screen": in `fullscreen` mode (pi's default since 1.0) the alt-screen layer consumes `tui.altScreen.*` **before** dialogs see the key, and on Windows/WSL several of those bindings differ from the Linux defaults. Run `pnpm check:keybindings [candidate...]` (and `pnpm check:keybindings --all` after a pi upgrade) — it probes the installed host's resolved keybinding table with the host's own `matchesKey`. A key free in the host table can still be taken at the machine layer: Windows Terminal binds `ctrl+v` (paste) and `ctrl+shift+f` (find), and third-party Windows tools commonly grab `ctrl+alt+<letter>` (a screenshot tool once took `ctrl+alt+f`) — so try the key for real on the target machine before shipping it.
402
+ - **Host compatibility** — the peer range is a measured statement, not a wish: the four most recently released pi minor lines are supported best-effort, the upper bound encodes the forward probe cap (newest line + 8 minor lines), and the README annotation must name the highest version actually verified. Re-run `pnpm check:compat` whenever the host dependency moves and read its verdict block (it fails if the declared range drifts from the policy). See the pi 宿主兼容 section of AGENTS.md; keybinding collisions found during an upgrade are reported to the maintainer for a decision, never re-keyed silently.
331
403
  - **Quality gate** — `pnpm typecheck && pnpm test` must pass before submitting
332
404
 
333
405
  ---
package/README.zh-CN.md CHANGED
@@ -9,12 +9,12 @@
9
9
  [![npm version](https://img.shields.io/npm/v/@andares/pi-toolkits?label=npm&logo=npm)](https://www.npmjs.com/package/@andares/pi-toolkits)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
11
  [![Package Manager](https://img.shields.io/badge/package%20manager-pnpm-orange?logo=pnpm)](https://pnpm.io)
12
- [![pi >= 0.99](https://img.shields.io/badge/pi-%3E%3D0.99.0%20%3C2.0.0-blueviolet)](https://github.com/earendil-works/pi)
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 `>=0.99.0 <2.0.0`(覆盖最近 4 个中版本线 0.99.x / 1.0.x / 1.1.x / 1.2.x —— 0.88–0.98 从未发布。区间内全部 10 个已发布版本(0.99.0 → 1.2.0)逐版本比对宿主 dist:用到的扩展 API 无破坏性差异(1.2.0 把 `before_agent_start` 改为判别联合类型、新增 `reachesTools`,我们读取的字段未变),favorites 两处运行时 patch 目标 `ModelSelectorComponent` / `AgentSession.cycleModel` 字节级一致,compact-model 镜像所依据的 `core/compaction/compaction.js` 与 `core/usage-totals.js` 同样字节级一致;并在 0.99.0、0.99.1、0.99.2、1.0.0、1.0.1、1.0.2、1.0.3、1.0.4、1.1.0、1.2.0 上逐一通过 typecheck + 全量测试,在真实 pi 1.2.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>
@@ -196,7 +247,7 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
196
247
  | auto-compact 阈值触发 | 压缩模型总结 |
197
248
  | context overflow 恢复 | 压缩模型总结,重试行为不变 |
198
249
 
199
- **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在支持区间内全部已发布版本(0.99.0 → 1.2.0)字节级不变,只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
250
+ **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在支持区间内全部已发布版本(1.0.0 → 1.3.0)字节级不变,只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
200
251
 
201
252
  - **摘要格式** —— Goal / Progress / Key Decisions 结构化 checkpoint;已有摘要时用迭代更新版 prompt,信息延续不丢
202
253
  - **切分行为** —— 保留最近 `keepRecentTokens` 的策略、`firstKeptEntryId` / `tokensBefore` 原样回传,不动
@@ -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 | 是否重放 | 原因 |
@@ -263,7 +323,7 @@ run = [
263
323
 
264
324
  **关键约束:只支持扩展命令**。`/add-dir`(pi-add-dir)、`/cd`(pi-cd)等 `registerCommand` 注册的命令可以;内置命令(`/compact`、`/reload`、`/resume`…)在 TUI 层分发,扩展层触发不了;skill 命令(`/skill:name`)是展开机制而非执行机制。依赖的目标扩展(如 pi-add-dir)需要先安装。
265
325
 
266
- > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 0.99.0–1.2.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
326
+ > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.0.0–1.3.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
267
327
 
268
328
  ---
269
329
 
@@ -277,6 +337,9 @@ run = [
277
337
  pnpm install # 安装依赖
278
338
  pnpm typecheck # tsc --noEmit
279
339
  pnpm test # vitest(全部功能单元测试)
340
+
341
+ pnpm check:keybindings # 用宿主自己的分发表审计本插件的按键
342
+ pnpm check:compat # 校验声明的 pi 区间:最近 4 条中版本线 + 向后 8 条线探针
280
343
  ```
281
344
 
282
345
  ### 项目结构
@@ -285,6 +348,7 @@ pnpm test # vitest(全部功能单元测试)
285
348
  src/
286
349
  ├── index.ts # 入口:聚合各功能模块
287
350
  ├── lib/ # 共享基础设施(工具集快照/恢复、
351
+ │ # 组合 tool_call 策略的闸门总线、
288
352
  │ # review 与 compact 共用的模型 key 解析)
289
353
  └── features/ # 功能模块 —— 一个功能一个目录
290
354
  ├── ask/ # ask 模式:命令 + 状态机、bash 沙箱、
@@ -295,6 +359,8 @@ src/
295
359
  │ # /compact-model、配置存储、prompt、测试
296
360
  ├── favorites/ # 模型收藏:选择器与循环 patch、
297
361
  │ # 持久化存储、测试
362
+ ├── permission/ # 会话级边界权限:模式、面板、bash 拆解、
363
+ │ # 策略、会话授权、确认对话框、测试
298
364
  ├── review/ # 第三方评审:输入/指令触发、
299
365
  │ # 评审模型配置存储、提示词、测试
300
366
  └── stash/ # 提示词暂存:热键状态机 + 测试
@@ -309,9 +375,13 @@ pnpm release patch # 0.1.2 → 0.1.3
309
375
  pnpm release minor # 0.1.2 → 0.2.0 (patch 清零)
310
376
  pnpm release major # 0.1.2 → 1.0.0 (minor + patch 清零)
311
377
  pnpm release patch --dry-run # 预览,不做任何改动
378
+ pnpm release:github # 为当前版本创建/校验 GitHub Release(幂等)
379
+ pnpm release:github --audit # 列出 npm 版本 vs GitHub Release vs 远端 tag
312
380
  ```
313
381
 
314
- `release` 要求且仅要求 `major | minor | patch` 之一;上级递增清零下级。执行链:**工作区必须干净(有未提交变更直接中止,保证 tag 内容 == 发布内容)** → `typecheck + test` 门禁 → 改版本 → git commit + `vX.Y.Z` tag(自检 tag==HEAD 且工作区干净) → `pnpm publish`(`prepublishOnly` 二次门禁) → 只推当前分支 + 当前 tag 并校验远端哈希一致(远端 tag 不一致则跳过 Release 创建)。
382
+ `release` 要求且仅要求 `major | minor | patch` 之一;上级递增清零下级。执行链:**工作区必须干净(有未提交变更直接中止,保证 tag 内容 == 发布内容)** → `typecheck + test` 门禁 → 改版本 → git commit + `vX.Y.Z` tag(自检 tag==HEAD 且工作区干净) → `pnpm publish`(`prepublishOnly` 二次门禁) → 推当前分支 → tag 远端没有就推、有就校验哈希一致(带有限次重试——刚推完 GitHub 的 ref 可见性可能滞后)→ 创建 GitHub Release。
383
+
384
+ 若最后一步被跳过(无 `GITHUB_TOKEN`、push 失败、HTTP 报错),该版本会永远没有 Release:下一版自动生成的 release notes 是**对着上一个已发布的 Release** 算 diff 的,于是缺的那版提交被吞进下一版,GitHub 的版本线就与 npm 对不上。`pnpm release:github` 就是补建/续跑入口——不动版本号、不碰 npm;`--audit` 是差集探测器(`--allow-gaps` 把历史缺口降级为警告)。
315
385
 
316
386
  ---
317
387
 
@@ -327,6 +397,7 @@ pnpm release patch --dry-run # 预览,不做任何改动
327
397
  - **内建 patch**(favorites)必须保持**版本守卫 + 幂等** —— 打 patch 前先确认目标原型成员存在,防止重复应用,失败时用 `console.warn` 降级而不是把扩展搞挂
328
398
  - **内建提示词同步**(compact) —— `src/features/compact/prompt.ts` 里的总结 prompt 逐字抄自 pi 内建压缩;升级 pi 依赖时 diff `dist/core/compaction/compaction.js`(及 `utils.js`),同步字符串与组装辅助函数,保持接管输出与内建字节级一致
329
399
  - **键位** —— 定新快捷键前先查 pi 默认键位 + 保留键、终端控制字符、OS/终端模拟器占用;跨平台安全优先选 `ctrl+alt+<字母>`。**不得只验证「主屏下我这台机器能用」**:自 pi 1.0 起默认 `fullscreen`,alt-screen 层在对话框看到按键**之前**就消费 `tui.altScreen.*`,且 Windows/WSL 上多个绑定与 Linux 默认值不同。用 `pnpm check:keybindings [候选键...]` 核查(pi 升级后跑 `pnpm check:keybindings --all`),它用宿主自己的 `matchesKey` 探测已安装宿主的**已解析**键位表。**宿主表空闲 ≠ 机器层空闲**:Windows Terminal 默认占 `ctrl+v`(粘贴)与 `ctrl+shift+f`(查找),第三方 Windows 工具常绑 `ctrl+alt+<字母>`(截图工具占过 `ctrl+alt+f`)——定键后必须在真机试按,确认按键真的到得了应用。
400
+ - **宿主兼容** —— peer 区间是实测结论而非愿望:最近 4 个已发布中版本线尽力支持,上界编码「向后探 8 条中版本线」的上限,README 必须标注**实测支持到的最高版本**。宿主依赖一动就重跑 `pnpm check:compat`,读它的 verdict 段(区间与策略不一致会直接判失败)。详见 AGENTS.md「pi 宿主兼容」;升级中发现的键位冲突**上报崔总决策**,不得顺手换键。
330
401
  - **质量门禁** —— 提交前必须通过 `pnpm typecheck && pnpm test`
331
402
 
332
403
  ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.7.3",
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,20 +25,24 @@
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
- "@earendil-works/pi-coding-agent": ">=0.99.0 <2.0.0",
34
+ "@earendil-works/pi-coding-agent": ">=1.0.0 <1.12.0",
32
35
  "@earendil-works/pi-tui": "*"
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": {
39
- "@earendil-works/pi-ai": "^1.2.0",
40
- "@earendil-works/pi-coding-agent": "^1.2.0",
41
- "@earendil-works/pi-tui": "^1.2.0",
43
+ "@earendil-works/pi-ai": "^1.3.0",
44
+ "@earendil-works/pi-coding-agent": "^1.3.0",
45
+ "@earendil-works/pi-tui": "^1.3.0",
42
46
  "@types/node": "^26.6.2",
43
47
  "typescript": "^5.6.0",
44
48
  "vitest": "^4.1.10"
@@ -52,6 +56,8 @@
52
56
  "typecheck": "tsc --noEmit",
53
57
  "test": "vitest run",
54
58
  "check:keybindings": "node ./scripts/check-keybindings.mjs",
59
+ "check:compat": "node ./scripts/check-compat.mjs",
60
+ "release:github": "node ./scripts/release-github.mjs",
55
61
  "release": "node ./scripts/publish.mjs",
56
62
  "tag-current": "node ./scripts/tag-current.mjs"
57
63
  }
@@ -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.
@@ -2,9 +2,9 @@
2
2
  * ask feature — actionable rejection messages.
3
3
  *
4
4
  * The `tool_call` hook's `reason` is delivered to the model as the tool result
5
- * (verified against pi 1.2.0: `extensions/runner.js` returns the `{block,
5
+ * (verified against pi 1.3.0: `extensions/runner.js` returns the `{block,
6
6
  * reason}` result unchanged and pi-agent-core's `agent-loop.js` turns it into
7
- * `createErrorToolResult(reason)`; both paths are byte-identical to 1.1.0), so it
7
+ * `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so it
8
8
  * is the best place to name the nearest allowed alternative and stop the model
9
9
  * from retrying variants of a blocked command.
10
10
  */