@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,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
|
+
}
|
|
@@ -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
|
+
}
|