@andares/pi-toolkits 0.11.3 → 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 CHANGED
@@ -13,7 +13,7 @@
13
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
- In a nutshell: seven ready-to-use capabilities — **read-only consult mode** `ask`, **session-scoped boundary permission** `ask-perm`, **model favorites** `favorites`, **prompt stash** `stash`, **third-party code review** `third-review`, **dedicated compaction model** `compact-model` and **session startup automation** `autostart` — with more to come.
16
+ 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
  [![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
- 一句话介绍:目前包含七个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
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.11.3",
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("置为 null"),
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
  });
@@ -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: 压缩摘要为空,已回落 pi 默认压缩。",
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: 压缩摘要失败(${message}),已回落 pi 默认压缩。`,
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: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-compact.json 手动配置。",
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: 配置的压缩模型 ${configured} 不存在或不可用,请重新选择。`,
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: 已取消,压缩模型配置未变更。清除配置请编辑 <agent-dir>/pi-toolkits-compact.json(把 model 置为 null)。",
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: 压缩模型已设置为「${model.name}」,后续压缩总结将由它执行。`,
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: 配置的压缩模型 ${configured} 不存在或不可用,已回落 pi 默认压缩。`,
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;
@@ -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;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * quit-guard feature — watchdog state machine.
3
+ *
4
+ * Deliberately dependency-free and side-effect free: it is fed stdout chunks
5
+ * and clock/scheduler callbacks, so every branch is unit-testable without a
6
+ * real TUI. The wiring to `process.stdout`, `SIGCONT`, the settings store and
7
+ * the actual `process.exit()` lives in index.ts.
8
+ *
9
+ * State machine:
10
+ *
11
+ * idle ──teardown marker──▶ armed ──timer──▶ fired (onFire, then exit)
12
+ * ▲ │
13
+ * └──restart marker / resume┘
14
+ *
15
+ * Invariants:
16
+ * - arming is idempotent while armed (a teardown writes two markers);
17
+ * - a TUI restart (mode switch, Ctrl+Z resume) always wins over the timer:
18
+ * the fire callback is deferred one turn so a SIGCONT handler can disarm
19
+ * first, and the token check drops a fire whose arm was superseded;
20
+ * - disabled means inert: it never arms and disarms immediately.
21
+ */
22
+ import { RESTART_MARKERS, TEARDOWN_MARKERS } from "./constants.js";
23
+
24
+ /** A chunk as written to `process.stdout` (string, or Buffer/typed array). */
25
+ export type GuardChunk = string | Uint8Array;
26
+
27
+ export interface GuardFireInfo {
28
+ /** Clock value captured when the guard armed. */
29
+ startedAt: number;
30
+ /** Milliseconds spent armed (i.e. inside the shutdown phase). */
31
+ elapsedMs: number;
32
+ /** The teardown sequence that armed the guard. */
33
+ marker: string;
34
+ }
35
+
36
+ export interface QuitGuardDeps {
37
+ now?: () => number;
38
+ /** Start a timer; returns a handle handed back to `cancel`. */
39
+ schedule?: (run: () => void, ms: number) => unknown;
40
+ cancel?: (handle: unknown) => void;
41
+ /**
42
+ * Defer one turn (setImmediate in production). Used so an expired timer
43
+ * cannot beat a SIGCONT-driven disarm on the resume path.
44
+ */
45
+ defer?: (run: () => void) => void;
46
+ /** Called exactly once per arm that reaches the deadline. */
47
+ onFire: (info: GuardFireInfo) => void;
48
+ /** Optional debug tap (PI_QUIT_GUARD_DEBUG); never affects behavior. */
49
+ onEvent?: (event: "arm" | "disarm" | "fire", detail: string) => void;
50
+ /**
51
+ * Second, independent "is a TUI still running" signal, checked right before
52
+ * firing. The teardown marker can also arrive inside rendered content (a
53
+ * file or tool output that happens to contain the escape sequence); raw
54
+ * mode cannot: the TUI leaves raw mode while tearing down. An active
55
+ * session must never be killed by a stray marker.
56
+ */
57
+ isTuiActive?: () => boolean;
58
+ }
59
+
60
+ /** First marker in `markers` that appears in the chunk, if any. */
61
+ export function findMarker(
62
+ chunk: GuardChunk,
63
+ markers: readonly string[],
64
+ ): string | undefined {
65
+ if (typeof chunk === "string") {
66
+ if (chunk.length === 0) return undefined;
67
+ for (const marker of markers) {
68
+ if (chunk.includes(marker)) return marker;
69
+ }
70
+ return undefined;
71
+ }
72
+ if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) {
73
+ return undefined;
74
+ }
75
+ // Buffers are the normal case; other typed arrays are rare enough that a
76
+ // one-off copy is cheaper than a hand-rolled byte scan.
77
+ const buffer: Buffer = Buffer.isBuffer(chunk)
78
+ ? chunk
79
+ : Buffer.from(chunk);
80
+ for (const marker of markers) {
81
+ if (buffer.includes(marker)) return marker;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ export class QuitGuard {
87
+ private readonly now: () => number;
88
+ private readonly schedule: (run: () => void, ms: number) => unknown;
89
+ private readonly cancel: (handle: unknown) => void;
90
+ private readonly defer: (run: () => void) => void;
91
+ private readonly onFire: (info: GuardFireInfo) => void;
92
+ private readonly onEvent?: (event: "arm" | "disarm" | "fire", detail: string) => void;
93
+ private readonly isTuiActive?: () => boolean;
94
+
95
+ private enabled = true;
96
+ private timeoutMs: number;
97
+ private armed = false;
98
+ private handle: unknown;
99
+ private startedAt = 0;
100
+ private armedBy = "";
101
+ /** Incremented on every arm; a stale fire callback compares against it. */
102
+ private armToken = 0;
103
+
104
+ constructor(timeoutMs: number, deps: QuitGuardDeps) {
105
+ this.timeoutMs = timeoutMs;
106
+ // performance.now() is monotonic: a wall-clock jump (NTP, WSL VM
107
+ // resume, manual `date`) must not make a 10 s deadline look like 7 s,
108
+ // and `elapsedMs` in the hang report must measure real time spent.
109
+ this.now = deps.now ?? (() => performance.now());
110
+ this.schedule =
111
+ deps.schedule ??
112
+ ((run, ms) => setTimeout(run, ms));
113
+ this.cancel =
114
+ deps.cancel ??
115
+ ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
116
+ this.defer = deps.defer ?? ((run) => setImmediate(run));
117
+ this.onFire = deps.onFire;
118
+ this.onEvent = deps.onEvent;
119
+ this.isTuiActive = deps.isTuiActive;
120
+ }
121
+
122
+ /** Feed one stdout chunk. Converts markers into arm/disarm transitions. */
123
+ observeOutput(chunk: GuardChunk): void {
124
+ if (!this.enabled) return;
125
+ if (this.armed) {
126
+ // While armed the only meaningful signal is "the TUI came back".
127
+ if (findMarker(chunk, RESTART_MARKERS)) {
128
+ this.disarm();
129
+ }
130
+ return;
131
+ }
132
+ const marker = findMarker(chunk, TEARDOWN_MARKERS);
133
+ if (marker) this.arm(marker);
134
+ }
135
+
136
+ /** Process resumed from Ctrl+Z: the TUI is about to re-enter, never kill it. */
137
+ resume(): void {
138
+ this.disarm();
139
+ }
140
+
141
+ setEnabled(enabled: boolean): void {
142
+ this.enabled = enabled;
143
+ if (!enabled) this.disarm();
144
+ }
145
+
146
+ isEnabled(): boolean {
147
+ return this.enabled;
148
+ }
149
+
150
+ setTimeoutMs(timeoutMs: number): void {
151
+ this.timeoutMs = timeoutMs;
152
+ }
153
+
154
+ getTimeoutMs(): number {
155
+ return this.timeoutMs;
156
+ }
157
+
158
+ isArmed(): boolean {
159
+ return this.armed;
160
+ }
161
+
162
+ private arm(marker: string): void {
163
+ const token = (this.armToken += 1);
164
+ const startedAt = this.now();
165
+ this.armed = true;
166
+ this.startedAt = startedAt;
167
+ this.armedBy = marker;
168
+ this.onEvent?.("arm", `${marker} timeout=${this.timeoutMs}ms`);
169
+ this.handle = this.schedule(
170
+ () => this.onTimeout(token, startedAt),
171
+ this.timeoutMs,
172
+ );
173
+ }
174
+
175
+ private disarm(): void {
176
+ if (!this.armed) return;
177
+ this.armed = false;
178
+ this.onEvent?.("disarm", this.armedBy);
179
+ if (this.handle !== undefined) {
180
+ this.cancel(this.handle);
181
+ this.handle = undefined;
182
+ }
183
+ }
184
+
185
+ private onTimeout(token: number, startedAt: number): void {
186
+ this.handle = undefined;
187
+ // One turn later: a pending SIGCONT handler runs before this and can
188
+ // disarm, so resume always wins over an expired timer.
189
+ this.defer(() => {
190
+ if (!this.armed || this.armToken !== token || this.startedAt !== startedAt) {
191
+ return;
192
+ }
193
+ // The liveness probe is best-effort: if it throws (stdin already
194
+ // destroyed/replaced during teardown), treat the TUI as gone and
195
+ // still fire. A probe that can throw must not disable the watchdog.
196
+ let tuiActive = false;
197
+ try {
198
+ tuiActive = this.isTuiActive?.() === true;
199
+ } catch {
200
+ tuiActive = false;
201
+ }
202
+ if (tuiActive) {
203
+ // A live TUI means the marker was content, not a teardown.
204
+ this.armed = false;
205
+ this.onEvent?.("disarm", "tui-still-active");
206
+ return;
207
+ }
208
+ this.armed = false;
209
+ const elapsedMs = this.now() - startedAt;
210
+ this.onEvent?.("fire", `elapsed=${Math.round(elapsedMs)}ms`);
211
+ this.onFire({ startedAt, elapsedMs, marker: this.armedBy });
212
+ });
213
+ }
214
+ }