@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 +10 -9
- package/README.zh-CN.md +10 -9
- package/package.json +5 -5
- package/src/features/ask/bash-sandbox.test.ts +133 -2
- package/src/features/ask/bash-sandbox.ts +221 -17
- package/src/features/ask/constants.ts +16 -1
- package/src/features/ask/index.ts +39 -19
- package/src/features/ask/prompt.test.ts +45 -0
- package/src/features/ask/prompt.ts +51 -12
- package/src/features/ask/reasons.test.ts +35 -0
- package/src/features/ask/reasons.ts +68 -0
- package/src/features/autostart/index.ts +2 -1
- package/src/features/compact/index.ts +8 -4
- package/src/features/compact/prompt.ts +11 -6
- package/src/features/favorites/constants.ts +4 -3
- package/src/features/favorites/patch-cycle.ts +8 -7
- package/src/features/favorites/patch-selector.ts +4 -4
- package/src/features/stash/stash.ts +1 -1
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +2 -2
- package/src/lib/tools.ts +5 -3
package/README.md
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@andares/pi-toolkits)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://pnpm.io)
|
|
12
|
-
[](https://github.com/earendil-works/pi)
|
|
13
13
|
[](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.
|
|
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
|
-
-
|
|
87
|
-
-
|
|
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 —
|
|
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`;
|
|
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
|
[](https://www.npmjs.com/package/@andares/pi-toolkits)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
[](https://pnpm.io)
|
|
12
|
-
[](https://github.com/earendil-works/pi)
|
|
13
13
|
[](https://github.com/andares/pi-toolkits)
|
|
14
14
|
|
|
15
15
|
一句话介绍:目前包含六个开箱即用的功能——**只读咨询模式** `ask`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
|
|
16
16
|
|
|
17
|
-
**版本要求**:pi `>=0.
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
40
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
41
|
-
"@earendil-works/pi-tui": "^1.
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
17
|
-
// curl -o FILE
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
53
|
+
/^\s*wget\b[^|;&]*\s-O\s+(?!-)\S+/,
|
|
25
54
|
// dd of=FILE
|
|
26
|
-
|
|
55
|
+
/^\s*dd\b[^|;&]*\sof=/i,
|
|
27
56
|
// tee FILE (writes to stdout AND file)
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
81
|
-
// 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 (
|
|
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 =
|
|
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 {
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* wholesale systemPrompt replacement (see
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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(
|
|
21
|
-
|
|
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
|
-
* (
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/src/lib/model-config.ts
CHANGED
|
@@ -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.
|
|
8
|
-
* through 1.
|
|
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
|
package/src/lib/model-picker.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
12
|
-
export function restoreActiveTools(
|
|
13
|
-
|
|
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
|
|