@andares/pi-toolkits 0.9.6 → 0.9.8

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
@@ -10,12 +10,12 @@
10
10
  [![Pi package gallery](https://img.shields.io/badge/pi.dev-package%20gallery-6f42c1)](https://pi.dev/packages/@andares/pi-toolkits)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
12
12
  [![Package Manager](https://img.shields.io/badge/package%20manager-pnpm-orange?logo=pnpm)](https://pnpm.io)
13
- [![pi >= 1.0](https://img.shields.io/badge/pi-%3E%3D1.0.0%20%3C1.12.0-blueviolet)](https://github.com/earendil-works/pi)
13
+ [![pi >= 0.99](https://img.shields.io/badge/pi-%3E%3D0.99.0-blueviolet)](https://github.com/earendil-works/pi)
14
14
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
15
15
 
16
16
  In a nutshell: seven ready-to-use capabilities — **read-only consult mode** `ask`, **session-scoped boundary permission** `ask-perm`, **model favorites** `favorites`, **prompt stash** `stash`, **third-party code review** `third-review`, **dedicated compaction model** `compact-model` and **session startup automation** `autostart` — with more to come.
17
17
 
18
- **Requirements**: pi `>=1.0.0 <1.12.0` — the four most recently released minor lines (1.0.x / 1.1.x / 1.2.x / 1.3.x) are supported best-effort, and the upper bound encodes the forward probe: on every host release we check up to **8 minor lines** beyond the newest one (now 1.4 … 1.11) and never claim past that cap. **Measured natural compatibility: 1.3.0** (the newest release at the time of writing — nothing newer exists to probe yet). A single command, `pnpm check:compat`, derives that version set from the registry and runs, per version, in a sandbox under the system temp dir with the whole `@earendil-works/*` family pinned to the version under test: install → `tsc --noEmit` → full test suite → keybinding audit → hash-compare of the host `dist` files this package patches or mirrors. Results over 1.0.0 → 1.3.0 (all 8 published versions): typecheck, 397 tests and the keybinding audit pass on every one; the tracked host files drift only in `core/agent-session.js` (1.3.0 changed the tool-restore path; `cycleModel` untouched), `core/keybindings.js` and `core/compaction/utils.js` (both changed in 1.2.0, table semantics for our keys unchanged), and `extensions/types.d.ts` (additive) — `ModelSelectorComponent`, `core/compaction/compaction.js` and `core/usage-totals.js` are byte-identical across the whole window. Smoke-loading on real pi 1.3.0 produces no warnings. Note: pi ≥ 0.99 hosts require `@earendil-works/pi-tui` to be declared in `peerDependencies` as `"*"` (provided by the host); this package declares it as required.
18
+ **Requirements**: pi `>=0.99.0`. Every published pi release from 0.99.0 through 1.4.0 has been verified with the full gate (install → `tsc --noEmit` → full test suite → keybinding audit → hash compare of the host files this package patches), each in a sandbox with the whole `@earendil-works/*` family pinned to the version under test. Newer releases are verified as they ship and the floor moves only when the measurement says so; `pnpm check:compat` reproduces the result. Note: pi ≥ 0.99 hosts require host-provided packages to be declared in `peerDependencies` with a `"*"` range — `@earendil-works/pi-tui` and `typebox` (the `scratchpad` tool builds a TypeBox schema) are declared that way; an installed copy in `dependencies` would make pi warn.
19
19
 
20
20
  ---
21
21
 
@@ -37,7 +37,7 @@ pi -e ./src/index.ts
37
37
 
38
38
  After installing, verify in any pi session:
39
39
 
40
- - `/ask` → a grey `ask` status appears in the footer, `write`/`edit` are hard-disabled
40
+ - `/ask` → a grey `ask` status appears in the footer, `write`/`edit` are hard-disabled, and a `scratchpad` tool appears for throwaway notes
41
41
  - `/model` (or `ctrl+l`) → a favorites hint line appears at the top of the selector; `ctrl+A` on the selected model → it turns bold bright yellow and the favorites counter goes up (this step is also the real-machine keypress check — see Keybindings)
42
42
  - press `ctrl+alt+y` in the input box → the current prompt is stashed and the input box is cleared
43
43
  - after finishing a development task, type `/third-review` → switches to the review model and runs check + fix
@@ -68,7 +68,7 @@ After installing, verify in any pi session:
68
68
 
69
69
  | Feature | Entry point | One-line description |
70
70
  | --- | --- | --- |
71
- | 🛡️ **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 |
71
+ | 🛡️ **ask** read-only consult mode | `/ask` | enter consult mode: `write`/`edit`/`powershell` hard-disabled, `bash` runs in a read-only sandbox, `grep`/`find`/`ls` activated, and `scratchpad` allowed to write throwaway notes into the session's temp directory |
72
72
  | 🔐 **permission** session-scoped boundary checks | `/ask-perm` | four in/out × read/write modes, off by default and never persisted; outside writes ask, credentials are denied, destructive commands always ask; composes with `/ask` |
73
73
  | ⭐ **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 |
74
74
  | 📥 **stash** prompt stash | `ctrl+alt+y` | a stash slot for one prompt: press to swap, press twice to stash and clear the input box |
@@ -82,17 +82,21 @@ After installing, verify in any pi session:
82
82
 
83
83
  <a name="ask"></a>
84
84
 
85
- Enter with `/ask` (press it again to exit). Meant for the case where you only want to "ask questions and read code", and absolutely never want the model to modify any file.
85
+ Enter with `/ask` (press it again to exit). Meant for the case where you only want to "ask questions and read code": the model may not change the project, but it is not starved of a place to think.
86
86
 
87
87
  **While active**:
88
88
 
89
- - **`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
89
+ - **Project writes stay mechanically impossible** — `write` / `edit` / `powershell` are removed from the active tool set, so pi rejects calls to inactive tools outright ("Tool not found"); the gate blocks them again as defence in depth
90
+ - **One write channel: `scratchpad`** — a tool that takes a *relative* note name (`plan.md`, `notes/api.md`) and can only reach `<os.tmpdir()>/pi-ask/<session-id>`. It is registered inactive (`defaultActive: false`) and activated only in ask mode, so its declaration and its "notes are not deliverables" guidelines are part of the request exactly when they apply. Notes are capped at 256 KiB each, a pre-existing symlinked path component is refused, and nothing written there is ever executed — the bash sandbox needs no write allowance at all. On a host with no writable temp dir the tool stays inactive and the mode degrades to the old fully read-only behaviour (the entry notice says so instead of half-opening the mode)
90
91
  - **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
91
92
  - **`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
92
- - **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
93
- - **Grey `ask` footer status** — a permanent reminder that read-only mode is on
93
+ - **A short system-prompt banner** — three lines: consult only, the scratch channel, the exit path. The bash allowlist is deliberately *not* restated there: the sandbox enforces it and a rejection names the allowed alternative, so that detail costs tokens only on the turn it is needed (the old banner restated it on every request, ~300 tokens). The banner carries no path and no timestamp, so it stays byte-identical for the session and remains a cacheable prefix
94
+ - **Rejections point at the next step** — a blocked `write` / `edit` names `scratchpad` for throwaway notes and `/ask` for a real change; a blocked redirect says the same instead of only "cannot write"
95
+ - **Grey `ask` footer status** — a permanent reminder that consult mode is on
94
96
 
95
- **Exit**: press `/ask` again; the tool-set snapshot taken on entry is fully restored (read-only helpers included).
97
+ **Scratch lifecycle**: one directory per session under the OS temp dir (`os.tmpdir()`, so Windows `%TEMP%` works too), created on entry and kept on exit — a resumed session finds its notes again, and an in-process session switch (/new, /resume, fork) rebinds the directory to the new session id. Directories older than seven days are removed when a session enters ask mode. The ON notice prints the path; nothing in it is part of the project and it never shows up in `git status`.
98
+
99
+ **Exit**: press `/ask` again; the tool-set snapshot taken on entry is fully restored (scratch tool and read-only helpers included).
96
100
 
97
101
  ---
98
102
 
@@ -133,7 +137,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
133
137
  - **credentials are denied in every mode** — `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.pi/agent/auth*.json`, `.env` files (templates excepted). Without this, `out read = allow` would hand out `~/.ssh/id_rsa`.
134
138
  - **destructive commands always ask** — `rm` of `/`, the home directory, or the working directory; `git reset --hard`; `git clean -f`; `git push --force`; package `publish`; `dd` to a raw device; `mkfs*`; shutdown/reboot; `chmod -R 777`; anything through `sudo`. Scope alone cannot express "destructive".
135
139
 
136
- **Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists.
140
+ **Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists. The one exception is ask's `scratchpad`: that tool is self-jailed (relative names only, one temp directory), so ask marks it while active and permission skips the boundary dialog that would otherwise fire for an "outside write" on every note.
137
141
 
138
142
  **Enable it for every session** (the state itself is never persisted — this is the opt-in):
139
143
 
@@ -251,11 +255,11 @@ pi's `/compact` and auto-compact summarize the context **with the current sessio
251
255
  | auto-compact threshold reached | summarized by the compaction model |
252
256
  | context overflow recovery | summarized by the compaction model, retry behavior unchanged |
253
257
 
254
- **Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across every published version of the supported window, 1.0.0 → 1.3.0 — and only the executing model changes; the conversation serializer itself is imported from the host, so it follows whatever the running host does, e.g. 1.2.0 stops feeding assistant thinking back into the summarization request):
258
+ **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 1.1.0 → 1.4.0 apart from the two 1.4.0 fixes adopted here: summaries request the model's output limit instead of a fixed fraction of `reserveTokens`, and `/compact <instructions>` also reaches the split-turn prefix — 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):
255
259
 
256
260
  - **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
257
261
  - **Split behavior** — the `keepRecentTokens` retention policy, and `firstKeptEntryId` / `tokensBefore` passed back unchanged
258
- - **Output budget** — the same formula as the built-in (history segment 0.8×, split-prefix segment 0.5× of `reserveTokens`, then capped by the model's max output)
262
+ - **Output budget** — summaries ask for the model's own output limit, exactly like the built-in since 1.4.0 (#9512: a fixed 0.8×/0.5× `reserveTokens` cap made long reasoning fail the summary with "generation hit the token cap"); `reserveTokens` only decides when compaction starts
259
263
  - **File-list tail** — `<read-files>` / `<modified-files>` tags are appended to the summary as usual, with details maintained in the same shape
260
264
  - **Usage accounting** — the compaction model's tokens/cost are counted in the session statistics
261
265
  - **Cancellable** — `Esc` during compaction cancels cleanly (the signal is passed through to the underlying call)
@@ -332,7 +336,7 @@ An **identical command line** declared by both sources runs once: a duplicate is
332
336
 
333
337
  **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.
334
338
 
335
- > **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.0.0–1.3.0, see the host-compatibility discipline in AGENTS.md). Pre-validation uses `pi.getCommands()` (accepting only `source: "extension"`). Depends on `smol-toml` (zero-dependency, ~10KB, TOML 1.0).
339
+ > **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.1.0–1.4.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).
336
340
 
337
341
  ---
338
342
 
@@ -348,7 +352,7 @@ pnpm typecheck # tsc --noEmit
348
352
  pnpm test # vitest (all feature unit tests)
349
353
 
350
354
  pnpm check:keybindings # audit this plugin's keys against the host's own dispatch table
351
- pnpm check:compat # verify the declared pi range: last 4 minor lines + 8-line forward probe
355
+ pnpm check:compat # mandatory 4-line window + backward probe (4 more lines) → public floor
352
356
  ```
353
357
 
354
358
  ### Project structure
@@ -357,10 +361,12 @@ pnpm check:compat # verify the declared pi range: last 4 minor lines + 8-
357
361
  src/
358
362
  ├── index.ts # entry point: aggregates feature modules
359
363
  ├── lib/ # shared infrastructure (tool-set snapshot/restore,
360
- │ # gate bus composing tool_call policies,
364
+ │ # gate bus composing tool_call policies, jailed-tool
365
+ │ # registry the boundary gate consults,
361
366
  │ # model-key resolution shared by review & compact)
362
367
  └── features/ # feature modules — one directory per feature
363
368
  ├── ask/ # ask mode: command + state machine, bash sandbox,
369
+ │ # scratch notes (temp dir + scratchpad tool),
364
370
  │ # system prompt banner, tests
365
371
  ├── autostart/ # session startup automation: autostart.toml parsing,
366
372
  │ # command pre-validation + replay, tests
@@ -408,7 +414,7 @@ PRs and issues welcome. A few conventions:
408
414
  - **Built-in patches** (favorites) must stay **version-guarded and idempotent** — verify the target prototype members exist before patching, guard against double-apply, degrade with a `console.warn` instead of breaking the extension
409
415
  - **Built-in prompt sync** (compact) — the summarization prompts in `src/features/compact/prompt.ts` are copied verbatim from pi's built-in compaction; when upgrading the pi dependency, diff `dist/core/compaction/compaction.js` (and `utils.js`) and update the strings + assembly helpers to match, keeping takeover output byte-compatible with stock
410
416
  - **Keybindings** — before picking a new shortcut, check pi defaults + reserved keys, terminal control chars, and OS/terminal-emulator grabs; prefer `ctrl+alt+<letter>` for cross-platform safety. Do not stop at "it works on my machine in the main screen": in `fullscreen` mode (pi's default since 1.0) the alt-screen layer consumes `tui.altScreen.*` **before** dialogs see the key, and on Windows/WSL several of those bindings differ from the Linux defaults. Run `pnpm check:keybindings [candidate...]` (and `pnpm check:keybindings --all` after a pi upgrade) — it probes the installed host's resolved keybinding table with the host's own `matchesKey`. A key free in the host table can still be taken at the machine layer: Windows Terminal binds `ctrl+v` (paste) and `ctrl+shift+f` (find), and third-party Windows tools commonly grab `ctrl+alt+<letter>` (a screenshot tool once took `ctrl+alt+f`) — so try the key for real on the target machine before shipping it.
411
- - **Host compatibility** — the peer range is a measured statement, not a wish: the four most recently released pi minor lines are supported best-effort, the upper bound encodes the forward probe cap (newest line + 8 minor lines), and the README annotation must name the highest version actually verified. Re-run `pnpm check:compat` whenever the host dependency moves and read its verdict block (it fails if the declared range drifts from the policy). See the pi 宿主兼容 section of AGENTS.md; keybinding collisions found during an upgrade are reported to the maintainer for a decision, never re-keyed silently.
417
+ - **Host compatibility** — the peer range is a measured statement, not a wish, and it only points backwards: the four most recently released pi minor lines are a mandatory window (fix our code, never raise the floor), below it we probe up to four older lines to find where compatibility actually ends, and the single number users see is the resulting floor (`pi >= <floor>`, no upper bound). Re-run `pnpm check:compat` whenever the host dependency moves and read its verdict block (it fails if the declared floor drifts from the measured one). See the pi 宿主兼容 section of AGENTS.md; keybinding collisions found during an upgrade are reported to the maintainer for a decision, never re-keyed silently.
412
418
  - **Quality gate** — `pnpm typecheck && pnpm test` must pass before submitting
413
419
 
414
420
  ---
package/README.zh-CN.md CHANGED
@@ -10,12 +10,12 @@
10
10
  [![Pi package gallery](https://img.shields.io/badge/pi.dev-package%20gallery-6f42c1)](https://pi.dev/packages/@andares/pi-toolkits)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
12
12
  [![Package Manager](https://img.shields.io/badge/package%20manager-pnpm-orange?logo=pnpm)](https://pnpm.io)
13
- [![pi >= 1.0](https://img.shields.io/badge/pi-%3E%3D1.0.0%20%3C1.12.0-blueviolet)](https://github.com/earendil-works/pi)
13
+ [![pi >= 0.99](https://img.shields.io/badge/pi-%3E%3D0.99.0-blueviolet)](https://github.com/earendil-works/pi)
14
14
  [![GitHub](https://img.shields.io/badge/github-andares%2Fpi--toolkits-181717?logo=github)](https://github.com/andares/pi-toolkits)
15
15
 
16
16
  一句话介绍:目前包含七个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
17
17
 
18
- **版本要求**:pi `>=1.0.0 <1.12.0` —— 最近 4 个中版本线(1.0.x / 1.1.x / 1.2.x / 1.3.x)尽力支持,上界编码「向后探针」的硬上限:宿主每发一版,我们最多向后查 **8 条中版本线**(当前 1.4 … 1.11),再晚不宣称。**实测自然兼容最高 1.3.0**(写此文时的最新版本,更新的还没发布、无物可探)。一条命令 `pnpm check:compat` 从 registry 推导目标集合,逐版本在系统临时目录沙箱中(把整个 `@earendil-works/*` 家族钉在待测版本)跑:安装 → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch/mirror 的宿主 `dist` 文件做哈希比对。1.0.0 → 1.3.0 全部 8 个已发布版本:typecheck、397 个测试、键位审计全绿;被跟踪的宿主文件仅在 `core/agent-session.js`(1.3.0 改了 tool-restore 路径,`cycleModel` 未动)、`core/keybindings.js` 与 `core/compaction/utils.js`(二者都是 1.2.0 变的,我们按键的解析结果不变)、`extensions/types.d.ts`(纯增量)上有差异 —— `ModelSelectorComponent`、`core/compaction/compaction.js` 与 `core/usage-totals.js` 在窗口内字节级不变。真实 pi 1.3.0 冒烟加载无警告。注:pi ≥ 0.99 的宿主要求 `@earendil-works/pi-tui` 声明为 peerDependencies `"*"`(由宿主提供),本包已按要求声明。
18
+ **版本要求**:pi `>=0.99.0`。0.99.0 到 1.4.0 的每一个已发布版本都用完整门禁实测过(install → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch 目标宿主文件做哈希比对),每个版本都在沙箱中把整个 `@earendil-works/*` 家族钉在同一版本。更新的版本随发布逐个验证,下限只在实测结果允许时移动;`pnpm check:compat` 可复现该结论。注:pi ≥ 0.99 的宿主要求「宿主提供的包」一律声明为 peerDependencies `"*"` —— `@earendil-works/pi-tui` 与 `typebox`(ask 的 `scratchpad` 用它构造参数 schema)都按此声明;若放进 `dependencies`,pi 会告警。
19
19
 
20
20
  ---
21
21
 
@@ -37,7 +37,7 @@ pi -e ./src/index.ts
37
37
 
38
38
  安装后在任意 pi 会话中验证:
39
39
 
40
- - `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit` 被硬禁用
40
+ - `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit` 被硬禁用,同时出现 `scratchpad` 工具用于草稿笔记
41
41
  - `/model`(或 `ctrl+l`)→ 选择器顶部出现收藏提示行;对选中模型按 `ctrl+A` → 文字变**加粗亮黄**、收藏计数 +1(这一步同时就是真机按键检查,见「键位」约定)
42
42
  - 输入框内按 `ctrl+alt+y` → 当前提示词被暂存并清空输入框
43
43
  - 完成一个开发任务后输入 `/third-review` → 切换到评审模型执行查+修
@@ -68,7 +68,7 @@ pi -e ./src/index.ts
68
68
 
69
69
  | 功能 | 入口 | 一句话说明 |
70
70
  | --- | --- | --- |
71
- | 🛡️ **ask** 只读咨询模式 | `/ask` | 进入只读问答模式,`write`/`edit`/`powershell` 硬禁用,`bash` 只读沙箱,临时启用 `grep`/`find`/`ls` |
71
+ | 🛡️ **ask** 只读咨询模式 | `/ask` | 进入咨询模式:`write`/`edit`/`powershell` 硬禁用,`bash` 只读沙箱,临时启用 `grep`/`find`/`ls`,并开放 `scratchpad` 把草稿写进会话临时目录 |
72
72
  | 🔐 **permission** 会话级边界权限 | `/ask-perm` | 项目内/外 × 读/写四格模式,默认关闭且从不落盘;项目外写询问、凭据路径硬拒、破坏性命令必问;与 `/ask` 并行组合 |
73
73
  | ⭐ **favorites** 模型收藏 | `/model` 内 `ctrl+A` / `ctrl+J` | 收藏常用模型(加粗亮黄标记),`ctrl+p` 仅在收藏间循环 |
74
74
  | 📥 **stash** 提示词暂存 | `ctrl+alt+y` | 一段提示词的暂存槽,按键交换 / 连按两次暂存并清空输入框 |
@@ -82,17 +82,21 @@ pi -e ./src/index.ts
82
82
 
83
83
  <a name="ask"></a>
84
84
 
85
- 用 `/ask` 进入(再按一次退出)。适合只想要"问问题、读代码",绝不希望模型改动任何文件的场景。
85
+ 用 `/ask` 进入(再按一次退出)。适合只想要「问问题、读代码」的场景:模型不能改项目,但不会被剥夺思考的落点。
86
86
 
87
87
  **进入后**:
88
88
 
89
- - **`write` / `edit` / `powershell` 硬禁用** —— 从活跃工具集中移除,pi 运行时对未激活工具的调用直接拒绝("Tool not found"),文件修改在机制上不可能发生
89
+ - **项目文件写入仍然机制上不可能** —— `write` / `edit` / `powershell` 从活跃工具集中移除,pi 对未激活工具的调用直接拒绝("Tool not found");闸门还会二次拦截,防止有东西把它们重新打开
90
+ - **只留一个写通道:`scratchpad`** —— 参数是**相对**笔记名(`plan.md`、`notes/api.md`),只能落到 `<os.tmpdir()>/pi-ask/<session-id>`。它注册时处于未激活状态(`defaultActive: false`),只在 ask 模式里启用,所以它的声明与「笔记不是交付物」的 guidelines 恰好在适用时才进入请求。单文件上限 256 KiB,路径上已存在的符号链接会被拒绝,里面的东西永不被执行—— bash 沙箱因此完全不需要写权限。宿主没有可写临时目录时工具保持未激活,模式降级为旧的纯只读(进入时的提示会说清楚,不做半开)
90
91
  - **临时启用只读检索工具** —— `grep` / `find` / `ls`(宿主已注册但默认不激活)在 ask 期间打开,让模型有正规搜索工具而不是只能走 bash;退出时按进入前快照完整恢复
91
92
  - **`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`)。启发式,非安全边界
92
- - **系统提示词给出替代方案** —— 追加的模式说明不仅列限制,还写明被拦后用什么替代(读文件用 `read`、取网页用 `curl -s`、要改动则 `/ask` 退出);被拒的工具调用返回分类原因,直接指向最近的可用替代,模型不会盲目重试
93
- - **灰色 `ask` footer 状态** —— 常驻提示当前处于只读模式
93
+ - **短横幅而不是清单** —— 系统提示词只追加三行:咨询模式、草稿通道、退出方式。**不再重述 bash 白名单** —— 那是沙箱的职责,被拦时的拒绝理由会直接指出可用的替代,所以细节只在真用到的那一轮付费(旧横幅每个请求都要重申一遍,约 300 token)。横幅不含路径与时间戳,会话内字节稳定,可以稳定命中 provider 的提示词缓存
94
+ - **拒绝理由指向下一步** —— 被拦的 `write` / `edit` 会说明草稿用 `scratchpad`、真正改动要 `/ask` 退出;被拦的重定向也同样指路,而不是只说「不能写」
95
+ - **灰色 `ask` footer 状态** —— 常驻提示当前处于咨询模式
94
96
 
95
- **退出**:再按 `/ask`,完整恢复进入前的工具集快照(含临时启用的只读工具)。
97
+ **草稿生命周期**:每个会话一个目录,位于系统临时目录(`os.tmpdir()`,所以 Windows 的 `%TEMP%` 也适用);进入时创建、退出时**不删**——续会话(`pi -c`)还能找回笔记,进程内切换会话(/new、/resume、fork)会重新绑定到新会话的目录;进入时清掉超过 7 天的旧目录。开启提示会打印路径;里面的内容不属于项目,也不会出现在 `git status` 里。
98
+
99
+ **退出**:再按 `/ask`,完整恢复进入前的工具集快照(含 scratch 工具与临时启用的只读工具)。
96
100
 
97
101
  ---
98
102
 
@@ -251,11 +255,11 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
251
255
  | auto-compact 阈值触发 | 压缩模型总结 |
252
256
  | context overflow 恢复 | 压缩模型总结,重试行为不变 |
253
257
 
254
- **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在支持区间内全部已发布版本(1.0.0 → 1.3.0)字节级不变,只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
258
+ **接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在 1.1.0 → 1.4.0 内字节级不变 —— 除了此处采纳的两项 1.4.0 修正:摘要请求使用模型自身的输出上限而非 `reserveTokens` 的固定比例,以及 `/compact <instructions>` 也会进入 split-turn 前缀;只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
255
259
 
256
260
  - **摘要格式** —— Goal / Progress / Key Decisions 结构化 checkpoint;已有摘要时用迭代更新版 prompt,信息延续不丢
257
261
  - **切分行为** —— 保留最近 `keepRecentTokens` 的策略、`firstKeptEntryId` / `tokensBefore` 原样回传,不动
258
- - **输出预算** —— 与内建同公式(历史段 0.8×、切分前缀段 0.5× `reserveTokens`,再按模型输出上限收敛)
262
+ - **输出预算** —— 摘要请求使用模型自身的输出上限,与 1.4.0 起的内建一致(#9512:过去按 0.8×/0.5× `reserveTokens` 设上限,长思考会把摘要顶到 "generation hit the token cap" 而失败);`reserveTokens` 只决定何时开始压缩
259
263
  - **文件清单尾部** —— `<read-files>` / `<modified-files>` 标签照常拼在摘要尾部,details 同形状维护
260
264
  - **用量统计** —— 压缩模型的 token/cost 计入 session 统计
261
265
  - **可取消** —— 压缩进行中 `Esc` 干净取消(信号透传到底层调用)
@@ -332,7 +336,7 @@ run = [
332
336
 
333
337
  **关键约束:只支持扩展命令**。`/add-dir`(pi-add-dir)、`/cd`(pi-cd)等 `registerCommand` 注册的命令可以;内置命令(`/compact`、`/reload`、`/resume`…)在 TUI 层分发,扩展层触发不了;skill 命令(`/skill:name`)是展开机制而非执行机制。依赖的目标扩展(如 pi-add-dir)需要先安装。
334
338
 
335
- > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.0.0–1.3.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
339
+ > **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.1.0–1.4.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
336
340
 
337
341
  ---
338
342
 
@@ -348,7 +352,7 @@ pnpm typecheck # tsc --noEmit
348
352
  pnpm test # vitest(全部功能单元测试)
349
353
 
350
354
  pnpm check:keybindings # 用宿主自己的分发表审计本插件的按键
351
- pnpm check:compat # 校验声明的 pi 区间:最近 4 条中版本线 + 向后 8 条线探针
355
+ pnpm check:compat # 校验声明的 pi 下限:强制 4 条线窗口 + 向后 4 条线探针 → public floor
352
356
  ```
353
357
 
354
358
  ### 项目结构
@@ -358,9 +362,11 @@ src/
358
362
  ├── index.ts # 入口:聚合各功能模块
359
363
  ├── lib/ # 共享基础设施(工具集快照/恢复、
360
364
  │ # 组合 tool_call 策略的闸门总线、
365
+ │ # 边界闸门会查询的自封闭工具登记表、
361
366
  │ # review 与 compact 共用的模型 key 解析)
362
367
  └── features/ # 功能模块 —— 一个功能一个目录
363
368
  ├── ask/ # ask 模式:命令 + 状态机、bash 沙箱、
369
+ │ # 草稿空间(临时目录 + scratchpad 工具)、
364
370
  │ # 系统提示词横幅、测试
365
371
  ├── autostart/ # 会话启动自动化:autostart.toml 解析、
366
372
  │ # 命令预校验 + 重放、测试
@@ -406,7 +412,7 @@ pnpm release:github --audit # 列出 npm 版本 vs GitHub Release vs 远端 t
406
412
  - **内建 patch**(favorites)必须保持**版本守卫 + 幂等** —— 打 patch 前先确认目标原型成员存在,防止重复应用,失败时用 `console.warn` 降级而不是把扩展搞挂
407
413
  - **内建提示词同步**(compact) —— `src/features/compact/prompt.ts` 里的总结 prompt 逐字抄自 pi 内建压缩;升级 pi 依赖时 diff `dist/core/compaction/compaction.js`(及 `utils.js`),同步字符串与组装辅助函数,保持接管输出与内建字节级一致
408
414
  - **键位** —— 定新快捷键前先查 pi 默认键位 + 保留键、终端控制字符、OS/终端模拟器占用;跨平台安全优先选 `ctrl+alt+<字母>`。**不得只验证「主屏下我这台机器能用」**:自 pi 1.0 起默认 `fullscreen`,alt-screen 层在对话框看到按键**之前**就消费 `tui.altScreen.*`,且 Windows/WSL 上多个绑定与 Linux 默认值不同。用 `pnpm check:keybindings [候选键...]` 核查(pi 升级后跑 `pnpm check:keybindings --all`),它用宿主自己的 `matchesKey` 探测已安装宿主的**已解析**键位表。**宿主表空闲 ≠ 机器层空闲**:Windows Terminal 默认占 `ctrl+v`(粘贴)与 `ctrl+shift+f`(查找),第三方 Windows 工具常绑 `ctrl+alt+<字母>`(截图工具占过 `ctrl+alt+f`)——定键后必须在真机试按,确认按键真的到得了应用。
409
- - **宿主兼容** —— peer 区间是实测结论而非愿望:最近 4 个已发布中版本线尽力支持,上界编码「向后探 8 条中版本线」的上限,README 必须标注**实测支持到的最高版本**。宿主依赖一动就重跑 `pnpm check:compat`,读它的 verdict 段(区间与策略不一致会直接判失败)。详见 AGENTS.md「pi 宿主兼容」;升级中发现的键位冲突**上报崔总决策**,不得顺手换键。
415
+ - **宿主兼容** —— peer 区间是实测结论而非愿望,而且只往回指:最近 4 个已发布中版本线是**强制窗口**(不兼容就改我们的代码,绝不抬下限),窗口之下再向后探最多 4 条线找出兼容实际停在哪里,对使用者只展示一个数:由此得到的**下限**(`pi >= <floor>`,无上界)。宿主依赖一动就重跑 `pnpm check:compat`,读它的 verdict 段(声明下限与实测不一致会直接判失败)。详见 AGENTS.md「pi 宿主兼容」;升级中发现的键位冲突**上报崔总决策**,不得顺手换键。
410
416
  - **质量门禁** —— 提交前必须通过 `pnpm typecheck && pnpm test`
411
417
 
412
418
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,8 +33,9 @@
33
33
  "boundary"
34
34
  ],
35
35
  "peerDependencies": {
36
- "@earendil-works/pi-coding-agent": ">=1.0.0 <1.12.0",
37
- "@earendil-works/pi-tui": "*"
36
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
37
+ "@earendil-works/pi-tui": "*",
38
+ "typebox": "*"
38
39
  },
39
40
  "dependencies": {
40
41
  "@dreki-gg/pi-command-sandbox": "^0.4.3",
@@ -42,10 +43,11 @@
42
43
  "smol-toml": "^1.9.0"
43
44
  },
44
45
  "devDependencies": {
45
- "@earendil-works/pi-ai": "^1.3.0",
46
- "@earendil-works/pi-coding-agent": "^1.3.0",
47
- "@earendil-works/pi-tui": "^1.3.0",
46
+ "@earendil-works/pi-ai": "^1.4.0",
47
+ "@earendil-works/pi-coding-agent": "^1.4.0",
48
+ "@earendil-works/pi-tui": "^1.4.0",
48
49
  "@types/node": "^26.6.2",
50
+ "typebox": "^1.3.27",
49
51
  "typescript": "^5.6.0",
50
52
  "vitest": "^4.1.10"
51
53
  },
@@ -8,6 +8,11 @@
8
8
  * `write`/`edit` are the obvious ones. `powershell` is a shell too: pi
9
9
  * registers it on every platform (inactive by default, enabled by settings),
10
10
  * and it would otherwise hand ask mode a full write channel.
11
+ *
12
+ * The one write channel ask mode keeps is its own tool, {@link ASK_SCRATCH_TOOL}:
13
+ * it takes a relative note name and can only reach the session's scratch
14
+ * directory (see scratch.ts), so it needs no path check from the model and no
15
+ * write allowance in the bash sandbox.
11
16
  */
12
17
  export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
13
18
  "write",
@@ -23,6 +28,13 @@ export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
23
28
  */
24
29
  export const ASK_READ_ONLY_HELPERS: readonly string[] = ["grep", "find", "ls"];
25
30
 
31
+ /**
32
+ * The scratch-note tool. Registered inactive (`defaultActive: false`) and
33
+ * activated only while ask mode is on, so its declaration and
34
+ * `promptGuidelines` are part of the request exactly when they apply.
35
+ */
36
+ export const ASK_SCRATCH_TOOL = "scratchpad";
37
+
26
38
  /** Footer status key (extension-scoped) and label. */
27
39
  export const ASK_STATUS_KEY = "ask-mode";
28
40
  export const ASK_STATUS_LABEL = "ask";
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * ask mode over the gate bus.
3
3
  *
4
- * The feature's command/state behaviour predates the bus; what this covers is
5
- * the migration itself: while ask mode is on, its gate blocks the tools it
6
- * confiscates and non-read-only bash, and it stops speaking once the mode is
7
- * off.
4
+ * Covers the state machine (tool snapshot, scratch tool activation, jailed-tool
5
+ * registration), the gate's verdicts, and the degradation path when the session
6
+ * has no writable temp directory.
8
7
  */
9
- import { describe, expect, it } from "vitest";
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ import { beforeEach, describe, expect, it } from "vitest";
10
12
  import type {
11
13
  ExtensionAPI,
12
14
  ExtensionContext,
13
15
  ToolCallEvent,
14
16
  } from "@earendil-works/pi-coding-agent";
15
17
  import type { GateHub, ToolGate } from "../../lib/gates.js";
18
+ import { isJailedTool, resetJailedTools } from "../../lib/tools.js";
19
+ import { ASK_SCRATCH_TOOL } from "./constants.js";
16
20
  import { registerAsk } from "./index.js";
17
21
 
18
22
  function event(toolName: string, input: Record<string, unknown> = {}): ToolCallEvent {
@@ -24,7 +28,11 @@ function event(toolName: string, input: Record<string, unknown> = {}): ToolCallE
24
28
  } as unknown as ToolCallEvent;
25
29
  }
26
30
 
27
- function makeHarness() {
31
+ function tempBase(): string {
32
+ return mkdtempSync(join(tmpdir(), "pi-ask-mode-"));
33
+ }
34
+
35
+ function makeHarness(options: { scratchBaseDir?: string } = {}) {
28
36
  const captured: ToolGate[] = [];
29
37
  const hub = {
30
38
  register(gate: ToolGate) {
@@ -36,19 +44,27 @@ function makeHarness() {
36
44
 
37
45
  let activeTools = ["read", "write", "edit", "bash"];
38
46
  let commandHandler: ((args: string, ctx: ExtensionContext) => unknown) | undefined;
47
+ let registeredTool: { defaultActive?: boolean; name: string } | undefined;
48
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => unknown>();
39
49
  const pi = {
40
- on: () => {},
50
+ on(name: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
51
+ handlers.set(name, handler);
52
+ },
41
53
  registerCommand(
42
54
  name: string,
43
55
  options: { handler: (args: string, ctx: ExtensionContext) => unknown },
44
56
  ) {
45
57
  if (name === "ask") commandHandler = options.handler;
46
58
  },
59
+ registerTool(tool: { defaultActive?: boolean; name: string }) {
60
+ registeredTool = tool;
61
+ },
47
62
  getActiveTools: () => [...activeTools],
48
63
  getAllTools: () => [
49
64
  { name: "grep" },
50
65
  { name: "find" },
51
66
  { name: "ls" },
67
+ { name: ASK_SCRATCH_TOOL },
52
68
  ],
53
69
  setActiveTools: (names: string[]) => {
54
70
  activeTools = [...names];
@@ -56,41 +72,61 @@ function makeHarness() {
56
72
  } as unknown as ExtensionAPI;
57
73
 
58
74
  const statuses: Array<[string, string | undefined]> = [];
59
- const notes: string[] = [];
75
+ const notes: Array<{ message: string; kind?: string }> = [];
76
+ let sessionId = "session-1";
60
77
  const ctx = {
61
78
  hasUI: true,
62
79
  mode: "tui",
63
80
  ui: {
64
- notify: (message: string) => notes.push(message),
81
+ notify: (message: string, kind?: string) => notes.push({ message, kind }),
65
82
  setStatus: (key: string, value: string | undefined) => statuses.push([key, value]),
66
83
  theme: { fg: (_color: string, text: string) => text },
67
84
  },
85
+ sessionManager: { getSessionId: () => sessionId },
68
86
  } as unknown as ExtensionContext;
69
87
 
70
- registerAsk(pi, hub);
88
+ registerAsk(pi, hub, options);
71
89
  const gate = captured[0];
72
90
  if (!gate || !commandHandler) throw new Error("ask did not register");
73
91
 
74
92
  return {
75
93
  gate,
76
94
  ctx,
95
+ notes,
77
96
  activeTools: () => activeTools,
97
+ registeredTool: () => registeredTool,
98
+ setSessionId: (id: string) => {
99
+ sessionId = id;
100
+ },
101
+ sessionStart: async () => {
102
+ await handlers.get("session_start")?.({ reason: "new" }, ctx);
103
+ },
78
104
  toggle: async () => {
79
105
  await commandHandler?.("", ctx);
80
106
  },
81
107
  evaluate: async (toolName: string, input: Record<string, unknown> = {}) =>
82
108
  gate.evaluate(event(toolName, input), ctx),
109
+ banner: () =>
110
+ handlers.get("before_agent_start")?.(
111
+ { type: "before_agent_start", systemPrompt: "base" },
112
+ ctx,
113
+ ) as { systemPrompt: string } | undefined,
83
114
  };
84
115
  }
85
116
 
117
+ beforeEach(() => {
118
+ // The jail registry is module state; one test's mark must not leak into the next.
119
+ resetJailedTools();
120
+ });
121
+
86
122
  describe("ask gate", () => {
87
123
  it("has no opinion while ask mode is off", async () => {
88
- const h = makeHarness();
124
+ const h = makeHarness({ scratchBaseDir: tempBase() });
89
125
  expect(await h.evaluate("write", { path: "x" })).toBeUndefined();
90
126
  });
91
127
 
92
128
  it("blocks confiscated tools and non-read-only bash while on", async () => {
93
- const h = makeHarness();
129
+ const h = makeHarness({ scratchBaseDir: tempBase() });
94
130
  await h.toggle();
95
131
  expect(await h.evaluate("write", { path: "x" })).toMatchObject({
96
132
  kind: "block",
@@ -102,17 +138,124 @@ describe("ask gate", () => {
102
138
  });
103
139
 
104
140
  it("stops blocking after exiting", async () => {
105
- const h = makeHarness();
141
+ const h = makeHarness({ scratchBaseDir: tempBase() });
106
142
  await h.toggle();
107
143
  await h.toggle();
108
144
  expect(await h.evaluate("write", { path: "x" })).toBeUndefined();
109
145
  });
146
+ });
147
+
148
+ describe("ask tool set", () => {
149
+ it("registers the scratch tool inactive", async () => {
150
+ const h = makeHarness({ scratchBaseDir: tempBase() });
151
+ expect(h.registeredTool()?.name).toBe(ASK_SCRATCH_TOOL);
152
+ expect(h.registeredTool()?.defaultActive).toBe(false);
153
+ });
154
+
155
+ it("activates scratchpad and marks it jailed only while on", async () => {
156
+ const h = makeHarness({ scratchBaseDir: tempBase() });
157
+ await h.toggle();
158
+ expect(h.activeTools()).toContain(ASK_SCRATCH_TOOL);
159
+ expect(isJailedTool(ASK_SCRATCH_TOOL)).toBe(true);
160
+
161
+ await h.toggle();
162
+ expect(h.activeTools()).not.toContain(ASK_SCRATCH_TOOL);
163
+ expect(isJailedTool(ASK_SCRATCH_TOOL)).toBe(false);
164
+ });
110
165
 
111
- it("restores the tool snapshot on exit", async () => {
112
- const h = makeHarness();
166
+ it("restores the entry snapshot on exit", async () => {
167
+ const h = makeHarness({ scratchBaseDir: tempBase() });
113
168
  await h.toggle();
114
169
  expect(h.activeTools()).not.toContain("write");
115
170
  await h.toggle();
116
171
  expect(h.activeTools()).toEqual(["read", "write", "edit", "bash"]);
117
172
  });
173
+
174
+ it("adds the read-only helpers that were inactive", async () => {
175
+ const h = makeHarness({ scratchBaseDir: tempBase() });
176
+ await h.toggle();
177
+ expect(h.activeTools()).toEqual(
178
+ expect.arrayContaining(["grep", "find", "ls"]),
179
+ );
180
+ });
181
+ });
182
+
183
+ describe("ask scratch availability", () => {
184
+ it("creates the scratch directory under the configured base", async () => {
185
+ const base = tempBase();
186
+ const h = makeHarness({ scratchBaseDir: base });
187
+ await h.toggle();
188
+ expect(h.notes.at(-1)?.message).toContain(resolve(base));
189
+ });
190
+
191
+ it("degrades to fully read-only when the temp dir is unusable", async () => {
192
+ const base = tempBase();
193
+ const file = join(base, "not-a-dir");
194
+ writeFileSync(file, "x");
195
+ const h = makeHarness({ scratchBaseDir: file });
196
+ await h.toggle();
197
+
198
+ expect(h.activeTools()).not.toContain(ASK_SCRATCH_TOOL);
199
+ expect(isJailedTool(ASK_SCRATCH_TOOL)).toBe(false);
200
+ expect(h.notes.at(-1)?.message).toContain("read-only");
201
+ const warning = h.notes.find((note) => note.kind === "warning");
202
+ expect(warning?.message).toContain("no scratch space");
203
+ // Still a consult mode: the hard block is unchanged.
204
+ expect(await h.evaluate("write", { path: "x" })).toMatchObject({
205
+ kind: "block",
206
+ });
207
+ expect(h.banner()?.systemPrompt).not.toContain(ASK_SCRATCH_TOOL);
208
+ });
209
+ });
210
+
211
+ describe("ask banner", () => {
212
+ it("names the scratch channel while it exists", async () => {
213
+ const h = makeHarness({ scratchBaseDir: tempBase() });
214
+ await h.toggle();
215
+ const banner = h.banner()?.systemPrompt ?? "";
216
+ expect(banner).toContain("[ASK MODE]");
217
+ expect(banner).toContain(ASK_SCRATCH_TOOL);
218
+ expect(banner).toContain("/ask");
219
+ });
220
+
221
+ it("stays silent when the mode is off", () => {
222
+ const h = makeHarness({ scratchBaseDir: tempBase() });
223
+ expect(h.banner()).toBeUndefined();
224
+ });
225
+ });
226
+
227
+ describe("ask session rebinding", () => {
228
+ it("rebinds the scratch directory when the session changes while on", async () => {
229
+ const base = tempBase();
230
+ const h = makeHarness({ scratchBaseDir: base });
231
+ await h.toggle();
232
+ const firstNotice = h.notes.at(-1)?.message ?? "";
233
+ expect(firstNotice).toContain("notes go to");
234
+
235
+ h.setSessionId("session-2");
236
+ await h.sessionStart();
237
+
238
+ const rebind = h.notes.at(-1)?.message ?? "";
239
+ expect(rebind).toContain("notes now go to");
240
+ // The two notices name different per-session directories.
241
+ const firstRoot = firstNotice.match(/notes go to (.+)\. \//)?.[1];
242
+ const reboundRoot = rebind.match(/notes now go to (.+)\./)?.[1];
243
+ expect(firstRoot).toBeDefined();
244
+ expect(reboundRoot).toBeDefined();
245
+ expect(reboundRoot).not.toBe(firstRoot);
246
+ });
247
+
248
+ it("keeps the scratch directory on a repeated emission for the same session", async () => {
249
+ const h = makeHarness({ scratchBaseDir: tempBase() });
250
+ await h.toggle();
251
+ const noteCount = h.notes.length;
252
+ await h.sessionStart();
253
+ expect(h.notes.length).toBe(noteCount);
254
+ });
255
+
256
+ it("does nothing while the mode is off", async () => {
257
+ const h = makeHarness({ scratchBaseDir: tempBase() });
258
+ await h.sessionStart();
259
+ expect(h.notes).toHaveLength(0);
260
+ });
118
261
  });