@andares/pi-toolkits 0.7.2 → 0.7.3

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
@@ -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.87](https://img.shields.io/badge/pi-%3E%3D0.87.0%20%3C2.0.0-blueviolet)](https://github.com/earendil-works/pi)
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)
13
13
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
14
14
 
15
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.
16
16
 
17
- **Requirements**: pi `>=0.87.0 <2.0.0` (covering the four most recently released minor lines — 0.87.x / 0.99.x / 1.0.x / 1.1.x; 0.88–0.98 were never released). Every one of the 11 published versions inside that range (0.87.0 → 1.1.0) was compared against the host `dist`: the extension APIs we use carry no breaking differences (1.0.4 → 1.1.0 only adds fields), and the two runtime patch targets used by favorites — `ModelSelectorComponent` / `AgentSession.cycleModel` — are byte-identical. `typecheck` plus the full test suite pass on each of 0.87.0, 0.87.1, 0.99.0, 0.99.2, 1.0.0, 1.0.4 and 1.1.0, and smoke-loading on real pi 1.1.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 `>=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.
18
18
 
19
19
  ---
20
20
 
@@ -65,7 +65,7 @@ After installing, verify in any pi session:
65
65
 
66
66
  | Feature | Entry point | One-line description |
67
67
  | --- | --- | --- |
68
- | 🛡️ **ask** read-only consult mode | `/ask` | enter read-only Q&A mode: `write`/`edit` hard-disabled, `bash` runs in a read-only sandbox |
68
+ | 🛡️ **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 |
69
69
  | ⭐ **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
70
  | 📥 **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
71
  | 🔎 **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" |
@@ -82,12 +82,13 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
82
82
 
83
83
  **While active**:
84
84
 
85
- - **`write` / `edit` hard-disabled** — removed from the active tool set; pi rejects calls to inactive tools outright ("Tool not found"), so file modification becomes mechanically impossible
86
- - **`bash` kept, but sandboxed to read-only** — based on shell tokenization plus write-intent detection (blocks `curl -o/-O`, `wget -O`, `dd of=`, `tee`, redirections, and the like). Heuristic, not a security boundary
87
- - **System prompt appendix** — a mode description is appended so the model behaves along the read-only constraint
85
+ - **`write` / `edit` / `powershell` hard-disabled** — removed from the active tool set; pi rejects calls to inactive tools outright ("Tool not found"), so file modification becomes mechanically impossible
86
+ - **Read-only helper tools activated** — `grep` / `find` / `ls` (registered by pi but inactive by default) are switched on for the duration, so the model gets proper search tools instead of bash-only search; the entry snapshot is restored exactly on exit
87
+ - **`bash` kept, but sandboxed to read-only** — shell tokenization, per-segment validation plus write-intent detection. Allowed beyond the obvious readers: git archaeology (`blame`, `grep`, `rev-parse`, `describe`, `shortlog`, `reflog show`, `stash list`, `tag -l`, `cat-file`, ...), package metadata queries (`pnpm view`, `pip show/list`, `python -m json.tool`) and network reads through `curl -s <url>` / `wget -O - <url>`. Still blocked: redirections, `tee`, `dd`, `sed -i`, installs/builds, git mutations, command substitution, command runners (`find -exec`, `git grep -O`, `rg --pre`), and every write target (`curl -o`, `wget -O file`, `git diff --output=`, `tree -o`). Heuristic, not a security boundary
88
+ - **System prompt appendix names the alternatives** — the appended mode description states what is blocked *and what to use instead* (`read` for file contents, `curl -s` for URLs, `/ask` to exit). Rejected tool calls come back with a classified reason that points at the nearest allowed alternative, so the model does not retry blind variants
88
89
  - **Grey `ask` footer status** — a permanent reminder that read-only mode is on
89
90
 
90
- **Exit**: press `/ask` again; the tool-set snapshot taken on entry is fully restored.
91
+ **Exit**: press `/ask` again; the tool-set snapshot taken on entry is fully restored (read-only helpers included).
91
92
 
92
93
  ---
93
94
 
@@ -195,7 +196,7 @@ pi's `/compact` and auto-compact summarize the context **with the current sessio
195
196
  | auto-compact threshold reached | summarized by the compaction model |
196
197
  | context overflow recovery | summarized by the compaction model, retry behavior unchanged |
197
198
 
198
- **Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — byte-identical since 0.87.1, verified up to 1.1.0; 0.87.0 shipped an earlier split-turn prompt, and the mirror carries the 0.87.1 fix for every host — and only the executing model changes):
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):
199
200
 
200
201
  - **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
201
202
  - **Split behavior** — the `keepRecentTokens` retention policy, and `firstKeptEntryId` / `tokensBefore` passed back unchanged
@@ -262,7 +263,7 @@ No file = feature disabled, zero overhead, silently skipped.
262
263
 
263
264
  **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.
264
265
 
265
- > **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`; verified against pi 0.87.0–1.1.0 sources, 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).
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).
266
267
 
267
268
  ---
268
269
 
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.87](https://img.shields.io/badge/pi-%3E%3D0.87.0%20%3C2.0.0-blueviolet)](https://github.com/earendil-works/pi)
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)
13
13
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
14
14
 
15
15
  一句话介绍:目前包含六个开箱即用的功能——**只读咨询模式** `ask`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
16
16
 
17
- **版本要求**:pi `>=0.87.0 <2.0.0`(覆盖最近 4 个中版本线 0.87.x / 0.99.x / 1.0.x / 1.1.x —— 0.88–0.98 从未发布。区间内全部 11 个已发布版本(0.87.0 → 1.1.0)逐版本比对宿主 dist:用到的扩展 API 无破坏性差异(1.0.4 → 1.1.0 仅新增字段),favorites 两处运行时 patch 目标 `ModelSelectorComponent` / `AgentSession.cycleModel` 字节级一致;并在 0.87.0、0.87.1、0.99.0、0.99.2、1.0.0、1.0.4、1.1.0 上逐一通过 typecheck + 全量测试,在真实 pi 1.1.0 上冒烟加载无警告)。注:pi ≥ 0.99 的宿主要求 `@earendil-works/pi-tui` 声明为 peerDependencies `"*"`(由宿主提供),本包已按要求声明。
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 `"*"`(由宿主提供),本包已按要求声明。
18
18
 
19
19
  ---
20
20
 
@@ -65,7 +65,7 @@ pi -e ./src/index.ts
65
65
 
66
66
  | 功能 | 入口 | 一句话说明 |
67
67
  | --- | --- | --- |
68
- | 🛡️ **ask** 只读咨询模式 | `/ask` | 进入只读问答模式,`write`/`edit` 硬禁用,`bash` 只读沙箱 |
68
+ | 🛡️ **ask** 只读咨询模式 | `/ask` | 进入只读问答模式,`write`/`edit`/`powershell` 硬禁用,`bash` 只读沙箱,临时启用 `grep`/`find`/`ls` |
69
69
  | ⭐ **favorites** 模型收藏 | `/model` 内 `ctrl+A` / `ctrl+J` | 收藏常用模型(加粗亮黄标记),`ctrl+p` 仅在收藏间循环 |
70
70
  | 📥 **stash** 提示词暂存 | `ctrl+alt+y` | 一段提示词的暂存槽,按键交换 / 连按两次暂存并清空输入框 |
71
71
  | 🔎 **review** 第三方代码评审 | `/third-review` | 召唤配置的评审模型,对刚完成的任务做查+修,重点检查「本轮需求实现 / 是否有遗漏 / 是否有错误」 |
@@ -82,12 +82,13 @@ pi -e ./src/index.ts
82
82
 
83
83
  **进入后**:
84
84
 
