@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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * quit-guard feature — hang artifact.
3
+ *
4
+ * When the watchdog fires, extension cleanup was cut short. The artifact is
5
+ * the only evidence of what the process was waiting on, so it is written
6
+ * synchronously *before* `process.exit(0)` and never throws: a failed report
7
+ * must not keep the user from getting the prompt back.
8
+ *
9
+ * Contents: the arm/fire timing, the process state (`activeHandles` /
10
+ * `activeRequests` class names from Node's undocumented introspection helpers),
11
+ * and the host's own libuv handle table (`process.report.getReport().libuv`).
12
+ * Together they are enough to tell "waiting on a socket" from "waiting on a
13
+ * thin promise that nobody will ever resolve" — the latter shows up as an
14
+ * empty handle table with pi's extension-shutdown frames missing.
15
+ *
16
+ * Privacy: `argv` and `cwd` are process facts, not conversation data, but they
17
+ * are still credential-adjacent (`pi --api-key …`, a prompt passed as an
18
+ * argument, a path under a private tree). The file is therefore owner-only
19
+ * (0600 in an 0700 directory, matching the approval log) and every argument
20
+ * goes through the same redaction rules the approval log uses.
21
+ */
22
+ import { mkdirSync, writeFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { redactText } from "../../lib/approval-log.js";
25
+
26
+ export interface HangReport {
27
+ version: 1;
28
+ kind: "quit-guard";
29
+ /** ISO timestamp of the forced exit. */
30
+ timestamp: string;
31
+ pid: number;
32
+ /** Watchdog deadline that was configured at arm time. */
33
+ timeoutMs: number;
34
+ /** Time actually spent inside the shutdown phase. */
35
+ elapsedMs: number;
36
+ /** The teardown sequence that armed the guard. */
37
+ marker: string;
38
+ uptimeSeconds: number;
39
+ cwd: string;
40
+ /** Process arguments, redacted (see the privacy note above). */
41
+ argv: string[];
42
+ /** Constructor names of handles keeping the event loop alive. */
43
+ activeHandles: string[];
44
+ /** Node's pending-request list (fs, dns, zlib, ... callbacks). */
45
+ activeRequests: string[];
46
+ /**
47
+ * Host libuv handle table (trimmed). Empty but non-null means "nothing is
48
+ * pending that the OS knows about": the process is parked on a promise.
49
+ */
50
+ libuv: unknown[];
51
+ }
52
+
53
+ interface ProcessIntrospection {
54
+ _getActiveHandles?: () => unknown[];
55
+ _getActiveRequests?: () => unknown[];
56
+ report?: { getReport?: () => { libuv?: unknown[] } };
57
+ }
58
+
59
+ export interface HangReportInput {
60
+ timeoutMs: number;
61
+ elapsedMs: number;
62
+ marker: string;
63
+ }
64
+
65
+ export interface HangReportOptions {
66
+ now?: () => Date;
67
+ pid?: number;
68
+ cwd?: string;
69
+ argv?: string[];
70
+ }
71
+
72
+ /** Stand-in for `process.cwd()` when the working directory no longer exists. */
73
+ const UNKNOWN_CWD = "<unknown: working directory unavailable>";
74
+
75
+ /**
76
+ * Flags whose *next* argument is a credential. `--api-key` with a bare opaque
77
+ * token is the common shape and matches none of the value-prefix rules, so the
78
+ * pair has to be recognised positionally.
79
+ */
80
+ const SECRET_ARG =
81
+ /^--?(?:api[-_]?key|key|token|access[-_]?token|refresh[-_]?token|auth[-_]?token|secret|client[-_]?secret|password|passwd)$/i;
82
+
83
+ /** Redact one argument list: flag/value pairs first, then the value patterns. */
84
+ export function redactArgv(argv: readonly string[]): string[] {
85
+ let hideNext = false;
86
+ return argv.map((arg) => {
87
+ if (hideNext) {
88
+ hideNext = false;
89
+ return "[redacted]";
90
+ }
91
+ if (SECRET_ARG.test(arg)) {
92
+ hideNext = true;
93
+ return arg;
94
+ }
95
+ const pair = /^(--?[^=]+)=(.+)$/.exec(arg);
96
+ if (pair && SECRET_ARG.test(pair[1] ?? "")) {
97
+ return `${pair[1]}=[redacted]`;
98
+ }
99
+ return redactText(arg);
100
+ });
101
+ }
102
+
103
+ /** Owner-only modes; the file is credential-adjacent, like the approval log. */
104
+ const REPORT_DIR_MODE = 0o700;
105
+ const REPORT_FILE_MODE = 0o600;
106
+
107
+ /** Read a process fact without letting it abort the report. */
108
+ function attempt<T>(read: () => T, fallback: T): T {
109
+ try {
110
+ return read();
111
+ } catch {
112
+ // Every field here is diagnostics; a missing one is better than no report.
113
+ return fallback;
114
+ }
115
+ }
116
+
117
+ function constructorNames(list: unknown[] | undefined): string[] {
118
+ if (!Array.isArray(list)) return [];
119
+ const names = new Set<string>();
120
+ for (const item of list) {
121
+ const name =
122
+ item && typeof item === "object" && "constructor" in item
123
+ ? (item.constructor as { name?: string })?.name
124
+ : undefined;
125
+ names.add(
126
+ typeof name === "string" && name.length > 0
127
+ ? name
128
+ : typeof item === "undefined"
129
+ ? "undefined"
130
+ : String(typeof item),
131
+ );
132
+ }
133
+ return [...names].sort((a, b) => a.localeCompare(b));
134
+ }
135
+
136
+ /** Show this to whoever has to debug the hang: one line, no prefixes. */
137
+ export function formatHangReportSummary(report: HangReport): string {
138
+ const handles =
139
+ report.activeHandles.length > 0
140
+ ? report.activeHandles.join(", ")
141
+ : "none";
142
+ const cwd = report.cwd === UNKNOWN_CWD ? " (cwd unavailable)" : "";
143
+ return `shutdown exceeded ${report.timeoutMs}ms (spent ${report.elapsedMs}ms); handles: ${handles}${cwd}`;
144
+ }
145
+
146
+ export function collectHangReport(
147
+ input: HangReportInput,
148
+ options: HangReportOptions = {},
149
+ ): HangReport {
150
+ // SAFETY: `_getActiveHandles`, `_getActiveRequests`, and `report.getReport`
151
+ // exist on Node's process object but are missing from (or narrower in)
152
+ // @types/node. `ProcessIntrospection` declares them as optional, so every
153
+ // use below is guarded and nothing is called unconditionally.
154
+ const proc = process as unknown as ProcessIntrospection & NodeJS.Process;
155
+ const now = options.now ?? (() => new Date());
156
+ const libuv = attempt(() => {
157
+ const raw = proc.report?.getReport?.();
158
+ return raw && Array.isArray(raw.libuv) ? raw.libuv.slice(0, 200) : [];
159
+ }, [] as unknown[]);
160
+ return {
161
+ version: 1,
162
+ kind: "quit-guard",
163
+ timestamp: now().toISOString(),
164
+ pid: options.pid ?? proc.pid,
165
+ timeoutMs: input.timeoutMs,
166
+ elapsedMs: Math.round(input.elapsedMs),
167
+ marker: input.marker,
168
+ uptimeSeconds: attempt(() => Math.round(proc.uptime()), 0),
169
+ // `process.cwd()` throws ENOENT when the directory was removed during
170
+ // the session (a startup crash pi itself guards against in 1.5.0). The
171
+ // report is written on the exit path, so it must survive that.
172
+ cwd: options.cwd ?? attempt(() => proc.cwd(), UNKNOWN_CWD),
173
+ argv: attempt(
174
+ () => redactArgv(options.argv ?? [...proc.argv]),
175
+ [],
176
+ ),
177
+ activeHandles: constructorNames(
178
+ attempt(() => proc._getActiveHandles?.(), [] as unknown[]),
179
+ ),
180
+ activeRequests: constructorNames(
181
+ attempt(() => proc._getActiveRequests?.(), [] as unknown[]),
182
+ ),
183
+ libuv,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Write the report under `dir`. Returns the path, or `undefined` when the
189
+ * filesystem rejected it (read-only home, missing permissions, ...).
190
+ */
191
+ export function writeHangReport(
192
+ dir: string,
193
+ report: HangReport,
194
+ ): string | undefined {
195
+ try {
196
+ mkdirSync(dir, { recursive: true, mode: REPORT_DIR_MODE });
197
+ const stamp = report.timestamp.replace(/[:.]/g, "-");
198
+ const file = join(dir, `quit-hang-${stamp}-${report.pid}.json`);
199
+ writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, {
200
+ encoding: "utf8",
201
+ mode: REPORT_FILE_MODE,
202
+ });
203
+ return file;
204
+ } catch (error) {
205
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
206
+ console.warn(
207
+ "[pi-toolkits/quit-guard] Failed to write hang report:",
208
+ error instanceof Error ? error.message : String(error),
209
+ );
210
+ return undefined;
211
+ }
212
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * quit-guard feature — persisted settings store.
3
+ *
4
+ * State lives in `<agent-dir>/pi-toolkits-quit-guard.json` (agent-dir from
5
+ * getAgentDir(), same convention as the favorites/compact/review stores).
6
+ *
7
+ * File shape:
8
+ * {
9
+ * "enabled": true,
10
+ * "timeoutMs": 10000,
11
+ * "report": true
12
+ * }
13
+ *
14
+ * `enabled` (default true) gates the whole watchdog; `timeoutMs` is clamped
15
+ * to [MIN_TIMEOUT_MS, MAX_TIMEOUT_MS]; `report` (default true) writes a JSON
16
+ * artifact under `<agent-dir>/quit-guard/` every time the guard fires.
17
+ *
18
+ * Robustness: a missing file means defaults (and is created on first save),
19
+ * corrupted/unreadable files fall back to defaults with a console.warn, and
20
+ * writes are atomic (tmp + rename) and never throw.
21
+ */
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ renameSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { dirname, join } from "node:path";
30
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
31
+ import {
32
+ DEFAULT_TIMEOUT_MS,
33
+ MAX_TIMEOUT_MS,
34
+ MIN_TIMEOUT_MS,
35
+ QUIT_GUARD_FILE,
36
+ } from "./constants.js";
37
+
38
+ export interface QuitGuardConfig {
39
+ /** Master switch. When false the guard never arms. */
40
+ enabled: boolean;
41
+ /** Milliseconds allowed between the terminal teardown write and exit. */
42
+ timeoutMs: number;
43
+ /** Write a JSON hang report before forcing exit. */
44
+ report: boolean;
45
+ }
46
+
47
+ export const DEFAULT_CONFIG: QuitGuardConfig = {
48
+ enabled: true,
49
+ timeoutMs: DEFAULT_TIMEOUT_MS,
50
+ report: true,
51
+ };
52
+
53
+ /** Clamp/round a timeout into the supported range; non-numbers fall back. */
54
+ export function normalizeTimeoutMs(value: unknown): number {
55
+ if (typeof value !== "number" || !Number.isFinite(value)) {
56
+ return DEFAULT_TIMEOUT_MS;
57
+ }
58
+ const rounded = Math.round(value);
59
+ if (rounded <= MIN_TIMEOUT_MS) return MIN_TIMEOUT_MS;
60
+ if (rounded >= MAX_TIMEOUT_MS) return MAX_TIMEOUT_MS;
61
+ return rounded;
62
+ }
63
+
64
+ /** Coerce an unknown JSON blob into a complete config (defaults for gaps). */
65
+ export function normalizeConfig(input: unknown): QuitGuardConfig {
66
+ const config: QuitGuardConfig = { ...DEFAULT_CONFIG };
67
+ if (!input || typeof input !== "object") return config;
68
+ const raw = input as Partial<QuitGuardConfig>;
69
+ if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
70
+ if (typeof raw.report === "boolean") config.report = raw.report;
71
+ if (raw.timeoutMs !== undefined) {
72
+ config.timeoutMs = normalizeTimeoutMs(raw.timeoutMs);
73
+ }
74
+ return config;
75
+ }
76
+
77
+ export class QuitGuardStore {
78
+ private config: QuitGuardConfig = { ...DEFAULT_CONFIG };
79
+ private readonly file: string;
80
+ private loaded = false;
81
+
82
+ constructor(file: string = join(getAgentDir(), QUIT_GUARD_FILE)) {
83
+ this.file = file;
84
+ }
85
+
86
+ /** Path of the JSON file (also used to derive the report directory). */
87
+ get path(): string {
88
+ return this.file;
89
+ }
90
+
91
+ private ensureLoaded(): void {
92
+ if (this.loaded) return;
93
+ this.loaded = true;
94
+ try {
95
+ if (!existsSync(this.file)) return;
96
+ const parsed: unknown = JSON.parse(readFileSync(this.file, "utf8"));
97
+ this.config = normalizeConfig(parsed);
98
+ } catch (error) {
99
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
100
+ console.warn(
101
+ "[pi-toolkits/quit-guard] Failed to load settings, using defaults:",
102
+ error instanceof Error ? error.message : String(error),
103
+ );
104
+ this.config = { ...DEFAULT_CONFIG };
105
+ }
106
+ }
107
+
108
+ /** Current settings (a copy). */
109
+ get(): QuitGuardConfig {
110
+ this.ensureLoaded();
111
+ return { ...this.config };
112
+ }
113
+
114
+ /** Merge a patch, normalize it, persist, and return the new settings. */
115
+ update(patch: Partial<QuitGuardConfig>): QuitGuardConfig {
116
+ this.ensureLoaded();
117
+ this.config = normalizeConfig({ ...this.config, ...patch });
118
+ this.save();
119
+ return { ...this.config };
120
+ }
121
+
122
+ /** Atomically persist (tmp + rename). Never throws. */
123
+ save(): void {
124
+ try {
125
+ const dir = dirname(this.file);
126
+ if (!existsSync(dir)) {
127
+ mkdirSync(dir, { recursive: true });
128
+ }
129
+ const tmp = `${this.file}.tmp`;
130
+ writeFileSync(
131
+ tmp,
132
+ `${JSON.stringify(this.config, null, 2)}\n`,
133
+ "utf8",
134
+ );
135
+ renameSync(tmp, this.file);
136
+ } catch (error) {
137
+ // pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
138
+ console.warn(
139
+ "[pi-toolkits/quit-guard] Failed to save settings:",
140
+ error instanceof Error ? error.message : String(error),
141
+ );
142
+ }
143
+ }
144
+ }
@@ -57,7 +57,7 @@ export async function pickReviewModel(
57
57
  // headless contexts have no dialogs, so fall back to a manual-config hint.
58
58
  if (!ctx.hasUI) {
59
59
  ctx.ui.notify(
60
- "third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
60
+ "third-review: this environment has no model picker dialog — edit <agent-dir>/pi-toolkits-review.json to configure it manually.",
61
61
  "error",
62
62
  );
63
63
  return undefined;
@@ -66,15 +66,16 @@ export async function pickReviewModel(
66
66
  const configuredModel = resolveConfiguredModel(ctx, configured);
67
67
  if (!configuredModel && configured) {
68
68
  ctx.ui.notify(
69
- `third-review: 配置的评审模型 ${configured} 不存在或不可用,请重新选择。`,
69
+ `third-review: the configured review model ${configured} does not exist or is unavailable — pick another one.`,
70
70
  "warning",
71
71
  );
72
72
  }
73
73
  const model = await openModelPicker(ctx, configuredModel, {
74
- selectTitle: "选择评审模型(首项为当前默认,回车使用)",
74
+ selectTitle:
75
+ "Select the review model (first entry is the current default; press Enter to use it)",
75
76
  });
76
77
  if (!model) {
77
- ctx.ui.notify("third-review: 已取消,评审模型配置未变更。", "info");
78
+ ctx.ui.notify("third-review: cancelled — the review model is unchanged.", "info");
78
79
  return undefined;
79
80
  }
80
81
  const key = modelKey(model);
@@ -82,7 +83,7 @@ export async function pickReviewModel(
82
83
  // Persist immediately (disk + in-memory + footer) — next run defaults here.
83
84
  store.setModel(key);
84
85
  ctx.ui.notify(
85
- `third-review: 评审模型已保存为「${model.name}」,下次默认使用。`,
86
+ `third-review: review model saved as "${model.name}" — it will be the default next time.`,
86
87
  "info",
87
88
  );
88
89
  }
@@ -103,12 +104,12 @@ export function createReviewRunner(
103
104
 
104
105
  return async (ctx) => {
105
106
  if (running) {
106
- ctx.ui.notify("third-review: 评审已在执行中,请稍候。", "warning");
107
+ ctx.ui.notify("third-review: a review is already running — please wait.", "warning");
107
108
  return;
108
109
  }
109
110
  if (!ctx.isIdle()) {
110
111
  ctx.ui.notify(
111
- "third-review: 当前任务仍在进行中,请等待完成后再触发。",
112
+ "third-review: the current task is still running — wait for it to finish before starting a review.",
112
113
  "warning",
113
114
  );
114
115
  return;
@@ -129,7 +130,7 @@ export function createReviewRunner(
129
130
  model = resolveConfiguredModel(ctx, store.getModel());
130
131
  if (!model) {
131
132
  ctx.ui.notify(
132
- "third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
133
+ "third-review: this environment has no model picker dialog — edit <agent-dir>/pi-toolkits-review.json to configure it manually.",
133
134
  "error",
134
135
  );
135
136
  return;
@@ -138,13 +139,13 @@ export function createReviewRunner(
138
139
  const switched = await pi.setModel(model);
139
140
  if (!switched) {
140
141
  ctx.ui.notify(
141
- `third-review: 无法切换到评审模型「${model.name}」(缺少 API key 或鉴权未配置)。`,
142
+ `third-review: cannot switch to the review model "${model.name}" (missing API key or auth not configured).`,
142
143
  "error",
143
144
  );
144
145
  return;
145
146
  }
146
147
  ctx.ui.notify(
147
- `third-review: 已切换到评审模型「${model.name}」执行评审(查+修);评审完成后会话保持该模型。`,
148
+ `third-review: switched to the review model "${model.name}" for this review (check + fix); the session stays on it afterwards.`,
148
149
  "info",
149
150
  );
150
151
  pi.sendUserMessage(REVIEW_PROMPT);
@@ -7,25 +7,30 @@
7
7
  * it makes — stays in the current session transcript right next to the work
8
8
  * under review.
9
9
  *
10
- * The three focus points the user asked for (本轮需求实现 / 是否有遗漏 /
11
- * 是否有错误) are spelled out verbatim in the prompt, together with a hard
10
+ * The three focus points the user asked for (requirement coverage / anything
11
+ * missing / anything wrong — originally stated as 本轮需求实现 / 是否有遗漏 /
12
+ * 是否有错误) are spelled out one by one in the prompt, together with a hard
12
13
  * scope constraint: the review must stay within this round's changes.
14
+ *
15
+ * The prompt is English because everything the model and the operator see at
16
+ * runtime is English (TUI notices, tool text, prompts); keep it that way when
17
+ * editing, and keep the three focus points and the scope rule intact.
13
18
  */
14
19
 
15
- export const REVIEW_PROMPT = `请以第三方代码评审的身份,对本次会话中刚完成的开发任务进行 Review。
20
+ export const REVIEW_PROMPT = `Act as a third-party code reviewer and review the development task that was just completed in this session.
16
21
 
17
- 本轮需求、开发过程和代码改动都在当前会话上下文中,请结合上下文,并用 read/grep 等工具实际检查仓库代码,不要凭空猜测。
22
+ The requirement for this round, the development process and the code changes are all in the current session context. Use that context, and actually inspect the repository with tools such as read/grep — do not guess.
18
23
 
19
- 请重点检查以下三点(逐项给出结论):
20
- 1. 本轮需求实现:本轮需求是否已完整、正确地实现?实现与需求描述是否一致?
21
- 2. 是否有遗漏:需求中是否有遗漏的功能点、边界情况、异常处理、兼容性、测试等?
22
- 3. 是否有错误:代码中是否存在逻辑错误、潜在 bug、类型/编译错误、安全与性能隐患等?
24
+ Focus on the following three points and give a conclusion for each:
25
+ 1. Requirement coverage: was this round's requirement implemented completely and correctly? Does the implementation match the requirement as described?
26
+ 2. Anything missing: are there requirement points, edge cases, error handling, compatibility concerns, or tests that were left out?
27
+ 3. Anything wrong: are there logic errors, potential bugs, type/compile errors, or security and performance risks in the code?
23
28
 
24
- 范围强约束:
25
- - 本次 Review 仅限当前本轮修改内容(本轮需求对应的代码改动),避免扩大范围;
26
- - 本轮改动范围内确认的问题才直接修复;范围外的问题(历史遗留、无关代码)只记录提示,不要动手修改。
29
+ Hard scope constraint:
30
+ - Review only this round's changes (the code changes that correspond to this round's requirement); do not widen the scope;
31
+ - Only fix problems confirmed inside this round's changes directly; for problems outside that scope (pre-existing issues, unrelated code) record them as notes — do not touch them.
27
32
 
28
- 要求:
29
- - 对确认的问题直接修复(write/edit),并简要说明改了什么、为什么改;
30
- - 无法确认或不宜直接改的问题,列出:位置(文件/行)、问题描述、建议方案;
31
- - 最后输出评审报告:发现问题清单(已修复 / 未修复)、修复说明、总体结论(通过 / 需修改)与剩余风险。`;
33
+ Requirements:
34
+ - Fix confirmed problems directly (write/edit) and briefly state what you changed and why;
35
+ - For problems you cannot confirm or should not fix directly, list: location (file/line), description of the problem, and a suggested approach;
36
+ - Finish with a review report: the list of findings (fixed / not fixed), the fix notes, an overall verdict (pass / needs changes) and the remaining risks.`;
@@ -256,7 +256,7 @@ describe("pickReviewModel", () => {
256
256
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
257
257
  expect(custom).not.toHaveBeenCalled();
258
258
  expect(notify).toHaveBeenCalledWith(
259
- expect.stringContaining("没有可用模型"),
259
+ expect.stringContaining("No models are available"),
260
260
  "error",
261
261
  );
262
262
  });
@@ -267,7 +267,7 @@ describe("pickReviewModel", () => {
267
267
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
268
268
  expect(custom).not.toHaveBeenCalled();
269
269
  expect(notify).toHaveBeenCalledWith(
270
- expect.stringContaining("不支持模型选择对话框"),
270
+ expect.stringContaining("has no model picker dialog"),
271
271
  "error",
272
272
  );
273
273
  });
@@ -302,7 +302,7 @@ describe("pickReviewModel", () => {
302
302
  const { ctx, notify } = fakeCtx({ customResult: undefined });
303
303
  await pickReviewModel(ctx, store);
304
304
  expect(notify).toHaveBeenCalledWith(
305
- expect.stringContaining("不存在或不可用"),
305
+ expect.stringContaining("does not exist or is unavailable"),
306
306
  "warning",
307
307
  );
308
308
  });
@@ -325,7 +325,7 @@ describe("pickReviewModel", () => {
325
325
  expect(picked).toBe(MODELS[2]);
326
326
  expect(store.getModel()).toBe("openrouter/openai/gpt-5");
327
327
  expect(notify).toHaveBeenCalledWith(
328
- expect.stringContaining("已保存为"),
328
+ expect.stringContaining("review model saved as"),
329
329
  "info",
330
330
  );
331
331
  });
@@ -339,7 +339,7 @@ describe("createReviewRunner", () => {
339
339
  const { ctx, notify } = fakeCtx({ idle: false });
340
340
  await createReviewRunner(pi)(ctx);
341
341
  expect(notify).toHaveBeenCalledWith(
342
- expect.stringContaining("等待完成"),
342
+ expect.stringContaining("wait for it to finish"),
343
343
  "warning",
344
344
  );
345
345
  expect(pi.setModel).not.toHaveBeenCalled();
@@ -356,7 +356,7 @@ describe("createReviewRunner", () => {
356
356
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
357
357
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
358
358
  expect(notify).toHaveBeenCalledWith(
359
- expect.stringContaining("已切换到评审模型"),
359
+ expect.stringContaining("switched to the review model"),
360
360
  "info",
361
361
  );
362
362
  });
@@ -387,7 +387,7 @@ describe("createReviewRunner", () => {
387
387
  const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
388
388
  await createReviewRunner(pi)(ctx);
389
389
  expect(notify).toHaveBeenCalledWith(
390
- expect.stringContaining("不存在或不可用"),
390
+ expect.stringContaining("does not exist or is unavailable"),
391
391
  "warning",
392
392
  );
393
393
  expect(getReviewStore().getModel()).toBe("anthropic/claude-sonnet-4");
@@ -414,7 +414,7 @@ describe("createReviewRunner", () => {
414
414
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
415
415
  expect(sendUserMessage).not.toHaveBeenCalled();
416
416
  expect(notify).toHaveBeenCalledWith(
417
- expect.stringContaining("无法切换到评审模型"),
417
+ expect.stringContaining("cannot switch to the review model"),
418
418
  "error",
419
419
  );
420
420
  });
@@ -449,7 +449,7 @@ describe("createReviewRunner", () => {
449
449
  } as unknown as ExtensionContext;
450
450
  await runner(ctx2);
451
451
  expect(notify2).toHaveBeenCalledWith(
452
- expect.stringContaining("评审已在执行中"),
452
+ expect.stringContaining("a review is already running"),
453
453
  "warning",
454
454
  );
455
455
  release?.(MODELS[0]);
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * - autostart — replay project-declared slash commands at session start (/add-dir persistence)
14
14
  * - permission — session-scoped boundary permission checks (/ask-perm)
15
15
  * - init — agent memory framework install / adoption (/ask-init)
16
+ * - quit-guard — quit watchdog: force-exit when pi's shutdown hangs (/quit-guard)
16
17
  *
17
18
  * `ask` and `permission` are independent gates on one bus: each answers its own
18
19
  * question and the strictest answer wins, so they compose rather than replace
@@ -27,6 +28,7 @@ import { registerCompact } from "./features/compact/index.js";
27
28
  import { registerFavorites } from "./features/favorites/index.js";
28
29
  import { registerInit } from "./features/init/index.js";
29
30
  import { registerPermission } from "./features/permission/index.js";
31
+ import { registerQuitGuard } from "./features/quit-guard/index.js";
30
32
  import { registerReview } from "./features/review/index.js";
31
33
  import { registerStash } from "./features/stash/index.js";
32
34
 
@@ -44,6 +46,7 @@ export default function piToolkits(pi: ExtensionAPI): void {
44
46
  registerCompact(pi);
45
47
  registerFavorites(pi);
46
48
  registerInit(pi);
49
+ registerQuitGuard(pi);
47
50
  registerReview(pi);
48
51
  registerStash(pi);
49
52
 
@@ -261,7 +261,14 @@ const REDACTIONS: ReadonlyArray<readonly [RegExp, Replacer]> = [
261
261
  ],
262
262
  ];
263
263
 
264
- function redactText(value: string): string {
264
+ /**
265
+ * Apply the credential patterns below to one string.
266
+ *
267
+ * Exported for the quit-guard hang report, which writes process arguments and
268
+ * must not store `pi --api-key …` verbatim; the approval log ships one copy of
269
+ * these rules so the two artifacts cannot drift apart.
270
+ */
271
+ export function redactText(value: string): string {
265
272
  let out = value;
266
273
  for (const [pattern, replacement] of REDACTIONS) {
267
274
  out =
@@ -115,7 +115,7 @@ describe("openModelPicker", () => {
115
115
  const { ctx, notify } = fakeCtx([]);
116
116
  expect(await openModelPicker(ctx)).toBeUndefined();
117
117
  expect(notify).toHaveBeenCalledWith(
118
- expect.stringContaining("没有可用模型"),
118
+ expect.stringContaining("No models are available"),
119
119
  "error",
120
120
  );
121
121
  });
@@ -170,7 +170,7 @@ describe("openModelPicker", () => {
170
170
  const picked = await openModelPicker(ctx, MODELS[1]);
171
171
  expect(custom).not.toHaveBeenCalled();
172
172
  expect(select).toHaveBeenCalledWith(
173
- "选择模型(首项为当前默认,回车使用)",
173
+ "Select a model (first entry is the current default; press Enter to use it)",
174
174
  [
175
175
  "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
176
176
  "Gemini 2.5 Flash (google/gemini-2.5-flash)",
@@ -184,8 +184,10 @@ describe("openModelPicker", () => {
184
184
  (ctx as { mode: string }).mode = "rpc";
185
185
  select.mockResolvedValue(undefined);
186
186
  expect(
187
- await openModelPicker(ctx, MODELS[0], { selectTitle: "选择评审模型" }),
187
+ await openModelPicker(ctx, MODELS[0], {
188
+ selectTitle: "Select the review model",
189
+ }),
188
190
  ).toBeUndefined();
189
- expect(select).toHaveBeenCalledWith("选择评审模型", expect.any(Array));
191
+ expect(select).toHaveBeenCalledWith("Select the review model", expect.any(Array));
190
192
  });
191
193
  });
@@ -43,9 +43,10 @@ function modelRuntimeFacade(ctx: ExtensionContext): ModelRuntime {
43
43
  refresh: (options?: { signal?: AbortSignal }) =>
44
44
  ctx.modelRegistry.refresh(options),
45
45
  };
46
- // SAFETY: the selector only touches the four members above (pi 1.0–1.3
47
- // model-selector.js); everything else on ModelRuntime is never reached
48
- // from this component.
46
+ // SAFETY: the selector only touches the four members above (pi 1.0–1.5
47
+ // model-selector.js; the 1.5.0 rendering/page-key changes add no runtime
48
+ // call); everything else on ModelRuntime is never reached from this
49
+ // component.
49
50
  return facade as unknown as ModelRuntime;
50
51
  }
51
52
 
@@ -64,7 +65,7 @@ export async function openModelPicker(
64
65
  if (!ctx.hasUI) return undefined;
65
66
  const available = ctx.modelRegistry.getAvailable();
66
67
  if (available.length === 0) {
67
- ctx.ui.notify("当前没有可用模型,请先在 pi 中配置模型。", "error");
68
+ ctx.ui.notify("No models are available — configure a model in pi first.", "error");
68
69
  return undefined;
69
70
  }
70
71
  if (ctx.mode === "tui") {
@@ -95,7 +96,8 @@ export async function openModelPicker(
95
96
  (m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
96
97
  );
97
98
  const choice = await ctx.ui.select(
98
- options?.selectTitle ?? "选择模型(首项为当前默认,回车使用)",
99
+ options?.selectTitle ??
100
+ "Select a model (first entry is the current default; press Enter to use it)",
99
101
  labels,
100
102
  );
101
103
  if (choice === undefined) return undefined;