@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 +76 -1
- package/README.zh-CN.md +45 -1
- package/package.json +1 -1
- package/src/features/compact/compact.test.ts +5 -5
- package/src/features/compact/index.ts +9 -8
- 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 +3 -2
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quit-guard feature — quit watchdog (`/quit-guard`).
|
|
3
|
+
*
|
|
4
|
+
* Problem this solves: pi's interactive `/quit` stops the TUI, then awaits
|
|
5
|
+
* `runtimeHost.dispose()` — every extension's `session_shutdown` handler,
|
|
6
|
+
* one after another, with no timeout (upstream issue #9997; still unbounded
|
|
7
|
+
* in 1.5.0 and on main). One handler that never settles leaves the process
|
|
8
|
+
* alive at 0% CPU, the terminal already restored and the final frame
|
|
9
|
+
* reprinted: the screen looks like a frozen "exit screen" and the shell
|
|
10
|
+
* prompt never comes back. The `isShuttingDown` latch in the host then
|
|
11
|
+
* swallows Ctrl+C / SIGTERM / SIGHUP, so only `kill -9` helps.
|
|
12
|
+
*
|
|
13
|
+
* The guard watches the terminal instead of the event stream, because the
|
|
14
|
+
* event stream is exactly what can hang (a handler loaded before ours never
|
|
15
|
+
* returns, so our own `session_shutdown` never runs). It arms when the TUI
|
|
16
|
+
* writes its teardown sequence, and disarms if the TUI comes back (mode
|
|
17
|
+
* switch, Ctrl+Z suspend/resume). If the deadline passes while pi is still
|
|
18
|
+
* alive, it writes a JSON hang report and forces `process.exit(0)`.
|
|
19
|
+
*
|
|
20
|
+
* Wiring:
|
|
21
|
+
* - one global `process.stdout.write` wrapper per process (survives
|
|
22
|
+
* `/reload`; the shared state object is the source of truth, so a reload
|
|
23
|
+
* only refreshes config/settings, never double-wraps);
|
|
24
|
+
* - one `SIGCONT` listener: a resume must never be mistaken for a quit;
|
|
25
|
+
* - `/quit-guard` — status / `on` / `off` / `<timeoutMs>`, persisted to
|
|
26
|
+
* `<agent-dir>/pi-toolkits-quit-guard.json`.
|
|
27
|
+
*
|
|
28
|
+
* Settings file (all fields optional, defaults shown):
|
|
29
|
+
* { "enabled": true, "timeoutMs": 10000, "report": true }
|
|
30
|
+
*/
|
|
31
|
+
import { appendFileSync, existsSync, readdirSync, writeSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import {
|
|
36
|
+
MAX_TIMEOUT_MS,
|
|
37
|
+
MIN_TIMEOUT_MS,
|
|
38
|
+
QUIT_GUARD_FILE,
|
|
39
|
+
QUIT_GUARD_REPORT_DIR,
|
|
40
|
+
} from "./constants.js";
|
|
41
|
+
import { QuitGuard, type GuardFireInfo } from "./guard.js";
|
|
42
|
+
import {
|
|
43
|
+
collectHangReport,
|
|
44
|
+
formatHangReportSummary,
|
|
45
|
+
writeHangReport,
|
|
46
|
+
} from "./report.js";
|
|
47
|
+
import {
|
|
48
|
+
QuitGuardStore,
|
|
49
|
+
normalizeTimeoutMs,
|
|
50
|
+
type QuitGuardConfig,
|
|
51
|
+
} from "./store.js";
|
|
52
|
+
|
|
53
|
+
/** Process-wide key; `/reload` re-imports the module but keeps this state. */
|
|
54
|
+
const STATE_KEY = Symbol.for("@andares/pi-toolkits/quit-guard/state");
|
|
55
|
+
|
|
56
|
+
interface QuitGuardState {
|
|
57
|
+
store: QuitGuardStore;
|
|
58
|
+
guard: QuitGuard;
|
|
59
|
+
config: QuitGuardConfig;
|
|
60
|
+
reportDir: string;
|
|
61
|
+
installed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sharedState(): QuitGuardState | undefined {
|
|
65
|
+
// SAFETY: `globalThis` is an untyped property bag; the symbol key is the
|
|
66
|
+
// private contract and this module is the only writer.
|
|
67
|
+
return (
|
|
68
|
+
globalThis as unknown as Record<symbol, QuitGuardState | undefined>
|
|
69
|
+
)[STATE_KEY];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function storeSharedState(state: QuitGuardState): void {
|
|
73
|
+
// SAFETY: same contract as `sharedState`; the value shape is checked by this
|
|
74
|
+
// module, not by the property bag.
|
|
75
|
+
(
|
|
76
|
+
globalThis as unknown as Record<symbol, QuitGuardState | undefined>
|
|
77
|
+
)[STATE_KEY] = state;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fire(state: QuitGuardState, info: GuardFireInfo): void {
|
|
81
|
+
const timeoutMs = state.guard.getTimeoutMs();
|
|
82
|
+
const elapsedMs = Math.round(info.elapsedMs);
|
|
83
|
+
let reportPath: string | undefined;
|
|
84
|
+
let summary: string | undefined;
|
|
85
|
+
if (state.config.report) {
|
|
86
|
+
// Diagnostics only: a throwing report (deleted cwd, unreadable home, ...)
|
|
87
|
+
// must never keep the user from getting the prompt back.
|
|
88
|
+
try {
|
|
89
|
+
const report = collectHangReport({
|
|
90
|
+
timeoutMs,
|
|
91
|
+
elapsedMs,
|
|
92
|
+
marker: info.marker,
|
|
93
|
+
});
|
|
94
|
+
summary = formatHangReportSummary(report);
|
|
95
|
+
reportPath = writeHangReport(state.reportDir, report);
|
|
96
|
+
} catch {
|
|
97
|
+
summary = undefined;
|
|
98
|
+
reportPath = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const line = summary
|
|
102
|
+
? `[pi-toolkits/quit-guard] ${summary}${reportPath ? ` — report: ${reportPath}` : ""}; forcing exit.`
|
|
103
|
+
: `[pi-toolkits/quit-guard] no exit ${elapsedMs}ms after the TUI teardown started (limit ${timeoutMs}ms); forcing exit.`;
|
|
104
|
+
// stderr write is synchronous on POSIX; on failure the exit still happens.
|
|
105
|
+
try {
|
|
106
|
+
writeSync(2, `\n${line}\n`);
|
|
107
|
+
} catch {
|
|
108
|
+
// The terminal is gone or blocked; the report (if any) still exists.
|
|
109
|
+
}
|
|
110
|
+
// `process.exit(0)` is normally immediate, but every step of it is host
|
|
111
|
+
// code: an `exit` listener (or a patched `reallyExit`) can turn it into a
|
|
112
|
+
// no-op or throw — one such case was observed while testing this feature.
|
|
113
|
+
// The SIGKILL fallback guarantees the prompt comes back; the report is
|
|
114
|
+
// already on disk and session entries are appended as they happen, so
|
|
115
|
+
// nothing recoverable is lost.
|
|
116
|
+
const hardKill = setTimeout(() => process.kill(process.pid, "SIGKILL"), 500);
|
|
117
|
+
try {
|
|
118
|
+
process.exit(0);
|
|
119
|
+
} catch {
|
|
120
|
+
// Fall through to the hard kill.
|
|
121
|
+
}
|
|
122
|
+
clearTimeout(hardKill);
|
|
123
|
+
process.kill(process.pid, "SIGKILL");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function installProcessHooks(state: QuitGuardState): void {
|
|
127
|
+
if (state.installed) return;
|
|
128
|
+
state.installed = true;
|
|
129
|
+
|
|
130
|
+
const originalWrite = process.stdout.write;
|
|
131
|
+
// Capture the overload parameters once, then forward the exact same tuple:
|
|
132
|
+
// no casts, and every `write(chunk)` / `write(chunk, cb)` / `write(chunk,
|
|
133
|
+
// encoding, cb)` call site keeps working.
|
|
134
|
+
const wrapped = (...args: Parameters<typeof process.stdout.write>): boolean => {
|
|
135
|
+
try {
|
|
136
|
+
state.guard.observeOutput(args[0]);
|
|
137
|
+
} catch {
|
|
138
|
+
// Never let observation break terminal output.
|
|
139
|
+
}
|
|
140
|
+
return originalWrite.apply(process.stdout, args);
|
|
141
|
+
};
|
|
142
|
+
// SAFETY: the forwarded tuple is exactly what the original function receives
|
|
143
|
+
// (Node normalizes the (chunk, cb) form itself); the cast only restores
|
|
144
|
+
// Writable.write's two overloads, which `Parameters<...>` collapses into one.
|
|
145
|
+
process.stdout.write = wrapped as unknown as typeof process.stdout.write;
|
|
146
|
+
|
|
147
|
+
// Ctrl+Z resume: `handleCtrlZ()` calls ui.stop() (teardown marker), then
|
|
148
|
+
// SIGCONT calls ui.start() (restart marker). Both cancel; this listener
|
|
149
|
+
// additionally disarms synchronously so an already-expired timer cannot
|
|
150
|
+
// win the race against the resume. Windows cannot deliver SIGCONT at all.
|
|
151
|
+
if (process.platform !== "win32") {
|
|
152
|
+
process.on("SIGCONT", () => state.guard.resume());
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function latestReportName(dir: string): string | undefined {
|
|
157
|
+
try {
|
|
158
|
+
if (!existsSync(dir)) return undefined;
|
|
159
|
+
const names = readdirSync(dir)
|
|
160
|
+
.filter(
|
|
161
|
+
(name) => name.startsWith("quit-hang-") && name.endsWith(".json"),
|
|
162
|
+
)
|
|
163
|
+
.sort();
|
|
164
|
+
return names.length > 0 ? names[names.length - 1] : undefined;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function statusText(state: QuitGuardState): string {
|
|
171
|
+
const config = state.config;
|
|
172
|
+
const last = latestReportName(state.reportDir);
|
|
173
|
+
const parts = [
|
|
174
|
+
`quit guard: ${config.enabled ? "on" : "off"}`,
|
|
175
|
+
`timeout ${config.timeoutMs}ms`,
|
|
176
|
+
`report ${config.report ? "on" : "off"}`,
|
|
177
|
+
`last hang: ${last ?? "none"}`,
|
|
178
|
+
];
|
|
179
|
+
return parts.join(" · ");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildState(agentDir: string): QuitGuardState {
|
|
183
|
+
const store = new QuitGuardStore(join(agentDir, QUIT_GUARD_FILE));
|
|
184
|
+
const config = store.get();
|
|
185
|
+
let state: QuitGuardState | undefined;
|
|
186
|
+
// PI_QUIT_GUARD_DEBUG points at a file that receives one line per
|
|
187
|
+
// arm/disarm/fire. It exists because the failure being debugged happens
|
|
188
|
+
// after the TUI is gone: there is no other channel left to observe.
|
|
189
|
+
const debugPath = process.env.PI_QUIT_GUARD_DEBUG;
|
|
190
|
+
const guard = new QuitGuard(config.timeoutMs, {
|
|
191
|
+
onFire: (info) => {
|
|
192
|
+
if (state) fire(state, info);
|
|
193
|
+
},
|
|
194
|
+
// Raw mode is the independent "TUI still running" signal; see guard.ts.
|
|
195
|
+
isTuiActive: () => process.stdin.isRaw === true,
|
|
196
|
+
onEvent: debugPath
|
|
197
|
+
? (event, detail) => {
|
|
198
|
+
try {
|
|
199
|
+
appendFileSync(
|
|
200
|
+
debugPath,
|
|
201
|
+
`${new Date().toISOString()} ${event} ${detail}\n`,
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
// Debug output must never affect the guard.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
: undefined,
|
|
208
|
+
});
|
|
209
|
+
state = {
|
|
210
|
+
store,
|
|
211
|
+
guard,
|
|
212
|
+
config,
|
|
213
|
+
reportDir: join(agentDir, QUIT_GUARD_REPORT_DIR),
|
|
214
|
+
installed: false,
|
|
215
|
+
};
|
|
216
|
+
return state;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface QuitGuardOptions {
|
|
220
|
+
/** pi config directory; defaults to `getAgentDir()` (tests inject a temp dir). */
|
|
221
|
+
readonly agentDir?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function registerQuitGuard(
|
|
225
|
+
pi: ExtensionAPI,
|
|
226
|
+
options: QuitGuardOptions = {},
|
|
227
|
+
): void {
|
|
228
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
229
|
+
const state = sharedState() ?? buildState(agentDir);
|
|
230
|
+
storeSharedState(state);
|
|
231
|
+
|
|
232
|
+
// A reload rebuilds the extension instance: refresh settings from disk and
|
|
233
|
+
// start unarmed. Any watchdog armed by the previous instance belonged to a
|
|
234
|
+
// teardown that has already been replaced.
|
|
235
|
+
state.store = new QuitGuardStore(join(agentDir, QUIT_GUARD_FILE));
|
|
236
|
+
state.config = state.store.get();
|
|
237
|
+
state.reportDir = join(agentDir, QUIT_GUARD_REPORT_DIR);
|
|
238
|
+
state.guard.resume();
|
|
239
|
+
state.guard.setEnabled(state.config.enabled);
|
|
240
|
+
state.guard.setTimeoutMs(state.config.timeoutMs);
|
|
241
|
+
|
|
242
|
+
installProcessHooks(state);
|
|
243
|
+
if (process.env.PI_QUIT_GUARD_DEBUG) {
|
|
244
|
+
try {
|
|
245
|
+
appendFileSync(
|
|
246
|
+
process.env.PI_QUIT_GUARD_DEBUG,
|
|
247
|
+
`${new Date().toISOString()} register agentDir=${agentDir} enabled=${state.config.enabled} timeout=${state.config.timeoutMs}\n`,
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
// ignore
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
pi.registerCommand("quit-guard", {
|
|
255
|
+
description:
|
|
256
|
+
"Quit watchdog: force-exit when pi's shutdown hangs (/quit-guard [on|off|<timeoutMs>])",
|
|
257
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
258
|
+
const raw = args.trim();
|
|
259
|
+
if (raw.length === 0) {
|
|
260
|
+
ctx.ui.notify(statusText(state), "info");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const token = raw.toLowerCase();
|
|
264
|
+
if (token === "on" || token === "off") {
|
|
265
|
+
const enabled = token === "on";
|
|
266
|
+
state.config = state.store.update({ enabled });
|
|
267
|
+
state.guard.setEnabled(enabled);
|
|
268
|
+
ctx.ui.notify(
|
|
269
|
+
enabled
|
|
270
|
+
? `quit guard ON — force exits ${state.config.timeoutMs}ms after a stuck shutdown.`
|
|
271
|
+
: "quit guard OFF — a stuck shutdown will keep hanging until kill -9.",
|
|
272
|
+
"info",
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (/^[0-9]+$/.test(token)) {
|
|
277
|
+
const timeoutMs = normalizeTimeoutMs(Number(token));
|
|
278
|
+
state.config = state.store.update({ timeoutMs });
|
|
279
|
+
state.guard.setTimeoutMs(timeoutMs);
|
|
280
|
+
ctx.ui.notify(
|
|
281
|
+
`quit guard timeout ${timeoutMs}ms (allowed range ${MIN_TIMEOUT_MS}–${MAX_TIMEOUT_MS}ms).`,
|
|
282
|
+
"info",
|
|
283
|
+
);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
ctx.ui.notify(
|
|
287
|
+
"Usage: /quit-guard (status) | /quit-guard on|off | /quit-guard <timeoutMs>",
|
|
288
|
+
"error",
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|