85
- - **`write` / `edit` 硬禁用** —— 从活跃工具集中移除,pi 运行时对未激活工具的调用直接拒绝("Tool not found"),文件修改在机制上不可能发生
86
- - **`bash` 保留但只读沙箱** —— 基于 shell 分词 + 写盘意图检测(`curl -o/-O`、`wget -O`、`dd of=`、`tee`、重定向等一律拦截)。启发式,非安全边界
87
- - **系统提示词追加** —— 追加一段模式说明,模型会遵守只读约束行事
85
+ - **`write` / `edit` / `powershell` 硬禁用** —— 从活跃工具集中移除,pi 运行时对未激活工具的调用直接拒绝("Tool not found"),文件修改在机制上不可能发生
86
+ - **临时启用只读检索工具** —— `grep` / `find` / `ls`(宿主已注册但默认不激活)在 ask 期间打开,让模型有正规搜索工具而不是只能走 bash;退出时按进入前快照完整恢复
87
+ - **`bash` 保留但只读沙箱** —— shell 分词 + 逐段校验 + 写盘意图检测。除常见读取命令外,放行 git 考古族(`blame`、`grep`、`rev-parse`、`describe`、`shortlog`、`reflog show`、`stash list`、`tag -l`、`cat-file` 等)、包元数据查询(`pnpm view`、`pip show/list`、`python -m json.tool`),以及网络读取 `curl -s <url>` / `wget -O - <url>`。仍拦截:重定向、`tee`、`dd`、`sed -i`、安装/构建、git 变更类命令、命令替换、会启动其它程序的选项(`find -exec`、`git grep -O`、`rg --pre`),以及一切写盘目标(`curl -o`、`wget -O file`、`git diff --output=`、`tree -o`)。启发式,非安全边界
88
+ - **系统提示词给出替代方案** —— 追加的模式说明不仅列限制,还写明被拦后用什么替代(读文件用 `read`、取网页用 `curl -s`、要改动则 `/ask` 退出);被拒的工具调用返回分类原因,直接指向最近的可用替代,模型不会盲目重试
88
89
  - **灰色 `ask` footer 状态** —— 常驻提示当前处于只读模式
89
90
 
90
- **退出**:再按 `/ask`,完整恢复进入前的工具集快照。
91
+ **退出**:再按 `/ask`,完整恢复进入前的工具集快照(含临时启用的只读工具)。
91
92
 
92
93
  ---
93
94
 
@@ -195,7 +196,7 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
195
196
  | auto-compact 阈值触发 | 压缩模型总结 |
196
197
  | context overflow 恢复 | 压缩模型总结,重试行为不变 |
197
198
 
198
- **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,0.87.1 起字节级不变、已核对至 1.1.0,只换执行模型;0.87.0 的分割轮次前缀 prompt 与 0.87.1 不同,本镜像对所有宿主采用 0.87.1 的修复文本):
199
+ **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在支持区间内全部已发布版本(0.99.0 → 1.2.0)字节级不变,只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
199
200
 
200
201
  - **摘要格式** —— Goal / Progress / Key Decisions 结构化 checkpoint;已有摘要时用迭代更新版 prompt,信息延续不丢
201
202
  - **切分行为** —— 保留最近 `keepRecentTokens` 的策略、`firstKeptEntryId` / `tokensBefore` 原样回传,不动
@@ -262,7 +263,7 @@ run = [
262
263
 
263
264
  **关键约束:只支持扩展命令**。`/add-dir`(pi-add-dir)、`/cd`(pi-cd)等 `registerCommand` 注册的命令可以;内置命令(`/compact`、`/reload`、`/resume`…)在 TUI 层分发,扩展层触发不了;skill 命令(`/skill:name`)是展开机制而非执行机制。依赖的目标扩展(如 pi-add-dir)需要先安装。
264
265
 
265
- > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,已对照 pi 0.87.0–1.1.0 源码核实,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
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)。
266
267
 
267
268
  ---
268
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
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",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "autostart"
29
29
  ],
30
30
  "peerDependencies": {
31
- "@earendil-works/pi-coding-agent": ">=0.87.0 <2.0.0",
31
+ "@earendil-works/pi-coding-agent": ">=0.99.0 <2.0.0",
32
32
  "@earendil-works/pi-tui": "*"
33
33
  },
