@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
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
package/src/lib/approval-log.ts
CHANGED
|
@@ -261,7 +261,14 @@ const REDACTIONS: ReadonlyArray<readonly [RegExp, Replacer]> = [
|
|
|
261
261
|
],
|
|
262
262
|
];
|
|
263
263
|
|
|
264
|
-
|
|
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], {
|
|
187
|
+
await openModelPicker(ctx, MODELS[0], {
|
|
188
|
+
selectTitle: "Select the review model",
|
|
189
|
+
}),
|
|
188
190
|
).toBeUndefined();
|
|
189
|
-
expect(select).toHaveBeenCalledWith("
|
|
191
|
+
expect(select).toHaveBeenCalledWith("Select the review model", expect.any(Array));
|
|
190
192
|
});
|
|
191
193
|
});
|
package/src/lib/model-picker.ts
CHANGED
|
@@ -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.
|
|
47
|
-
// model-selector.js
|
|
48
|
-
// from this
|
|
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("
|
|
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;
|