@andares/pi-toolkits 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -1
- package/README.zh-CN.md +45 -1
- package/package.json +1 -1
- package/src/features/compact/compact.test.ts +60 -5
- package/src/features/compact/index.ts +9 -8
- package/src/features/favorites/constants.ts +2 -1
- package/src/features/favorites/favorites.test.ts +36 -0
- package/src/features/favorites/patch-cycle.ts +3 -3
- package/src/features/favorites/patch-selector.ts +1 -1
- package/src/features/quit-guard/constants.ts +60 -0
- package/src/features/quit-guard/guard.ts +214 -0
- package/src/features/quit-guard/index.ts +292 -0
- package/src/features/quit-guard/quit-guard.test.ts +736 -0
- package/src/features/quit-guard/report.ts +212 -0
- package/src/features/quit-guard/store.ts +144 -0
- package/src/features/review/index.ts +11 -10
- package/src/features/review/prompt.ts +20 -15
- package/src/features/review/review.test.ts +9 -9
- package/src/index.ts +3 -0
- package/src/lib/approval-log.ts +8 -1
- package/src/lib/model-picker.test.ts +6 -4
- package/src/lib/model-picker.ts +7 -5
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](https://github.com/earendil-works/pi)
|
|
14
14
|
[](https://github.com/andares/pi-toolkits)
|
|
15
15
|
|
|
16
|
-
In a nutshell:
|
|
16
|
+
In a nutshell: nine 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`, **session startup automation** `autostart`, an **agent memory framework** `ask-init`, and a **quit watchdog** `quit-guard` — with more to come.
|
|
17
17
|
|
|
18
18
|
**Requirements**: pi `>=0.99.0`. Every published pi release from 0.99.0 through 1.5.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
|
|
|
@@ -57,6 +57,7 @@ After installing, verify in any pi session:
|
|
|
57
57
|
- [🗜️ compact — dedicated compaction model](#compact)
|
|
58
58
|
- [🚀 autostart — session startup automation](#autostart)
|
|
59
59
|
- [🧠 init — agent memory framework](#init)
|
|
60
|
+
- [🧯 quit-guard — quit watchdog](#quit-guard)
|
|
60
61
|
- [Development](#development)
|
|
61
62
|
- [Contributing](#contributing)
|
|
62
63
|
- [License](#license)
|
|
@@ -77,6 +78,7 @@ After installing, verify in any pi session:
|
|
|
77
78
|
| 🗜️ **compact** dedicated compaction model | `/compact-model` | give `/compact` and auto-compact a cheap fast model to summarize with, leaving the main model free to focus on the conversation; zero overhead when unconfigured, falls back to pi's default on any failure |
|
|
78
79
|
| 🚀 **autostart** session startup automation | `.pi/autostart.toml` | declare slash commands to run automatically at session start, per project (first use case: mounting multi-repo directories with `/add-dir`); never runs in untrusted directories, unregistered commands are skipped instead of being sent to the model |
|
|
79
80
|
| 🧠 **init** agent memory framework | `/ask-init` | install or adopt a fixed memory layout (`AGENTS.md` + `specs/` + `.agents/memory` + `.agents/implementations`), migrate an existing one with a snapshot and a coverage table, and audit it later for drift, budgets and duplicated facts |
|
|
81
|
+
| 🧯 **quit-guard** quit watchdog | `/quit-guard` | force-exit when pi's shutdown hangs after the TUI is gone (an extension `session_shutdown` that never settles), write a hang report, and give the shell prompt back; TUI restarts and Ctrl+Z resume disarm it |
|
|
80
82
|
|
|
81
83
|
---
|
|
82
84
|
|
|
@@ -451,6 +453,77 @@ published); text written *into* a project follows that project's language.
|
|
|
451
453
|
> framework version lives in `.agents/framework/state.toml`; a project whose state
|
|
452
454
|
> version is newer than the installed tool is never migrated.
|
|
453
455
|
|
|
456
|
+
## 🧯 quit-guard — quit watchdog
|
|
457
|
+
|
|
458
|
+
<a name="quit-guard"></a>
|
|
459
|
+
|
|
460
|
+
**The problem.** pi's interactive `/quit` stops the TUI and then awaits every extension's
|
|
461
|
+
`session_shutdown` handler, one after another, **with no timeout**
|
|
462
|
+
(`runtimeHost.dispose()` → `emitSessionShutdownEvent()`; upstream
|
|
463
|
+
[issue #9997](https://github.com/earendil-works/pi/issues/9997), still unbounded in 1.5.0 and on
|
|
464
|
+
`main`). One handler that never settles leaves pi alive at 0% CPU *after* the terminal has
|
|
465
|
+
already been restored and the final frame reprinted: the screen looks like a frozen "exit
|
|
466
|
+
screen", and the shell prompt never comes back. Ctrl+C and SIGTERM did not end the hung process
|
|
467
|
+
in our measurements (SIGKILL did); once that phase starts, the host has no way out of it.
|
|
468
|
+
Measured with a 20-extension setup (2026-11-05): a healthy quit spends 0.1–3 s in that phase,
|
|
469
|
+
`pi -ne` 0.12 s — and one of three quits with the same setup never finished at all.
|
|
470
|
+
|
|
471
|
+
**What this does.** The guard watches the terminal instead of the event stream, so it still
|
|
472
|
+
works when the handler that hangs was loaded *before* this package (in that case our own
|
|
473
|
+
`session_shutdown` would never run). It arms on the TUI's teardown sequence
|
|
474
|
+
(`\x1b[?2004l` / `\x1b[?1049l`); if the process is still alive `timeoutMs` later, it writes a
|
|
475
|
+
hang report and forces the exit. A TUI that comes back disarms it — an interactive mode switch
|
|
476
|
+
(`/settings` → TUI mode) and Ctrl+Z suspend/resume both re-enter the TUI, and a live session
|
|
477
|
+
must never be killed.
|
|
478
|
+
|
|
479
|
+
### Commands
|
|
480
|
+
|
|
481
|
+
| Command | Effect |
|
|
482
|
+
| --- | --- |
|
|
483
|
+
| `/quit-guard` | status: on/off, timeout, report on/off, newest hang report |
|
|
484
|
+
| `/quit-guard on` / `/quit-guard off` | enable/disable, persisted |
|
|
485
|
+
| `/quit-guard <ms>` | set the deadline (clamped to 2000–60000 ms), persisted |
|
|
486
|
+
|
|
487
|
+
**Configuration** — `<agent-dir>/pi-toolkits-quit-guard.json`, written by the commands; every
|
|
488
|
+
field is optional:
|
|
489
|
+
|
|
490
|
+
```json
|
|
491
|
+
{
|
|
492
|
+
"enabled": true,
|
|
493
|
+
"timeoutMs": 10000,
|
|
494
|
+
"report": true
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`timeoutMs` counts **from the teardown write**, not from the moment you press Enter, so the time
|
|
499
|
+
the TUI spends stopping is not charged against a slow-but-finishing shutdown. Keep it below your
|
|
500
|
+
slowest healthy quit; 10 s leaves a wide margin (the slowest healthy quit measured above was 5 s
|
|
501
|
+
end to end, of which ~2 s came after the teardown).
|
|
502
|
+
|
|
503
|
+
**Hang reports** — `<agent-dir>/quit-guard/quit-hang-<timestamp>-<pid>.json` records the
|
|
504
|
+
arm/fire timing, the teardown marker, and the process state at the deadline (`activeHandles`,
|
|
505
|
+
`activeRequests`, and the host's libuv handle table). An empty `libuv` table next to a live
|
|
506
|
+
process is the signature of a promise nobody will ever resolve, as opposed to a blocked socket
|
|
507
|
+
or a lingering child process. The file is owner-only (0600 in an 0700 directory) and process
|
|
508
|
+
arguments are redacted with the same rules as the approval log, so `pi --api-key …` does not
|
|
509
|
+
leave the key on disk.
|
|
510
|
+
|
|
511
|
+
`PI_QUIT_GUARD_DEBUG=<file>` appends one line per `arm` / `disarm` / `fire` (plus one per
|
|
512
|
+
extension registration); once the TUI is gone it is the only channel left.
|
|
513
|
+
|
|
514
|
+
### Boundaries
|
|
515
|
+
|
|
516
|
+
- Interactive TUI only. Print (`-p`), JSON, and RPC sessions never write the teardown
|
|
517
|
+
sequences, so the guard stays inert there.
|
|
518
|
+
- The deadline covers the **post-teardown** phase. A shutdown that hangs before the TUI stops
|
|
519
|
+
(not observed; `drainInput` is bounded at 1 s) is out of scope.
|
|
520
|
+
- When it fires, the remaining extension cleanup is skipped **by design** — that cleanup is what
|
|
521
|
+
was hanging. Session entries are appended to the session JSONL as they happen, so the
|
|
522
|
+
transcript is intact up to the last written entry. The process exits 0; if `process.exit()`
|
|
523
|
+
itself is turned into a no-op by an `exit` listener, a 500 ms fallback sends `SIGKILL`.
|
|
524
|
+
- This is a workaround for a host gap. If pi ever bounds `session_shutdown`, the guard becomes
|
|
525
|
+
a no-op safety net and can be removed.
|
|
526
|
+
|
|
454
527
|
---
|
|
455
528
|
|
|
456
529
|
## 🛠️ Development
|
|
@@ -493,6 +566,8 @@ src/
|
|
|
493
566
|
├── permission/ # session-scoped boundary permission: modes, panel,
|
|
494
567
|
│ # bash units, policy, session grants, prompt,
|
|
495
568
|
│ # audit payload, tests
|
|
569
|
+
├── quit-guard/ # quit watchdog: terminal-teardown trigger, settings
|
|
570
|
+
│ # store, hang report, tests
|
|
496
571
|
├── review/ # third-party review: input/command triggers,
|
|
497
572
|
│ # review-model config store, prompt, tests
|
|
498
573
|
└── stash/ # prompt stash: hotkey state machine + tests
|
package/README.zh-CN.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](https://github.com/earendil-works/pi)
|
|
14
14
|
[](https://github.com/andares/pi-toolkits)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
一句话介绍:目前包含九个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`、**agent 记忆框架** `ask-init`、**退出看门狗** `quit-guard`,后续持续追加。
|
|
17
17
|
|
|
18
18
|
**版本要求**:pi `>=0.99.0`。0.99.0 到 1.5.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
|
|
|
@@ -57,6 +57,7 @@ pi -e ./src/index.ts
|
|
|
57
57
|
- [🗜️ compact — 独立压缩模型](#compact)
|
|
58
58
|
- [🚀 autostart — 会话启动自动化](#autostart)
|
|
59
59
|
- [🧠 init — agent 记忆框架](#init)
|
|
60
|
+
- [🧯 quit-guard — 退出看门狗](#quit-guard)
|
|
60
61
|
- [开发](#development)
|
|
61
62
|
- [贡献](#contributing)
|
|
62
63
|
- [许可](#license)
|
|
@@ -77,6 +78,7 @@ pi -e ./src/index.ts
|
|
|
77
78
|
| 🗜️ **compact** 独立压缩模型 | `/compact-model` | 给 `/compact` 与 auto-compact 配一个便宜快模型做总结,主模型专注对话;未配置零开销、任何异常回落 pi 默认 |
|
|
78
79
|
| 🚀 **autostart** 会话启动自动化 | `.pi/autostart.toml` | 项目内声明启动时自动执行的斜杠命令(首批用例:`/add-dir` 多仓库挂载);未信任不执行、未注册命令跳过不发给模型 |
|
|
79
80
|
| 🧠 **init** agent 记忆框架 | `/ask-init` | 安装或接管一套固定的记忆结构(`AGENTS.md` + `specs/` + `.agents/memory` + `.agents/implementations`),带快照与覆盖表迁移旧文档,之后可随时审计漂移、预算与重复事实 |
|
|
81
|
+
| 🧯 **quit-guard** 退出看门狗 | `/quit-guard` | TUI 已停、进程却卡在扩展 `session_shutdown` 上时,超时后写诊断报告并强制退出,把命令行提示符还给你;TUI 重启(切换显示模式)与 Ctrl+Z 恢复都会解除看守 |
|
|
80
82
|
|
|
81
83
|
---
|
|
82
84
|
|
|
@@ -435,6 +437,46 @@ run = [
|
|
|
435
437
|
> **跑完记得 `/reload`**,让新的 `AGENTS.md` 进入提示词。框架版本记在
|
|
436
438
|
> `.agents/framework/state.toml`;若项目的状态版本比当前工具新,则拒绝迁移。
|
|
437
439
|
|
|
440
|
+
## 🧯 quit-guard — 退出看门狗
|
|
441
|
+
|
|
442
|
+
<a name="quit-guard"></a>
|
|
443
|
+
|
|
444
|
+
**要解决的问题**。pi 的 `/quit` 先停 TUI,然后**逐个 await** 每个扩展的 `session_shutdown` 处理器,且**没有任何超时**(`runtimeHost.dispose()` → `emitSessionShutdownEvent()`;上游
|
|
445
|
+
[issue #9997](https://github.com/earendil-works/pi/issues/9997),1.5.0 与 `main` 至今未修)。只要有一个处理器永不 settle,pi 就会在终端已还原、最后一帧已重绘之后,以 0% CPU 永远挂着:画面看起来像「停在退出界面」,提示符再也不回来;实测 Ctrl+C 与 SIGTERM 都无法结束这个进程(只有 SIGKILL 有效)——进了这段收尾,宿主自己也没有出口。20 个扩展的环境实测(2026-11-05):健康退出在这段收尾上花 0.1–3 秒,`pi -ne` 0.12 秒;同一环境三次 `/quit` 就有一次**永远不结束**。
|
|
446
|
+
|
|
447
|
+
**这个功能做什么**。看守盯的是**终端写出的收尾序列**,而不是事件流——所以即使卡死的处理器比本包**先**加载(那样我们自己的 `session_shutdown` 根本不会运行),它照样能触发。它在 TUI 收尾序列(`\x1b[?2004l` / `\x1b[?1049l`)出现时开始计时;若 `timeoutMs` 之后进程还活着,就写一份诊断报告并强制退出。TUI **回来**时自动解除:`/settings` 里切换 TUI 显示模式、Ctrl+Z 挂起后 `fg` 恢复,都会让 TUI 重新进入,绝不能误杀活着的会话。
|
|
448
|
+
|
|
449
|
+
### 命令
|
|
450
|
+
|
|
451
|
+
| 命令 | 作用 |
|
|
452
|
+
| --- | --- |
|
|
453
|
+
| `/quit-guard` | 查看状态:开关、超时、报告开关、最近一份诊断报告 |
|
|
454
|
+
| `/quit-guard on` / `/quit-guard off` | 开启/关闭,并持久化 |
|
|
455
|
+
| `/quit-guard <ms>` | 设置超时(钳制在 2000–60000 ms),并持久化 |
|
|
456
|
+
|
|
457
|
+
**配置** —— `<agent-dir>/pi-toolkits-quit-guard.json`,由命令写入;所有字段都可省略:
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{
|
|
461
|
+
"enabled": true,
|
|
462
|
+
"timeoutMs": 10000,
|
|
463
|
+
"report": true
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
`timeoutMs` 从**收尾序列写出**那一刻开始算,不是从你按下 Enter 开始算——TUI 自己停下来的时间不计入,所以「慢但能完成」的收尾(记忆库 flush、MCP 关闭)不会被误杀。把它设在「你最慢的健康退出」之上;10 秒余量很大(上例最慢的健康退出端到端 5 秒,其中约 2 秒发生在收尾之后)。
|
|
468
|
+
|
|
469
|
+
**诊断报告** —— `<agent-dir>/quit-guard/quit-hang-<时间戳>-<pid>.json`,记录开始计时/触发的时间、触发用的收尾序列,以及截止时进程的状态(`activeHandles`、`activeRequests`、宿主 libuv 句柄表)。**进程还活着但 `libuv` 表是空的**,就是「有个 Promise 永远不会 settle」的特征,区别于阻塞的 socket 或残留子进程。报告文件**仅本人可读**(0700 目录下的 0600 文件),进程参数按审批日志同一套规则脱敏,所以 `pi --api-key …` 不会把密钥留在盘上。
|
|
470
|
+
|
|
471
|
+
`PI_QUIT_GUARD_DEBUG=<文件>` 会把每次 `arm` / `disarm` / `fire`(以及每次扩展注册)追加一行;TUI 已经没了的时候,这是唯一还能观察的通道。
|
|
472
|
+
|
|
473
|
+
### 边界
|
|
474
|
+
|
|
475
|
+
- 只覆盖交互式 TUI。`-p`(print)、JSON、RPC 模式不会写这些收尾序列,看守在那里完全不生效。
|
|
476
|
+
- 计时覆盖的是**收尾之后**的阶段;在 TUI 停下之前就卡住的情况(未观察到;`drainInput` 上限 1 秒)不在范围内。
|
|
477
|
+
- 触发时**按设计跳过**剩余扩展清理——挂住的就是它。会话条目是随发生随写入 JSONL 的,所以最后一条已写入的记录不会丢。正常路径退出码 0;若 `process.exit()` 本身被 `exit` 监听器变成空操作,500 ms 后会补一记 `SIGKILL`。
|
|
478
|
+
- 这是对宿主缺口的权宜之计:哪天 pi 给 `session_shutdown` 加上限时,看守就退化成无害的保险,可以直接删掉。
|
|
479
|
+
|
|
438
480
|
---
|
|
439
481
|
|
|
440
482
|
## 🛠️ 开发
|
|
@@ -476,6 +518,8 @@ src/
|
|
|
476
518
|
├── permission/ # 会话级边界权限:模式、面板、bash 拆解、
|
|
477
519
|
│ # 策略、会话授权、确认对话框、
|
|
478
520
|
│ # 审计 payload、测试
|
|
521
|
+
├── quit-guard/ # 退出看门狗:终端收尾触发、配置存储、
|
|
522
|
+
│ # 诊断报告、测试
|
|
479
523
|
├── review/ # 第三方评审:输入/指令触发、
|
|
480
524
|
│ # 评审模型配置存储、提示词、测试
|
|
481
525
|
└── stash/ # 提示词暂存:热键状态机 + 测试
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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, a dedicated compaction model, and an agent memory framework (/ask-init)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -296,7 +296,7 @@ describe("pickCompactModel", () => {
|
|
|
296
296
|
const { ctx, notify } = fakeCtx({ models: [] });
|
|
297
297
|
await pickCompactModel(ctx, getCompactStore());
|
|
298
298
|
expect(notify).toHaveBeenCalledWith(
|
|
299
|
-
expect.stringContaining("
|
|
299
|
+
expect.stringContaining("No models are available"),
|
|
300
300
|
"error",
|
|
301
301
|
);
|
|
302
302
|
});
|
|
@@ -308,7 +308,7 @@ describe("pickCompactModel", () => {
|
|
|
308
308
|
expect(model).toBeUndefined();
|
|
309
309
|
expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
|
|
310
310
|
expect(notify).toHaveBeenCalledWith(
|
|
311
|
-
expect.stringContaining("
|
|
311
|
+
expect.stringContaining("set model to null"),
|
|
312
312
|
"info",
|
|
313
313
|
);
|
|
314
314
|
});
|
|
@@ -326,7 +326,7 @@ describe("pickCompactModel", () => {
|
|
|
326
326
|
await pickCompactModel(ctx, getCompactStore());
|
|
327
327
|
expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
|
|
328
328
|
expect(notify).toHaveBeenCalledWith(
|
|
329
|
-
expect.stringContaining("
|
|
329
|
+
expect.stringContaining("cancelled"),
|
|
330
330
|
"info",
|
|
331
331
|
);
|
|
332
332
|
});
|
|
@@ -336,7 +336,7 @@ describe("pickCompactModel", () => {
|
|
|
336
336
|
const { ctx, notify } = fakeCtx({ customResult: MODELS[0] });
|
|
337
337
|
await pickCompactModel(ctx, getCompactStore());
|
|
338
338
|
expect(notify).toHaveBeenCalledWith(
|
|
339
|
-
expect.stringContaining("
|
|
339
|
+
expect.stringContaining("does not exist or is unavailable"),
|
|
340
340
|
"warning",
|
|
341
341
|
);
|
|
342
342
|
expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
|
|
@@ -451,7 +451,7 @@ describe("session_before_compact handler", () => {
|
|
|
451
451
|
)) as SessionBeforeCompactResult | undefined;
|
|
452
452
|
expect(result).toBeUndefined();
|
|
453
453
|
expect(notify).toHaveBeenCalledWith(
|
|
454
|
-
expect.stringContaining("
|
|
454
|
+
expect.stringContaining("came back empty"),
|
|
455
455
|
"warning",
|
|
456
456
|
);
|
|
457
457
|
});
|
|
@@ -525,6 +525,61 @@ describe("session_before_compact handler", () => {
|
|
|
525
525
|
expect(compaction.usage?.input).toBe(107);
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
+
it("keeps the cost provider-reported only when both split-turn parts report it", async () => {
|
|
529
|
+
// 1.5.0's combineUsage rule end-to-end: the merged compaction usage, not
|
|
530
|
+
// just the helper. A part that reports no provider cost is enough to make
|
|
531
|
+
// the sum "not provider-reported"; only both parts reporting it flips it
|
|
532
|
+
// to true (asserted below on a second run). The flag is cost provenance,
|
|
533
|
+
// not the token sum.
|
|
534
|
+
getCompactStore().setModel("google/gemini-2.5-flash");
|
|
535
|
+
const { handler } = registeredHandler();
|
|
536
|
+
const withFlag = (flag: boolean | undefined) => {
|
|
537
|
+
const usage = fakeUsage();
|
|
538
|
+
if (flag !== undefined) usage.cost.totalFromProvider = flag;
|
|
539
|
+
return usage;
|
|
540
|
+
};
|
|
541
|
+
const { ctx } = fakeCtx({
|
|
542
|
+
complete: vi.fn(async (_model, context) => {
|
|
543
|
+
const text = (context.messages[0]?.content as Array<{ text?: string }>)[0]
|
|
544
|
+
?.text;
|
|
545
|
+
return text?.includes("# Instructions")
|
|
546
|
+
? fakeResponse("TURN PREFIX", { usage: withFlag(true) })
|
|
547
|
+
: fakeResponse("HISTORY", { usage: withFlag(undefined) });
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
550
|
+
const event = fakeEvent({
|
|
551
|
+
turnPrefixMessages: [userMessage("prefix msg")],
|
|
552
|
+
isSplitTurn: true,
|
|
553
|
+
});
|
|
554
|
+
const result = (await handler(
|
|
555
|
+
event as never,
|
|
556
|
+
ctx,
|
|
557
|
+
)) as SessionBeforeCompactResult | undefined;
|
|
558
|
+
// Read through the structural view, not the host's `CompleteUsage`:
|
|
559
|
+
// `cost.totalFromProvider` only exists on pi ≥ 1.5.0's types, so reading
|
|
560
|
+
// it directly from the host type breaks typecheck on the 1.2–1.4 window
|
|
561
|
+
// even though the runtime field we write is the same.
|
|
562
|
+
const mergedUsage = result!.compaction!.usage as unknown as UsageLike;
|
|
563
|
+
expect(mergedUsage.cost.totalFromProvider).toBe(false);
|
|
564
|
+
|
|
565
|
+
// Same split turn, but both summaries report the cost themselves → true.
|
|
566
|
+
const both = fakeCtx({
|
|
567
|
+
complete: vi.fn(async (_model, context) => {
|
|
568
|
+
const text = (context.messages[0]?.content as Array<{ text?: string }>)[0]
|
|
569
|
+
?.text;
|
|
570
|
+
return text?.includes("# Instructions")
|
|
571
|
+
? fakeResponse("TURN PREFIX", { usage: withFlag(true) })
|
|
572
|
+
: fakeResponse("HISTORY", { usage: withFlag(true) });
|
|
573
|
+
}),
|
|
574
|
+
});
|
|
575
|
+
const bothResult = (await handler(
|
|
576
|
+
event as never,
|
|
577
|
+
both.ctx,
|
|
578
|
+
)) as SessionBeforeCompactResult | undefined;
|
|
579
|
+
const bothUsage = bothResult!.compaction!.usage as unknown as UsageLike;
|
|
580
|
+
expect(bothUsage.cost.totalFromProvider).toBe(true);
|
|
581
|
+
});
|
|
582
|
+
|
|
528
583
|
it("keeps the previous summary as history text on split turns with no messages", async () => {
|
|
529
584
|
getCompactStore().setModel("google/gemini-2.5-flash");
|
|
530
585
|
const { handler } = registeredHandler();
|
|
@@ -217,7 +217,7 @@ async function runCompactModelCompaction(
|
|
|
217
217
|
if (!summaryText.trim()) {
|
|
218
218
|
if (!signal.aborted) {
|
|
219
219
|
ctx.ui.notify(
|
|
220
|
-
"compact-model:
|
|
220
|
+
"compact-model: the summary came back empty — falling back to pi's default compaction.",
|
|
221
221
|
"warning",
|
|
222
222
|
);
|
|
223
223
|
}
|
|
@@ -244,7 +244,7 @@ async function runCompactModelCompaction(
|
|
|
244
244
|
const message =
|
|
245
245
|
error instanceof Error ? error.message : String(error);
|
|
246
246
|
ctx.ui.notify(
|
|
247
|
-
`compact-model:
|
|
247
|
+
`compact-model: summarization failed (${message}) — falling back to pi's default compaction.`,
|
|
248
248
|
"error",
|
|
249
249
|
);
|
|
250
250
|
return undefined;
|
|
@@ -261,7 +261,7 @@ export async function pickCompactModel(
|
|
|
261
261
|
): Promise<AvailableModel | undefined> {
|
|
262
262
|
if (!ctx.hasUI) {
|
|
263
263
|
ctx.ui.notify(
|
|
264
|
-
"compact-model:
|
|
264
|
+
"compact-model: this environment has no model picker dialog — edit <agent-dir>/pi-toolkits-compact.json to configure it manually.",
|
|
265
265
|
"error",
|
|
266
266
|
);
|
|
267
267
|
return undefined;
|
|
@@ -270,16 +270,17 @@ export async function pickCompactModel(
|
|
|
270
270
|
const configuredModel = resolveConfiguredModel(ctx, configured);
|
|
271
271
|
if (!configuredModel && configured) {
|
|
272
272
|
ctx.ui.notify(
|
|
273
|
-
`compact-model:
|
|
273
|
+
`compact-model: the configured compact model ${configured} does not exist or is unavailable — pick another one.`,
|
|
274
274
|
"warning",
|
|
275
275
|
);
|
|
276
276
|
}
|
|
277
277
|
const model = await openModelPicker(ctx, configuredModel, {
|
|
278
|
-
selectTitle:
|
|
278
|
+
selectTitle:
|
|
279
|
+
"Select the compaction model (first entry is the current default; press Enter to use it)",
|
|
279
280
|
});
|
|
280
281
|
if (!model) {
|
|
281
282
|
ctx.ui.notify(
|
|
282
|
-
"compact-model:
|
|
283
|
+
"compact-model: cancelled — the compact model is unchanged. To clear the setting, edit <agent-dir>/pi-toolkits-compact.json and set model to null.",
|
|
283
284
|
"info",
|
|
284
285
|
);
|
|
285
286
|
return undefined;
|
|
@@ -290,7 +291,7 @@ export async function pickCompactModel(
|
|
|
290
291
|
store.setModel(key);
|
|
291
292
|
}
|
|
292
293
|
ctx.ui.notify(
|
|
293
|
-
`compact-model:
|
|
294
|
+
`compact-model: compact model set to "${model.name}" — it will run the next compaction summary.`,
|
|
294
295
|
"info",
|
|
295
296
|
);
|
|
296
297
|
return model;
|
|
@@ -310,7 +311,7 @@ export function registerCompact(pi: ExtensionAPI): void {
|
|
|
310
311
|
const model = resolveConfiguredModel(ctx, configured);
|
|
311
312
|
if (!model) {
|
|
312
313
|
ctx.ui.notify(
|
|
313
|
-
`compact-model:
|
|
314
|
+
`compact-model: the configured compact model ${configured} does not exist or is unavailable — falling back to pi's default compaction.`,
|
|
314
315
|
"warning",
|
|
315
316
|
);
|
|
316
317
|
return undefined;
|
|
@@ -29,7 +29,8 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
|
|
|
29
29
|
* (0.87–0.99 defaulted to `regular`, where the binding is inert). On
|
|
30
30
|
* Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
|
|
31
31
|
* `TuiAltScreen.handleViewportInput()` checks it before
|
|
32
|
-
* `
|
|
32
|
+
* `shouldDeferViewportInput()` (named `shouldDeferViewportInputToOverlay()`
|
|
33
|
+
* through 1.4.0), so the keystroke opens transcript
|
|
33
34
|
* search and the selector never sees it (verified in pi-tui 1.5.0
|
|
34
35
|
* `dist/tui-alt-screen.js`: the search check is still the first key branch
|
|
35
36
|
* and still returns `{ consume: true }` unconditionally; the 1.5.0 changes to
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
setSharedFavoritesStore,
|
|
40
40
|
} from "./store.js";
|
|
41
41
|
import { applyCyclePatch } from "./patch-cycle.js";
|
|
42
|
+
import { styleFavorite } from "./constants.js";
|
|
42
43
|
import {
|
|
43
44
|
applyModelSelectorPatches,
|
|
44
45
|
setSelectorThemeProvider,
|
|
@@ -505,5 +506,40 @@ describe("selector patches", () => {
|
|
|
505
506
|
expect(renamedLine).toContain(
|
|
506
507
|
"(MiniMax M3 [rasmus@openrouter, parasail])",
|
|
507
508
|
);
|
|
509
|
+
// The full line pins the 1.5.0 column order (id → name → provider badge)
|
|
510
|
+
// and the favorite highlight not leaking ANSI codes into the name. The
|
|
511
|
+
// two spaces after → are the (empty) current-marker column; the fake
|
|
512
|
+
// selector has no currentModel, so no ✓ is drawn.
|
|
513
|
+
expect(redundantLine).toBe("→ claude-sonnet-4 [anthropic]");
|
|
514
|
+
expect(renamedLine).toBe(
|
|
515
|
+
" minimax/minimax-m3 (MiniMax M3 [rasmus@openrouter, parasail]) [openrouter]",
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("keeps the favorite highlight while appending the disambiguating name", () => {
|
|
520
|
+
// Regression guard: the 1.5.0 name text must land AFTER the styled id
|
|
521
|
+
// (and outside its ANSI reset), or favorited ids would lose their color.
|
|
522
|
+
const favoriteModel = {
|
|
523
|
+
provider: "anthropic",
|
|
524
|
+
id: "claude-opus-4",
|
|
525
|
+
name: "Claude Opus 4 (max)",
|
|
526
|
+
};
|
|
527
|
+
getFavoritesStore().toggle(favoriteModel);
|
|
528
|
+
const lines: string[] = [];
|
|
529
|
+
const selector = fakeSelector({
|
|
530
|
+
activeModels: [],
|
|
531
|
+
filteredModels: [
|
|
532
|
+
{ provider: favoriteModel.provider, id: favoriteModel.id, model: favoriteModel },
|
|
533
|
+
] as unknown as FakeSelector["filteredModels"],
|
|
534
|
+
listContainer: {
|
|
535
|
+
clear() {},
|
|
536
|
+
addChild: (child: unknown) =>
|
|
537
|
+
lines.push((child as { text?: string }).text ?? ""),
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
selector.updateList();
|
|
541
|
+
expect(lines[0]).toBe(
|
|
542
|
+
`→ ${styleFavorite("claude-opus-4")} (Claude Opus 4 (max)) [anthropic]`,
|
|
543
|
+
);
|
|
508
544
|
});
|
|
509
545
|
});
|
|
@@ -59,7 +59,7 @@ interface CycleSessionInternals {
|
|
|
59
59
|
setDefaultModelAndProvider(provider: string, id: string): void;
|
|
60
60
|
};
|
|
61
61
|
/**
|
|
62
|
-
* pi 1.0–1.
|
|
62
|
+
* pi 1.0–1.5: keeps a persisted default usable when cycling inside a
|
|
63
63
|
* non-empty --models scope.
|
|
64
64
|
*/
|
|
65
65
|
_addPersistedDefaultToNonEmptyScope(model: unknown): void;
|
|
@@ -142,7 +142,7 @@ export function applyCyclePatch(): boolean {
|
|
|
142
142
|
return original.call(this, direction, options);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
// Same source and availability filtering as pi's stock cycling (1.0–1.
|
|
145
|
+
// Same source and availability filtering as pi's stock cycling (1.0–1.5).
|
|
146
146
|
const scoped = this._scopedModels.length > 0;
|
|
147
147
|
let candidates: CycleCandidate[];
|
|
148
148
|
if (scoped) {
|
|
@@ -187,7 +187,7 @@ export function applyCyclePatch(): boolean {
|
|
|
187
187
|
: this._getThinkingLevelForModelSwitch();
|
|
188
188
|
this.agent.state.model = next.model;
|
|
189
189
|
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
|
190
|
-
// pi 1.0–1.
|
|
190
|
+
// pi 1.0–1.5: only persist the default model when the caller asked for it.
|
|
191
191
|
if (options.persist) {
|
|
192
192
|
this.settingsManager.setDefaultModelAndProvider(
|
|
193
193
|
next.model.provider,
|
|
@@ -149,7 +149,7 @@ let patched = false;
|
|
|
149
149
|
export function applyModelSelectorPatches(): boolean {
|
|
150
150
|
if (patched) return true;
|
|
151
151
|
// SAFETY: ModelSelectorComponent's members are TS-private (compile-time
|
|
152
|
-
// only); the structural view matches pi 1.0–1.
|
|
152
|
+
// only); the structural view matches pi 1.0–1.5's prototype shape
|
|
153
153
|
// (byte-identical across that span) and every touched method is
|
|
154
154
|
// existence-checked below before any patching happens.
|
|
155
155
|
const proto =
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quit-guard feature — constants and the rationale behind them.
|
|
3
|
+
*
|
|
4
|
+
* WHY a terminal-sequence trigger instead of `session_shutdown`: pi runs
|
|
5
|
+
* extension shutdown handlers one after another without a timeout. If a
|
|
6
|
+
* handler loaded BEFORE ours never settles, our own `session_shutdown` never
|
|
7
|
+
* runs, so a watchdog armed from that event would miss exactly the hangs it
|
|
8
|
+
* is meant to catch. The teardown sequence below is written by the TUI
|
|
9
|
+
* before that phase starts, so it arms the guard regardless of extension
|
|
10
|
+
* load order (and even when the host's own writes are what hangs).
|
|
11
|
+
*
|
|
12
|
+
* Teardown markers (arm):
|
|
13
|
+
* - `\x1b[?2004l` — bracketed paste off, written by `ProcessTerminal.stop()`
|
|
14
|
+
* (both `regular` and `fullscreen` modes);
|
|
15
|
+
* - `\x1b[?1049l` — alternate-screen exit, written by the fullscreen
|
|
16
|
+
* renderer's `afterTerminalStop()`.
|
|
17
|
+
*
|
|
18
|
+
* Restart markers (disarm): a TUI that re-enters must never be killed by a
|
|
19
|
+
* timer meant for a process that is leaving:
|
|
20
|
+
* - `\x1b[?2004h` — bracketed paste on, `ProcessTerminal.start()`;
|
|
21
|
+
* - `\x1b[?1049h` — alternate-screen enter, fullscreen `beforeTerminalStart()`.
|
|
22
|
+
*
|
|
23
|
+
* These cover the two legitimate teardown-then-restart paths: an interactive
|
|
24
|
+
* TUI mode switch (`switchTuiMode()` stops the old renderer with
|
|
25
|
+
* `preserveScreen`, then starts the new one) and Ctrl+Z suspend/resume
|
|
26
|
+
* (`handleCtrlZ()` calls `ui.stop()`, then `ui.start()` on SIGCONT). The
|
|
27
|
+
* SIGCONT listener in index.ts disarms synchronously as well, so an expired
|
|
28
|
+
* timer cannot win a race against a resume.
|
|
29
|
+
*
|
|
30
|
+
* Scope: interactive TUI only. Print/JSON/RPC sessions never write these
|
|
31
|
+
* sequences, so the guard stays inert there (documented boundary).
|
|
32
|
+
*/
|
|
33
|
+
export const QUIT_GUARD_FILE = "pi-toolkits-quit-guard.json";
|
|
34
|
+
|
|
35
|
+
/** Directory holding the JSON reports written when the guard fires. */
|
|
36
|
+
export const QUIT_GUARD_REPORT_DIR = "quit-guard";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default watchdog deadline, measured from the terminal teardown write.
|
|
40
|
+
*
|
|
41
|
+
* Measured on a 20-extension setup (2026-11-05): a healthy `/quit` spends
|
|
42
|
+
* ~0.1–3 s between the teardown write and `process.exit(0)` (the whole
|
|
43
|
+
* `runtimeHost.dispose()` / extension-shutdown phase), and 0.12 s with
|
|
44
|
+
* `pi -ne`. 10 s leaves a wide margin for a slow-but-finishing shutdown
|
|
45
|
+
* (e.g. a memory flush or an MCP server close) while still returning the
|
|
46
|
+
* prompt quickly when something never settles.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
49
|
+
|
|
50
|
+
/** Floor for `/quit-guard <ms>`: below this, a healthy slow quit would be cut. */
|
|
51
|
+
export const MIN_TIMEOUT_MS = 2_000;
|
|
52
|
+
|
|
53
|
+
/** Ceiling for `/quit-guard <ms>`: beyond this the user is better off with `kill -9`. */
|
|
54
|
+
export const MAX_TIMEOUT_MS = 60_000;
|
|
55
|
+
|
|
56
|
+
/** Sequences after which the interactive TUI has begun tearing down. */
|
|
57
|
+
export const TEARDOWN_MARKERS = ["\x1b[?2004l", "\x1b[?1049l"] as const;
|
|
58
|
+
|
|
59
|
+
/** Sequences that mean a TUI is starting (mode switch or suspend/resume). */
|
|
60
|
+
export const RESTART_MARKERS = ["\x1b[?2004h", "\x1b[?1049h"] as const;
|