34
34
  "dependencies": {
@@ -36,9 +36,9 @@
36
36
  "smol-toml": "^1.9.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@earendil-works/pi-ai": "^1.1.0",
40
- "@earendil-works/pi-coding-agent": "^1.1.0",
41
- "@earendil-works/pi-tui": "^1.1.0",
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",
42
42
  "@types/node": "^26.6.2",
43
43
  "typescript": "^5.6.0",
44
44
  "vitest": "^4.1.10"
@@ -2,7 +2,7 @@
2
2
  * Boundary tests for the ask-mode read-only bash sandbox.
3
3
  */
4
4
  import { describe, expect, it } from "vitest";
5
- import { isReadOnlyCommand } from "./bash-sandbox.js";
5
+ import { classifyBlockedCommand, isReadOnlyCommand } from "./bash-sandbox.js";
6
6
 
7
7
  describe("isReadOnlyCommand — allowed (read-only)", () => {
8
8
  const allowed = [
@@ -14,6 +14,10 @@ describe("isReadOnlyCommand — allowed (read-only)", () => {
14
14
  "git status",
15
15
  "git log --oneline -5",
16
16
  "git diff --stat",
17
+ "git show HEAD",
18
+ "git branch -a",
19
+ "git remote -v",
20
+ "git show HEAD 2>/dev/null",
17
21
  "grep -r foo src",
18
22
  "find . -name '*.ts'",
19
23
  "cd src && ls",
@@ -21,7 +25,12 @@ describe("isReadOnlyCommand — allowed (read-only)", () => {
21
25
  "npm list --depth=0",
22
26
  "curl -s https://example.com",
23
27
  "curl -o - https://example.com", // stdout, allowed
28
+ "curl --output - https://example.com", // stdout, allowed
29
+ "curl --output=- https://example.com", // stdout, allowed
24
30
  "wget -O - https://example.com", // stdout, allowed
31
+ "wget -qO- https://example.com", // stdout, allowed
32
+ "wget -O - --quiet https://example.com", // stdout, allowed
33
+ "wget --output-document=- https://example.com", // stdout, allowed
25
34
  "echo 'hello && world'", // quoted operator is data, not control
26
35
  ];
27
36
 
@@ -30,6 +39,61 @@ describe("isReadOnlyCommand — allowed (read-only)", () => {
30
39
  });
31
40
  });
32
41
 
42
+ describe("isReadOnlyCommand — allowed (relaxed read-only families)", () => {
43
+ const allowed = [
44
+ // git archaeology
45
+ "git blame -L 10,20 src/index.ts",
46
+ "git grep -n registerAsk -- src",
47
+ "git rev-parse HEAD",
48
+ "git rev-list --count HEAD",
49
+ "git describe --tags --abbrev=0",
50
+ "git shortlog -sn",
51
+ "git reflog show",
52
+ "git reflog",
53
+ "git stash list",
54
+ "git stash show -p",
55
+ "git worktree list",
56
+ "git tag -l",
57
+ "git tag --list 'v*'",
58
+ "git cat-file -p HEAD",
59
+ "git merge-base HEAD~5 HEAD",
60
+ "git check-ignore -v node_modules",
61
+ "git config --list",
62
+ // destructive-substring false positives in arguments
63
+ "git log --grep=rm --oneline",
64
+ "git blame src/rm-utils.ts",
65
+ "git log --grep=rm --oneline && ls src",
66
+ // search arguments that merely *mention* a guarded flag are data
67
+ "grep -rn 'find -exec' src",
68
+ "git grep -n 'find -exec' -- .",
69
+ "git log --grep='curl -so x' --oneline",
70
+ "rg 'sed -i' .",
71
+ // package metadata
72
+ "pnpm view vite version",
73
+ "pnpm info react",
74
+ "pnpm search eslint",
75
+ "pip show requests",
76
+ "pip list",
77
+ "pip3 freeze",
78
+ "uv pip list",
79
+ "python3 -m json.tool package.json",
80
+ // guards must not over-block plain readers
81
+ "fd -e ts",
82
+ "sort package.json",
83
+ "curl -so- https://example.com", // clustered short form, stdout
84
+ "git grep --textconv -n foo", // read path, not the -O pager
85
+ "env", // bare env prints the environment
86
+ "env -i",
87
+ "env FOO=bar", // assignment-only form, no command
88
+ "file README.md",
89
+ "sed -n '1p' README.md",
90
+ ];
91
+
92
+ it.each(allowed)("allows: %s", (command) => {
93
+ expect(isReadOnlyCommand(command)).toBe(true);
94
+ });
95
+ });
96
+
33
97
  describe("isReadOnlyCommand — blocked (mutating/destructive)", () => {
34
98
  const blocked = [
35
99
  "rm -rf /",
@@ -40,19 +104,67 @@ describe("isReadOnlyCommand — blocked (mutating/destructive)", () => {
40
104
  "touch foo",
41
105
  "npm install",
42
106
  "npm run build",
107
+ "pnpm install",
108
+ "pnpm add left-pad",
109
+ "pip install requests",
43
110
  "git commit -m x",
44
111
  "git push",
45
112
  "git checkout main",
113
+ "git reset --hard",
114
+ "git stash drop",
115
+ "git stash pop",
116
+ "git worktree remove x",
117
+ "git reflog expire --all",
118
+ "git tag v1",
119
+ "git tag -d v1",
120
+ "git branch new-branch",
121
+ "git branch -m old new",
122
+ "git branch -D old",
123
+ "git remote add origin git@example.com:x/y.git",
124
+ "git remote remove origin",
46
125
  "cat x > out.txt",
47
126
  "echo hi >> log",
48
127
  "echo $(rm -rf /)", // command substitution
128
+ "git log --grep=$(rm -rf /)", // substitution hidden in an allowed verb
49
129
  "ls | rm -rf /", // pipeline with destructive command
130
+ "git log --grep=rm --oneline && rm -rf /", // second segment is a write
131
+ "find . -name x -exec rm {} \\;", // find stays out of the read-only bypass
132
+ "find . -delete", // -delete is a write
133
+ "ls | find . -delete", // ... and the pre-check sees pipelines too
134
+ "find . -name '*.ts' -exec sh -c 'echo x > /tmp/y' \\;", // -exec runs commands
135
+ "ls | find . -name x -exec echo {} \\;", // exec intent outside the first segment
136
+ "fd . -x rm", // fd -x runs commands: never on the bypass list
137
+ "fd . -x touch y",
138
+ "fd --exec rm {}",
139
+ "sort -o out.txt file", // sort's output flag is a write
140
+ "sort file --output=out.txt",
141
+ "curl -so /tmp/x https://example.com", // clustered short form writes
142
+ "curl -o/tmp/x https://example.com", // attached short form writes
143
+ "curl -sLo /tmp/x https://example.com",
144
+ "curl --remote-name https://example.com",
145
+ "sed -n -i 's/a/b/' f", // -i sneaks past the `sed -n` allowlist
146
+ "tree -o /tmp/x",
147
+ "tree --output=/tmp/x",
148
+ "file -C -m magic",
149
+ "git grep -O 'rm -rf /' pattern", // -O runs a pager command
150
+ "git grep --open-files-in-pager=less pattern",
151
+ "git log --ext-diff -p", // external diff driver
152
+ "env curl -o out.txt https://example.com", // env forwards to a write
153
+ "env -i curl -o out.txt https://example.com",
154
+ "env FOO=1 curl -o out.txt https://example.com",
155
+ "ls && env curl -o out.txt https://example.com",
156
+ "env -S 'sh -c x'",
157
+ "env find . -delete",
158
+ "env sed -i 's/a/b/' f",
50
159
  "curl -o file https://example.com",
51
160
  "curl -O https://example.com/x",
52
161
  "curl --output file https://example.com",
53
162
  "wget -O file https://example.com",
54
163
  "wget --output-document file https://example.com",
55
- "wget -qO- https://example.com", // sandbox rejects (only wget -O - is safe); fine, be strict
164
+ "wget https://example.com",
165
+ "git diff --output=out.txt", // real write path on a read-only verb
166
+ "git log --output out.txt",
167
+ "git show -o out.txt",
56
168
  "dd if=/dev/zero of=/tmp/x bs=1 count=1",
57
169
  "echo x | tee file",
58
170
  "vim README.md",
@@ -64,3 +176,22 @@ describe("isReadOnlyCommand — blocked (mutating/destructive)", () => {
64
176
  expect(isReadOnlyCommand(command)).toBe(false);
65
177
  });
66
178
  });
179
+
180
+ describe("classifyBlockedCommand", () => {
181
+ it.each([
182
+ ["echo $(id)", "substitution"],
183
+ ["cat a > b", "redirect"],
184
+ ["git grep -O x", "exec"],
185
+ ["env curl -o out.txt https://example.com", "exec"],
186
+ ["find . -exec echo {} ;", "exec"],
187
+ ["rm -rf /", "write"],
188
+ ["find . -delete", "write"],
189
+ ["curl -O https://example.com", "write"],
190
+ ["wget https://example.com", "download"],
191
+ ["git commit -m x", "git"],
192
+ ["pnpm install", "install"],
193
+ ["vim README.md", "unlisted"],
194
+ ] as const)("classifies %s as %s", (command, kind) => {
195
+ expect(classifyBlockedCommand(command)).toBe(kind);
196
+ });
197
+ });
@@ -5,35 +5,239 @@
5
5
  * per-segment validation (&& | ; etc.), command-substitution rejection and
6
6
  * deny-by-default for unmodeled operators.
7
7
  *
8
- * On top of that, this wrapper adds write-intent detection for known blind
9
- * spots: allowlisted commands whose arguments can write files (curl -o/-O,
10
- * wget -O, dd of=, tee). Heuristic only — not a security boundary.
8
+ * This wrapper adds four things on top:
9
+ * 1. write-intent detection for known blind spots — allowlisted commands
10
+ * whose arguments can still write files (curl/wget output targets, dd of=,
11
+ * tee, sed -i, sort -o, tree -o, find -delete, the git diff family's
12
+ * --output).
13
+ * 2. exec-intent detection — read-by-name commands whose options run another
14
+ * program (git grep -O, git --ext-diff, find -exec/-ok, fd -x, rg --pre).
15
+ * 3. `extraSafe` patterns for read-only commands the upstream allowlist does
16
+ * not model: git archaeology verbs, package metadata queries,
17
+ * `python -m json.tool`, wget's stdout form.
18
+ * 4. a per-segment read-only bypass for DESTRUCTIVE_PATTERNS that match only
19
+ * a command's *arguments* (`git log --grep=rm`, `git blame src/rm-utils.ts`)
20
+ * — see READ_ONLY_COMMAND_PATTERNS.
21
+ *
22
+ * Heuristic only — not a security boundary.
11
23
  */
12
- import { isSafeCommand } from "@dreki-gg/pi-command-sandbox";
24
+ import {
25
+ hasCommandSubstitution,
26
+ isSafeCommand,
27
+ parseCommandSegments,
28
+ } from "@dreki-gg/pi-command-sandbox";
29
+
30
+ /* -------------------------------------------------------------------------- */
31
+ /* Write intent */
32
+ /* -------------------------------------------------------------------------- */
13
33
 
34
+ /**
35
+ * Commands that are safe by name but whose arguments can write files.
36
+ * Patterns are anchored at the command position and checked per shell segment,
37
+ * so grep/rg *search arguments* mentioning a flag (`grep -rn "curl -so"`) are
38
+ * data, not intent. Stdout forms stay explicitly allowed: `-o -` / `-O -` /
39
+ * `-qO-` / `--output -` / `--output=-` / `--output-document=-`.
40
+ */
14
41
  const WRITE_INTENT_PATTERNS: RegExp[] = [
15
- // curl --output FILE
16
- /\bcurl\b[^|;&]*\s--output\b/i,
17
- // curl -o FILE (curl -o - / -o- = stdout, allowed)
18
- /\bcurl\b[^|;&]*\s-o\s+(?!-)\S+/,
19
- // curl -O (uppercase: save to disk under remote name)
20
- /\bcurl\b[^|;&]*\s-O\b/,
21
- // wget --output-document FILE
22
- /\bwget\b[^|;&]*\s--output-document\b/i,
42
+ // curl --output FILE (curl --output - / --output=- = stdout, allowed)
43
+ /^\s*curl\b[^|;&]*\s--output(?:=|\s+)(?!-)\S+/i,
44
+ // curl -o FILE / -oFILE / -so FILE: short form, attached or clustered
45
+ // (`-o -` / `-o-` / `-so-` are stdout and stay allowed)
46
+ /^\s*curl\b[^|;&]*\s-[a-zA-Z]*o(?:\s+(?!-)\S+|(?!-)\S+)/,
47
+ // curl -O / -sO / --remote-name: always saves to disk
48
+ /^\s*curl\b[^|;&]*\s-[a-zA-Z]*O\b/,
49
+ /^\s*curl\b[^|;&]*\s--remote-name\b/i,
50
+ // wget --output-document FILE (--output-document - = stdout, allowed)
51
+ /^\s*wget\b[^|;&]*\s--output-document(?:=|\s+)(?!-)\S+/i,
23
52
  // wget -O FILE (wget -O - / -qO- = stdout, allowed)
24
- /\bwget\b[^|;&]*\s-O\s+(?!-)\S+/,
53
+ /^\s*wget\b[^|;&]*\s-O\s+(?!-)\S+/,
25
54
  // dd of=FILE
26
- /\bdd\b[^|;&]*\sof=/i,
55
+ /^\s*dd\b[^|;&]*\sof=/i,
27
56
  // tee FILE (writes to stdout AND file)
28
- /\btee\b/i,
57
+ /^\s*tee\b/i,
58
+ // sed -i / -ni: in-place edits; only `sed -n` is otherwise allowlisted
59
+ /^\s*sed\b[^|;&]*\s-[a-zA-Z]*i(?:\s|$)/i,
60
+ /^\s*sed\b[^|;&]*\s--in-place\b/i,
61
+ // sort -o FILE / --output FILE
62
+ /^\s*sort\b[^|;&]*\s(?:-o\s|--output(?:=|\s+))/i,
63
+ // tree -o FILE / --output FILE (short form may be clustered: -ao FILE)
64
+ /^\s*tree\b[^|;&]*\s(?:-[a-zA-Z]*o(?:\s|$)|--output(?:=|\s+))/i,
65
+ // file -C / --compile writes the compiled magic database
66
+ /^\s*file\b[^|;&]*\s(?:-[a-zA-Z]*C(?:\s|$)|--compile\b)/i,
67
+ // find -delete (its -exec/-ok forms are exec intent below)
68
+ /^\s*find\b[^|;&]*\s-delete\b/i,
69
+ // git diff family --output FILE: a real write path on commands that are
70
+ // otherwise read-only (`git diff --output=out.txt` creates the file).
71
+ // `-o` is not a valid short option for these verbs; guarding it is free.
72
+ /^\s*git\s+(?:log|diff|show|diff-tree|whatchanged)\b[^|;&]*(?:\s--output(?:=|\s+)|\s-o\s)\S+/i,
29
73
  ];
30
74
 
75
+ /** True when a segment is a write-intent invocation. */
31
76
  function hasWriteIntent(command: string): boolean {
32
- return WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(command));
77
+ return parseCommandSegments(command).some((segment) =>
78
+ WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(segment.command)),
79
+ );
80
+ }
81
+
82
+ /* -------------------------------------------------------------------------- */
83
+ /* Exec intent */
84
+ /* -------------------------------------------------------------------------- */
85
+
86
+ /**
87
+ * Read-by-name commands whose options start another program. Same per-segment,
88
+ * command-position anchoring as the write intent above, so an allowed verb
89
+ * cannot smuggle execution in and search arguments stay data.
90
+ */
91
+ const EXEC_INTENT_PATTERNS: RegExp[] = [
92
+ // git grep -O[pager] / --open-files-in-pager: runs a pager on matched files
93
+ /^\s*git\s+grep\b[^|;&]*\s(?:-O|--open-files-in-pager\b)/i,
94
+ // external diff drivers (from config/gitattributes)
95
+ /^\s*git\s+\S+\b[^|;&]*\s--ext-diff\b/i,
96
+ // env forwards to another command (`env [-i] [-u NAME] [NAME=VALUE] CMD …`);
97
+ // bare env, flag-only and assignment-only forms print the environment and
98
+ // stay allowed (the lookahead keeps NAME=VALUE from posing as CMD)
99
+ /^\s*env\b(?:\s+(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=\S+))*\s+(?![A-Za-z_][A-Za-z0-9_]*=)[^-\s]\S*/,
100
+ // find/fd command runners
101
+ /^\s*find\b[^|;&]*\s-(?:exec|execdir|ok|okdir)\b/i,
102
+ /^\s*fd\b[^|;&]*\s(?:-x|--exec(?:-batch)?)\b/i,
103
+ // ripgrep preprocessor
104
+ /^\s*rg\b[^|;&]*\s--pre(?:=|\s)/i,
105
+ ];
106
+
107
+ /** True when a segment is an exec-intent invocation. */
108
+ function hasExecIntent(command: string): boolean {
109
+ return parseCommandSegments(command).some((segment) =>
110
+ EXEC_INTENT_PATTERNS.some((pattern) => pattern.test(segment.command)),
111
+ );
112
+ }
113
+
114
+ /* -------------------------------------------------------------------------- */
115
+ /* Read-only allowlist extensions */
116
+ /* -------------------------------------------------------------------------- */
117
+
118
+ /**
119
+ * Read-only commands the upstream allowlist lacks, plus the read-only forms of
120
+ * git verbs that also have mutating siblings.
121
+ *
122
+ * Every pattern is anchored and pinned to stdout-only shapes:
123
+ * - no `find -exec`/`-delete`, no `fd -x`, no `sort -o`, no `sed -i`;
124
+ * - `git stash` is pinned to list/show (`drop`/`pop`/`apply` stay blocked);
125
+ * - `git tag` is pinned to -l/--list (create/delete stay blocked);
126
+ * - `git reflog` is pinned to bare/`show` (delete/expire stay blocked);
127
+ * - `git config` is pinned to get/list forms (set/unset stay blocked).
128
+ */
129
+ const READ_ONLY_COMMAND_PATTERNS: RegExp[] = [
130
+ // git: read-only archaeology verbs upstream never modeled
131
+ /^\s*git\s+(?:blame|grep|rev-parse|rev-list|describe|shortlog|cat-file|show-ref|for-each-ref|name-rev|diff-tree|whatchanged|merge-base|cherry|check-ignore|check-attr|count-objects)\b/i,
132
+ // git: upstream read verbs whose arguments may hold destructive words
133
+ /^\s*git\s+(?:log|diff|show|status|ls-files|ls-tree)\b/i,
134
+ // git: read-only forms of verbs that also mutate
135
+ /^\s*git\s+reflog(?:\s+show\b[^|;&]*)?\s*$/i,
136
+ /^\s*git\s+stash\s+(?:list|show)\b/i,
137
+ /^\s*git\s+worktree\s+list\b/i,
138
+ /^\s*git\s+tag\s+(?:-l|--list)\b/i,
139
+ /^\s*git\s+config\s+(?:--get\b|--get-all\b|--get-regexp\b|-l\b|--list\b)/i,
140
+ // harmless navigation/readers commonly chained before a search
141
+ /^\s*cd\b/i,
142
+ /^\s*(?:head|tail|cat|tac|wc|uniq|jq|column)\b/i,
143
+ /^\s*(?:ls|file|stat|tree|du|df|bat|eza)\b/i,
144
+ /^\s*(?:grep|rg)\b/i,
145
+ /^\s*sed\s+-n\b/i,
146
+ // package metadata (network/library reads, never installs)
147
+ /^\s*pnpm\s+(?:view|info|search|licenses)\b/i,
148
+ /^\s*pip3?\s+(?:show|list|freeze)\b/i,
149
+ /^\s*uv\s+(?:pip\s+(?:list|show|freeze)|tree)\b/i,
150
+ /^\s*python3?\s+-m\s+json\.tool\b/i,
151
+ // network reads: wget to stdout only (file targets stay blocked)
152
+ /^\s*wget\b[^|;&]*\s(?:-O\s*-|-O-|-qO-|--output-document(?:=|\s+)-)/i,
153
+ ];
154
+
155
+ /**
156
+ * Gaps in the upstream DESTRUCTIVE_PATTERNS closed here: git commands that can
157
+ * write but are not modeled as destructive — `git branch <name>` creates a ref,
158
+ * `git branch -m/-M/-c/-C` moves/copies, `git remote add/remove/set-url/...`
159
+ * edits .git/config. File-write and command-runner options are handled by the
160
+ * write/exec intent checks above (which run on the raw command, so pipelines
161
+ * are covered too).
162
+ */
163
+ const EXTRA_DESTRUCTIVE_PATTERNS: RegExp[] = [
164
+ // positional argument after `branch` = create branch (flags stay read-only)
165
+ /^\s*git\s+branch\s+(?=[^-\s])/i,
166
+ /^\s*git\s+branch\s+-(?:[mMcC]\b|move\b|copy\b|edit-description\b)/i,
167
+ // remote subcommands other than the read-only ones
168
+ /^\s*git\s+remote\s+(?!-)(?!(?:show|get-url)(?=\s|$))/i,
169
+ ];
170
+
171
+ /**
172
+ * True when every parsed segment is a curated read-only shape: no redirect, no
173
+ * unmodeled operator, each segment matching one of the patterns above.
174
+ *
175
+ * Used as `allowCommand`, which runs *before* the package's destructive check —
176
+ * that is what lets `git log --grep=rm` through while `git log && rm -rf x`
177
+ * still fails (the second segment matches nothing here).
178
+ */
179
+ function allSegmentsReadOnly(command: string): boolean {
180
+ const segments = parseCommandSegments(command);
181
+ if (segments.length === 0) return false;
182
+ return segments.every(
183
+ (segment) =>
184
+ !segment.hasRedirect &&
185
+ segment.unsafeOperator === undefined &&
186
+ READ_ONLY_COMMAND_PATTERNS.some((pattern) =>
187
+ pattern.test(segment.command),
188
+ ),
189
+ );
33
190
  }
34
191
 
35
192
  /** True when `command` is considered read-only by the sandbox. */
36
193
  export function isReadOnlyCommand(command: string): boolean {
37
194
  if (hasWriteIntent(command)) return false;
38
- return isSafeCommand(command);
195
+ if (hasExecIntent(command)) return false;
196
+ // The read-only-segment bypass below runs before the package's own
197
+ // substitution check, so reject command substitution here first.
198
+ if (hasCommandSubstitution(command)) return false;
199
+ return isSafeCommand(command, {
200
+ extraSafe: READ_ONLY_COMMAND_PATTERNS,
201
+ extraDestructive: EXTRA_DESTRUCTIVE_PATTERNS,
202
+ allowCommand: allSegmentsReadOnly,
203
+ });
204
+ }
205
+
206
+ /* -------------------------------------------------------------------------- */
207
+ /* Block classification (for actionable rejection messages) */
208
+ /* -------------------------------------------------------------------------- */
209
+
210
+ /** Why a bash command was rejected; drives the message shown to the model. */
211
+ export type BashBlockKind =
212
+ | "substitution"
213
+ | "redirect"
214
+ | "exec"
215
+ | "write"
216
+ | "download"
217
+ | "git"
218
+ | "install"
219
+ | "unlisted";
220
+
221
+ const INSTALL_INTENT: RegExp[] = [
222
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|remove|rm|update|up|ci|link|publish|dlx|exec|create)\b/i,
223
+ /\bpip3?\s+(?:install|uninstall)\b/i,
224
+ /\b(?:apt|apt-get|brew|cargo|go)\s+(?:install|add|get)\b/i,
225
+ ];
226
+
227
+ /** Classic file writers, for classifying a rejection as a write attempt. */
228
+ const WRITER_INTENT =
229
+ /\b(?:rm|rmdir|mv|cp|mkdir|touch|chmod|chown|chgrp|ln|truncate|shred|dd|sed\s+-i|sort\s+-o|yq\s+-i)\b/i;
230
+
231
+ /** Classify a rejected command so the block reason can point at alternatives. */
232
+ export function classifyBlockedCommand(command: string): BashBlockKind {
233
+ if (hasCommandSubstitution(command)) return "substitution";
234
+ if (parseCommandSegments(command).some((segment) => segment.hasRedirect)) {
235
+ return "redirect";
236
+ }
237
+ if (hasExecIntent(command)) return "exec";
238
+ if (hasWriteIntent(command) || WRITER_INTENT.test(command)) return "write";
239
+ if (/\b(?:wget|curl)\b/i.test(command)) return "download";
240
+ if (/^\s*git\b/i.test(command)) return "git";
241
+ if (INSTALL_INTENT.some((pattern) => pattern.test(command))) return "install";
242
+ return "unlisted";
39
243
  }
@@ -2,12 +2,27 @@
2
2
  * ask feature — constants (tool names, UI keys, labels).
3
3
  */
4
4
 
5
- /** Tools hard-removed from the active set while ask mode is on. */
5
+ /**
6
+ * Tools hard-removed from the active set while ask mode is on.
7
+ *
8
+ * `write`/`edit` are the obvious ones. `powershell` is a shell too: pi
9
+ * registers it on every platform (inactive by default, enabled by settings),
10
+ * and it would otherwise hand ask mode a full write channel.
11
+ */
6
12
  export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
7
13
  "write",
8
14
  "edit",
15
+ "powershell",
9
16
  ]);
10
17
 
18
+ /**
19
+ * Read-only helper tools activated for the duration of ask mode (when the host
20
+ * has them registered). pi registers `grep`/`find`/`ls` but leaves them
21
+ * inactive by default; ask mode is a reading mode, so the model gets proper
22
+ * search tools instead of bash-only search. Restored exactly on exit.
23
+ */
24
+ export const ASK_READ_ONLY_HELPERS: readonly string[] = ["grep", "find", "ls"];
25
+
11
26
  /** Footer status key (extension-scoped) and label. */
12
27
  export const ASK_STATUS_KEY = "ask-mode";
13
28
  export const ASK_STATUS_LABEL = "ask";
@@ -2,13 +2,15 @@
2
2
  * ask feature — read-only Q&A mode.
3
3
  *
4
4
  * Design notes (see PLAN.md):
5
- * - Hard guarantee: write/edit are removed from the active tool set via
6
- * pi.setActiveTools(); pi's runtime rejects calls to non-active tools
5
+ * - Hard guarantee: write/edit/powershell are removed from the active tool set
6
+ * via pi.setActiveTools(); pi's runtime rejects calls to non-active tools
7
7
  * ("Tool not found") so nothing executes.
8
+ * - Read-only helper tools (grep/find/ls) are activated for the duration so
9
+ * the model gets proper search tools; the entry snapshot restores exactly.
8
10
  * - bash stays available but is gated by a read-only sandbox
9
11
  * (see bash-sandbox.ts) via the tool_call hook.
10
12
  * - Mode banner appended to the system prompt via before_agent_start
11
- * chained systemPrompt (see prompt.ts).
13
+ * chained systemPrompt (see prompt.ts), listing blocked -> alternative.
12
14
  */
13
15
  import type {
14
16
  ExtensionAPI,
@@ -21,11 +23,13 @@ import {
21
23
  } from "../../lib/tools.js";
22
24
  import {
23
25
  ASK_BLOCKED_TOOLS,
26
+ ASK_READ_ONLY_HELPERS,
24
27
  ASK_STATUS_KEY,
25
28
  ASK_STATUS_LABEL,
26
29
  } from "./constants.js";
27
30
  import { isReadOnlyCommand } from "./bash-sandbox.js";
28
31
  import { appendAskBanner } from "./prompt.js";
32
+ import { blockedBashReason, blockedToolReason } from "./reasons.js";
29
33
 
30
34
  export function registerAsk(pi: ExtensionAPI): void {
31
35
  let enabled = false;
@@ -40,14 +44,30 @@ export function registerAsk(pi: ExtensionAPI): void {
40
44
  );
41
45
  }
42
46
 
47
+ /** Read-only helpers currently active (drives the banner's search hint). */
48
+ function activeHelperTools(): string[] {
49
+ const active = new Set(pi.getActiveTools());
50
+ return ASK_READ_ONLY_HELPERS.filter((name) => active.has(name));
51
+ }
52
+
43
53
  function enter(ctx: ExtensionContext): void {
44
54
  if (enabled) return;
45
55
  enabled = true;
46
56
  snapshot = snapshotActiveTools(pi);
47
- pi.setActiveTools(withoutTools(snapshot, ASK_BLOCKED_TOOLS));
57
+ // Activate grep/find/ls when the host has them registered and they were
58
+ // not already active. Names must be registered; unknown names are ignored
59
+ // by setActiveTools, but filtering here keeps the intent explicit.
60
+ const registered = new Set(pi.getAllTools().map((tool) => tool.name));
61
+ const helpers = ASK_READ_ONLY_HELPERS.filter(
62
+ (name) => registered.has(name) && !snapshot.includes(name),
63
+ );
64
+ pi.setActiveTools([
65
+ ...withoutTools(snapshot, ASK_BLOCKED_TOOLS),
66
+ ...helpers,
67
+ ]);
48
68
  updateStatus(ctx);
49
69
  ctx.ui.notify(
50
- "Ask mode ON — read-only (write/edit disabled). /ask to exit.",
70
+ "Ask mode ON — read-only (write/edit/powershell disabled, bash sandboxed). /ask to exit.",
51
71
  "info",
52
72
  );
53
73
  }
@@ -77,27 +97,23 @@ export function registerAsk(pi: ExtensionAPI): void {
77
97
  });
78
98
 
79
99
  // Defense-in-depth gate while ask mode is active:
80
- // - write/edit are already removed from the active set (runtime rejects
81
- // them), this blocks them again in case anything re-enables them.
100
+ // - write/edit/powershell are already removed from the active set (runtime
101
+ // rejects them); this blocks them again in case anything re-enables them.
82
102
  // - bash is kept available but only read-only commands pass the sandbox.
103
+ // The rejection `reason` reaches the model as the tool result, so it names
104
+ // the nearest allowed alternative (see reasons.ts).
83
105
  pi.on("tool_call", (event) => {
84
106
  if (!enabled) return;
85
107
 
86
- if (event.toolName === "write" || event.toolName === "edit") {
87
- return {
88
- block: true,
89
- reason:
90
- "Ask mode: write/edit are disabled. Use /ask to exit ask mode first.",
91
- };
108
+ if (ASK_BLOCKED_TOOLS.has(event.toolName)) {
109
+ return { block: true, reason: blockedToolReason(event.toolName) };
92
110
  }
93
111
 
94
112
  if (event.toolName === "bash") {
95
- const command = event.input.command as string;
113
+ const command =
114
+ typeof event.input.command === "string" ? event.input.command : "";
96
115
  if (!isReadOnlyCommand(command)) {
97
- return {
98
- block: true,
99
- reason: `Ask mode: only read-only bash commands are allowed.\nCommand: ${command}\nUse /ask to exit ask mode first.`,
100
- };
116
+ return { block: true, reason: blockedBashReason(command) };
101
117
  }
102
118
  }
103
119
  });
@@ -107,6 +123,10 @@ export function registerAsk(pi: ExtensionAPI): void {
107
123
  if (!enabled) return;
108
124
  // event.systemPrompt is the current (possibly already modified by other
109
125
  // extensions) system prompt — append, don't replace.
110
- return { systemPrompt: appendAskBanner(event.systemPrompt) };
126
+ return {
127
+ systemPrompt: appendAskBanner(event.systemPrompt, {
128
+ helperTools: activeHelperTools(),
129
+ }),
130
+ };
111
131
  });
112
132
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tests for the ask-mode banner: current state + blocked -> alternative hints.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { appendAskBanner, buildAskBanner } from "./prompt.js";
6
+
7
+ describe("buildAskBanner", () => {
8
+ it("states the hard-blocked tools and the exit path", () => {
9
+ const banner = buildAskBanner();
10
+ expect(banner).toContain("[ASK MODE — read-only]");
11
+ expect(banner).toContain("write, edit, powershell");
12
+ expect(banner).toContain("/ask");
13
+ });
14
+
15
+ it("names the alternatives for blocked commands", () => {
16
+ const banner = buildAskBanner();
17
+ expect(banner).toContain("`read` tool");
18
+ expect(banner).toContain("`curl -s <url>`");
19
+ expect(banner).toContain("cat/sed tricks");
20
+ });
21
+
22
+ it("lists the read-only git families", () => {
23
+ const banner = buildAskBanner();
24
+ expect(banner).toContain("blame");
25
+ expect(banner).toContain("reflog show");
26
+ expect(banner).toContain("stash list");
27
+ });
28
+
29
+ it("adds the helper-tool hint only when helpers are active", () => {
30
+ const withHelpers = buildAskBanner({ helperTools: ["grep", "find", "ls"] });
31
+ expect(withHelpers).toContain("`grep`/`find`/`ls` tools");
32
+
33
+ const withoutHelpers = buildAskBanner();
34
+ expect(withoutHelpers).not.toContain("active during ask mode");
35
+ });
36
+ });
37
+
38
+ describe("appendAskBanner", () => {
39
+ it("appends instead of replacing the existing system prompt", () => {
40
+ const original = "You are a helpful coding agent.";
41
+ const combined = appendAskBanner(original, { helperTools: ["grep"] });
42
+ expect(combined.startsWith(original)).toBe(true);
43
+ expect(combined).toContain("[ASK MODE — read-only]");
44
+ });
45
+ });
@@ -1,22 +1,61 @@
1
1
  /**
2
2
  * ask feature — system prompt banner.
3
3
  *
4
- * Appended to the system prompt (chained append, not replacement) while
5
- * ask mode is active so the model knows the current mode and its
6
- * restrictions. Do NOT use the removed `systemPromptAppend` field or
7
- * wholesale systemPrompt replacement (see PLAN.md).
4
+ * Appended to the system prompt (chained append, not replacement) while ask
5
+ * mode is active so the model knows the current mode, what it may still use,
6
+ * and — for everything blocked — what to use instead. Do NOT use the removed
7
+ * `systemPromptAppend` field or wholesale systemPrompt replacement (see
8
+ * PLAN.md).
8
9
  */
9
10
 
10
- export const ASK_BANNER = `[ASK MODE]
11
- You are in ask mode — a read-only Q&A mode. You may read, search, and analyze the codebase, but you MUST NOT modify anything.
11
+ export interface AskBannerOptions {
12
+ /** Read-only helper tools active in this session (subset of grep/find/ls). */
13
+ readonly helperTools?: readonly string[];
14
+ }
15
+
16
+ const BANNER_HEADER = `[ASK MODE — read-only]
17
+ You are in ask mode: answer questions and read code; nothing may be modified.
18
+
19
+ Hard-blocked (calls fail — no workaround): write, edit, powershell.
20
+ If fulfilling a request needs a change, say exactly what to change and tell the
21
+ user to exit ask mode with /ask first.
22
+
23
+ bash runs under a read-only allowlist. Working examples:
24
+ - files: ls, cat, head, tail, sed -n, wc, file, stat, diff, tree
25
+ - search: grep, rg, fd, find (without -exec), jq
26
+ - git: status, log, diff, show, branch, remote, blame, grep, rev-parse,
27
+ describe, shortlog, reflog show, stash list, tag -l, cat-file
28
+ - network: curl -s <url>, wget -O - <url>
29
+ - packages: npm view, pnpm view, pip show/list, python3 -m json.tool
12
30
 
13
- Restrictions:
14
- - write and edit are disabled (hard-blocked; calls will fail)
15
- - bash is restricted to read-only commands (ls, cat, grep, git status/log/diff, etc.)
31
+ Blocked without exception: redirection (> >>), tee, dd, rm/mv/cp/mkdir, sed -i,
32
+ installs/builds, git mutations (add/commit/checkout/reset/stash drop/...), and
33
+ command substitution $(...) / backticks.`;
16
34
 
17
- Answer the user's question directly. If a question requires changing files, say so and suggest exiting ask mode with /ask.`;
35
+ const BANNER_FOOTER = `Never write a file, not even a temporary one.`;
36
+
37
+ /** Build the banner, adapting the search hint to the active helper tools. */
38
+ export function buildAskBanner(options: AskBannerOptions = {}): string {
39
+ const helpers = options.helperTools ?? [];
40
+ const helperLabel = helpers.map((name) => `\`${name}\``).join("/");
41
+ const helperLine =
42
+ helpers.length > 0
43
+ ? `\n- search with the ${helperLabel} tools (active during ask mode)`
44
+ : "";
45
+ return `${BANNER_HEADER}
46
+
47
+ If a bash command is rejected, do not retry variants or invent workarounds:
48
+ - read file contents with the \`read\` tool instead of cat/sed tricks${helperLine}
49
+ - fetch a URL with \`curl -s <url>\` instead of wget
50
+ - otherwise tell the user which command is blocked and why
51
+
52
+ ${BANNER_FOOTER}`;
53
+ }
18
54
 
19
55
  /** Append the ask-mode banner to an existing system prompt. */
20
- export function appendAskBanner(systemPrompt: string): string {
21
- return `${systemPrompt}\n\n${ASK_BANNER}`;
56
+ export function appendAskBanner(
57
+ systemPrompt: string,
58
+ options: AskBannerOptions = {},
59
+ ): string {
60
+ return `${systemPrompt}\n\n${buildAskBanner(options)}`;
22
61
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tests for ask-mode rejection messages: each one must point at a next step.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { blockedBashReason, blockedToolReason } from "./reasons.js";
6
+
7
+ describe("blockedToolReason", () => {
8
+ it.each(["write", "edit", "powershell"])(
9
+ "names the exit path for %s",
10
+ (tool) => {
11
+ const reason = blockedToolReason(tool);
12
+ expect(reason).toContain(tool);
13
+ expect(reason).toContain("/ask");
14
+ },
15
+ );
16
+ });
17
+
18
+ describe("blockedBashReason", () => {
19
+ it.each([
20
+ ["rm -rf /", "writes to disk"],
21
+ ["cat a > b", "redirection"],
22
+ ["echo $(id)", "command substitution"],
23
+ ["wget https://example.com", "curl -s"],
24
+ ["git commit -m x", "read-only git"],
25
+ ["git grep -O x", "run another program"],
26
+ ["pnpm install", "install/build"],
27
+ ["vim README.md", "allowlist"],
28
+ ] as const)("for %s mentions: %s", (command, needle) => {
29
+ expect(blockedBashReason(command)).toContain(needle);
30
+ });
31
+
32
+ it("always echoes the blocked command", () => {
33
+ expect(blockedBashReason("git push")).toContain("Command: git push");
34
+ });
35
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ask feature — actionable rejection messages.
3
+ *
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,
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
8
+ * is the best place to name the nearest allowed alternative and stop the model
9
+ * from retrying variants of a blocked command.
10
+ */
11
+ import { classifyBlockedCommand } from "./bash-sandbox.js";
12
+
13
+ /** Reason for a hard-blocked tool call (write / edit / powershell). */
14
+ export function blockedToolReason(toolName: string): string {
15
+ if (toolName === "powershell") {
16
+ return "Ask mode: powershell is disabled — ask mode is read-only. " +
17
+ "Inspect files with the read/grep/find/ls tools or the read-only bash " +
18
+ "allowlist; if a command must change something, tell the user and ask " +
19
+ "them to exit ask mode with /ask.";
20
+ }
21
+ return `Ask mode: ${toolName} is disabled — ask mode is read-only. ` +
22
+ "If a change is needed, explain what to change and ask the user to exit " +
23
+ "ask mode with /ask.";
24
+ }
25
+
26
+ /** Reason for a rejected bash command, classified by intent. */
27
+ export function blockedBashReason(command: string): string {
28
+ const suffix = `\nCommand: ${command}`;
29
+ switch (classifyBlockedCommand(command)) {
30
+ case "substitution":
31
+ return "Ask mode: command substitution — $(...) and backticks — is " +
32
+ "blocked because it can run arbitrary commands. Run the inner " +
33
+ "command directly instead." + suffix;
34
+ case "redirect":
35
+ return "Ask mode: output redirection is blocked — ask mode cannot " +
36
+ "write files, not even temporary ones. Use the read/grep/find/ls " +
37
+ "tools to inspect files instead." + suffix;
38
+ case "exec":
39
+ return "Ask mode: this command can run another program (git grep -O, " +
40
+ "git --ext-diff, find -exec/-ok, fd -x, rg --pre, env CMD). Only " +
41
+ "read-only operations are allowed; run the underlying read-only " +
42
+ "command directly instead." + suffix;
43
+ case "write":
44
+ return "Ask mode: this command writes to disk (curl -o/-O, wget -O, " +
45
+ "dd of=, tee, sed -i, git --output, ...). Nothing can be written in " +
46
+ "ask mode. To read a URL without saving it use `curl -s <url>`; to " +
47
+ "read files use the `read` tool." + suffix;
48
+ case "download":
49
+ return "Ask mode: downloads to disk are blocked. Use `curl -s <url>` " +
50
+ "or `wget -O - <url>` to read a URL to stdout instead." + suffix;
51
+ case "git":
52
+ return "Ask mode: only read-only git commands are allowed (status, " +
53
+ "log, diff, show, blame, grep, rev-parse, describe, shortlog, " +
54
+ "reflog show, stash list, tag -l, cat-file). To change the " +
55
+ "repository, ask the user to exit ask mode with /ask." + suffix;
56
+ case "install":
57
+ return "Ask mode: install/build commands are blocked. Read-only " +
58
+ "package queries (npm view, pnpm view, pip show/list) are allowed; " +
59
+ "to actually install or build, ask the user to exit ask mode with " +
60
+ "/ask." + suffix;
61
+ default:
62
+ return "Ask mode: this command is not on the read-only allowlist " +
63
+ "(or is classified as destructive), so it was not executed — it may " +
64
+ "itself be read-only. Do not retry variants: use the " +
65
+ "read/grep/find/ls tools, a recognised read-only bash command, or " +
66
+ "tell the user the command is blocked." + suffix;
67
+ }
68
+ }
@@ -16,7 +16,8 @@
16
16
  * Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
17
17
  * that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
18
18
  * instead of the model prompt path. The wrapper defaults it to false
19
- * (verified against pi 1.1.0 agent-session.js; see AGENTS.md host-compat
19
+ * (each byte-identical across every published version of the supported
20
+ * window, 0.99.0 → 1.2.0; see AGENTS.md host-compat
20
21
  * discipline for the version matrix).
21
22
  *
22
23
  * Only extension commands can run this way. Built-in commands (/compact,
@@ -11,8 +11,11 @@
11
11
  * before running its own compaction
12
12
  * - prompts, message assembly, split-turn handling, output budgets, and the
13
13
  * summary file-list tail are verbatim pi built-in behavior (copied from
14
- * 0.87.1, verified unchanged through 1.1.0) — only the executing model
15
- * differs
14
+ * 0.87.1, byte-identical across every published version of the supported
15
+ * window, 0.99.0 → 1.2.0) — only the executing model differs. The one
16
+ * exception is `serializeConversation`, which is imported from the host
17
+ * instead of mirrored, so it tracks whatever the running host does
18
+ * (1.2.0 stopped feeding assistant thinking into the request).
16
19
  * - lossless fallback: unset config, unavailable model, empty/failed
17
20
  * summary, or user abort → return undefined and pi's default compaction
18
21
  * (session model) runs untouched. The extension can never make
@@ -116,7 +119,7 @@ async function completeSummary(
116
119
  }
117
120
 
118
121
  /**
119
- * pi's output-budget formula (compaction.js, 0.87.1 → 1.1.0 unchanged): a
122
+ * pi's output-budget formula (compaction.js, 0.87.1 → 1.2.0 unchanged): a
120
123
  * fraction of reserveTokens clamped by the model's own output cap.
121
124
  */
122
125
  function summarizationBudget(
@@ -132,7 +135,8 @@ function summarizationBudget(
132
135
 
133
136
  /**
134
137
  * Run the full compaction summarization with the configured model,
135
- * replicating pi's compact() (0.87.1, verified unchanged through 1.1.0):
138
+ * replicating pi's compact() (0.87.1, byte-identical across the supported
139
+ * 0.99.0 → 1.2.0 window):
136
140
  * history summary (first-connection or iterative-update prompt, custom
137
141
  * focus appended), plus a separate turn-prefix checkpoint when the cut point
138
142
  * splits a turn, merged into one summary with the file-list tail. Returns
@@ -5,11 +5,15 @@
5
5
  * compaction (dist/core/compaction/compaction.js and utils.js) so that
6
6
  * summaries produced by the configured compact model are formatted exactly
7
7
  * like pi's own compaction output. Only the executing model differs.
8
- * Verified byte-identical against pi 1.1.0 (0.88–0.98 were never published;
9
- * the only 0.87.1→1.1.0 change there is a combineUsage refactor into a
10
- * shared core/usage-totals.js, no prompt or budget change). 0.87.0 shipped a
11
- * different split-turn prefix prompt; the text mirrored here is 0.87.1+'s
12
- * fix, which is also what 0.87.0 hosts get — intentional.
8
+ * Verified byte-identical against every published version of the supported
9
+ * window (0.99.0 → 1.2.0): dist/core/compaction/compaction.js and
10
+ * core/usage-totals.js are unchanged files across that span, and the only
11
+ * utils.js change (1.2.0 dropping assistant thinking from
12
+ * serializeConversation) does not touch the strings mirrored here — that
13
+ * serializer is imported from the host, not mirrored. 0.87.0 shipped a
14
+ * different split-turn prefix prompt; the text mirrored here is 0.87.1's
15
+ * fix, and the mirror is the only variant any supported host gets —
16
+ * intentional.
13
17
  *
14
18
  * Update these strings whenever pi changes its built-in prompts (diff
15
19
  * dist/core/compaction/compaction.js between versions).
@@ -198,7 +202,8 @@ export function formatFileOperations(
198
202
 
199
203
  /**
200
204
  * Combine two provider usage records field-wise (pi 0.87.1 compaction.js
201
- * combineUsage, moved verbatim to core/usage-totals.js in 0.99.0): used when
205
+ * combineUsage, moved verbatim to core/usage-totals.js in 0.99.0 and
206
+ * unchanged through 1.2.0): used when
202
207
  * a split turn produces two summarization calls.
203
208
  */
204
209
  export function combineUsage<T extends UsageLike>(first: T, second: T): T {
@@ -8,7 +8,8 @@
8
8
  * bindings — only the cycle semantics change (patch-cycle.ts), so no
9
9
  * keybinding conflicts arise.
10
10
  *
11
- * Verified against pi 0.87.0 / 0.99.0 / 1.0.4 / 1.1.0 keybinding
11
+ * Verified against every published version of the supported window
12
+ * (0.99.0 → 1.2.0) keybinding
12
13
  * tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
13
14
  * `pnpm check:keybindings` after every host upgrade before trusting this.
14
15
  */
@@ -29,11 +30,11 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
29
30
  * Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
30
31
  * `TuiAltScreen.handleViewportInput()` checks it before
31
32
  * `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
32
- * search and the selector never sees it (verified in pi-tui 1.1.0
33
+ * search and the selector never sees it (verified in pi-tui 1.2.0
33
34
  * `dist/tui-alt-screen.js`: the search check is the first key branch and
34
35
  * returns `{ consume: true }` unconditionally).
35
36
  *
36
- * Why ctrl+alt+f was discarded: clean at the pi layer across 0.87–1.1 (only
37
+ * Why ctrl+alt+f was discarded: clean at the pi layer across 0.99–1.2 (only
37
38
  * ctrl+alt+] is taken from that family) and byte-distinguishable without
38
39
  * kitty — but grabbed at the OS/app layer on the author's machine by a
39
40
  * Windows screenshot tool, so it never reached the terminal. Lesson: a key
@@ -14,7 +14,8 @@
14
14
  * - with exactly one favorite, already on it → undefined (stock "only one
15
15
  * model available" feedback); otherwise jump to it
16
16
  * - the apply logic mirrors pi's _cycleScopedModel/_cycleAvailableModel
17
- * (byte-identical across every published version in 0.87.0 → 1.1.0:
17
+ * (byte-identical across every published version in the supported window
18
+ * 0.99.0 → 1.2.0:
18
19
  * state swap, session persistence, default-model persistence only when
19
20
  * `options.persist` is set, thinking-level re-clamp, model_select emit
20
21
  * with source "cycle")
@@ -30,9 +31,9 @@ import { getFavoritesStore, modelKey } from "./store.js";
30
31
 
31
32
  /**
32
33
  * pi's `Model<any>` — the exact type of `ModelCycleResult.model`
33
- * (pi-coding-agent 0.87–1.1; the field is unchanged across that span).
34
+ * (pi-coding-agent 0.99–1.2; the field is unchanged across that span).
34
35
  * Derived here instead of importing `Model` from @earendil-works/pi-ai,
35
- * whose index.d.ts (0.87 through 1.1.0 alike) re-exports it via an
36
+ * whose index.d.ts (0.99 through 1.2.0 alike) re-exports it via an
36
37
  * extensioned `"./types.ts"` specifier that not every TS resolver substitutes
37
38
  * to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
38
39
  */
@@ -56,7 +57,7 @@ interface CycleSessionInternals {
56
57
  setDefaultModelAndProvider(provider: string, id: string): void;
57
58
  };
58
59
  /**
59
- * pi 0.87: keeps a persisted default usable when cycling inside a
60
+ * pi 0.99–1.2: keeps a persisted default usable when cycling inside a
60
61
  * non-empty --models scope.
61
62
  */
62
63
  _addPersistedDefaultToNonEmptyScope(model: unknown): void;
@@ -97,7 +98,7 @@ function nextCycleIndex(
97
98
  : (currentIndex - 1 + len) % len;
98
99
  }
99
100
 
100
- /** Options accepted by pi 0.87's cycleModel (persist gates default-model persistence). */
101
+ /** Options accepted by pi's cycleModel (persist gates default-model persistence). */
101
102
  interface CycleOptions {
102
103
  persist?: boolean;
103
104
  }
@@ -139,7 +140,7 @@ export function applyCyclePatch(): boolean {
139
140
  return original.call(this, direction, options);
140
141
  }
141
142
 
142
- // Same source and availability filtering as pi 0.87's stock cycling.
143
+ // Same source and availability filtering as pi's stock cycling (0.99–1.2).
143
144
  const scoped = this._scopedModels.length > 0;
144
145
  let candidates: CycleCandidate[];
145
146
  if (scoped) {
@@ -184,7 +185,7 @@ export function applyCyclePatch(): boolean {
184
185
  : this._getThinkingLevelForModelSwitch();
185
186
  this.agent.state.model = next.model;
186
187
  this.sessionManager.appendModelChange(next.model.provider, next.model.id);
187
- // pi 0.87: only persist the default model when the caller asked for it.
188
+ // pi 0.99–1.2: only persist the default model when the caller asked for it.
188
189
  if (options.persist) {
189
190
  this.settingsManager.setDefaultModelAndProvider(
190
191
  next.model.provider,
@@ -22,7 +22,7 @@
22
22
  * keeps loading and ctrl+p falls back to stock behavior. The prototype
23
23
  * shape touched here (handleInput/updateList/filterModels and the instance
24
24
  * fields below) is byte-identical across every published version in
25
- * pi 0.87.0 → 1.1.0.
25
+ * pi 0.99.0 → 1.2.0 (and 0.87.0 → 1.1.0 before the floor moved).
26
26
  */
27
27
  import {
28
28
  ModelSelectorComponent,
@@ -61,7 +61,7 @@ interface SelectorPatchTarget {
61
61
  handleInput(keyData: string): void;
62
62
  updateList(): void;
63
63
  filterModels(query: string): void;
64
- /** pi 0.87: default-model badge source. */
64
+ /** pi 0.99–1.2: default-model badge source. */
65
65
  isDefaultModel(model: ModelRef): boolean;
66
66
  listContainer: ListContainerLike;
67
67
  searchInput: SearchInputLike;
@@ -116,7 +116,7 @@ let patched = false;
116
116
  export function applyModelSelectorPatches(): boolean {
117
117
  if (patched) return true;
118
118
  // SAFETY: ModelSelectorComponent's members are TS-private (compile-time
119
- // only); the structural view matches pi 0.87–1.1's prototype shape
119
+ // only); the structural view matches pi 0.99–1.2's prototype shape
120
120
  // (byte-identical across that span) and every touched method is
121
121
  // existence-checked below before any patching happens.
122
122
  const proto =
@@ -155,7 +155,7 @@ export function applyModelSelectorPatches(): boolean {
155
155
  };
156
156
 
157
157
  // ── 2. List rendering: hint row + bold bright yellow favorites ────────
158
- // Replicates the built-in updateList layout (pi 0.87–1.1: fixed-width cursor
158
+ // Replicates the built-in updateList layout (pi 0.99–1.2: fixed-width cursor
159
159
  // and ✓ current-marker columns, provider badge, `· default` badge, scroll
160
160
  // indicator, empty/error states, refresh status, Model Name footer) with
161
161
  // one change: favorited model ids render in the favorite style.
@@ -21,7 +21,7 @@ import type { KeyId } from "@earendil-works/pi-tui";
21
21
  /**
22
22
  * Stash hotkey.
23
23
  *
24
- * Compatibility rationale (verified against pi 0.87.0–1.1.0 keybindings, live
24
+ * Compatibility rationale (verified against pi 0.99.0–1.2.0 keybindings, live
25
25
  * matchesKey probes and terminal/OS behavior):
26
26
  * - pi: no default binding for ctrl+alt+<letter> (only ctrl+alt+] is taken);
27
27
  * extension shortcuts dispatch first while the editor is focused
@@ -4,8 +4,8 @@
4
4
  * (review, compact-model — favorites keeps its own store-local key logic).
5
5
  *
6
6
  * Types are derived from pi-coding-agent's own exports (ModelRegistry)
7
- * rather than imported from @earendil-works/pi-ai: pi-ai's index.d.ts (0.87
8
- * through 1.1.0 alike) re-exports `Model`/`Api` via extensioned
7
+ * rather than imported from @earendil-works/pi-ai: pi-ai's index.d.ts (0.99
8
+ * through 1.2.0 alike) re-exports `Model`/`Api` via extensioned
9
9
  * `./types.ts` specifiers, which tsc 5.9 bundler resolution substitutes to
10
10
  * `.d.ts` but some tsserver builds do not. Deriving from ModelRegistry keeps
11
11
  * every resolver in agreement with the exact type
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The component is driven through a small ModelRuntime facade backed by
11
11
  * ctx.modelRegistry (pi's extension-facing wrapper). SAFETY: verified
12
- * against pi 0.87.0–1.1.0 dist (modes/interactive/components/model-selector.js +
12
+ * against pi 0.99.0–1.2.0 dist (modes/interactive/components/model-selector.js +
13
13
  * model-catalog-refresh.js — byte-identical across that span) — the
14
14
  * component only calls getAvailableSnapshot, getModel, getError and refresh
15
15
  * on modelRuntime, and ModelRegistry provides exactly those
@@ -42,7 +42,7 @@ function modelRuntimeFacade(ctx: ExtensionContext): ModelRuntime {
42
42
  refresh: (options?: { signal?: AbortSignal }) =>
43
43
  ctx.modelRegistry.refresh(options),
44
44
  };
45
- // SAFETY: the selector only touches the four members above (pi 0.87–1.1
45
+ // SAFETY: the selector only touches the four members above (pi 0.99–1.2
46
46
  // model-selector.js); everything else on ModelRuntime is never reached
47
47
  // from this component.
48
48
  return facade as unknown as ModelRuntime;
package/src/lib/tools.ts CHANGED
@@ -8,9 +8,11 @@ export function snapshotActiveTools(pi: ExtensionAPI): string[] {
8
8
  return [...pi.getActiveTools()];
9
9
  }
10
10
 
11
- /** Restore a previously snapshotted tool set. No-op when the snapshot is empty. */
12
- export function restoreActiveTools(pi: ExtensionAPI, snapshot: string[]): void {
13
- if (snapshot.length === 0) return;
11
+ /** Restore a previously snapshotted tool set (an empty snapshot = no tools). */
12
+ export function restoreActiveTools(
13
+ pi: ExtensionAPI,
14
+ snapshot: readonly string[],
15
+ ): void {
14
16
  pi.setActiveTools([...snapshot]);
15
17
  }
16
18