pi-checkup 0.0.0-stage → 0.1.1

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,360 @@
1
+ // Runtime checks — the host environment Pi itself depends on.
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import net from "node:net";
5
+ import { bareHost, curlProbe, describeFsError, exists, humanBytes, isMissing, isWindows, proxyFromEnv, redactProxy, whichAll } from "../util.js";
6
+
7
+ export const group = "Runtime";
8
+
9
+ /**
10
+ * Can this directory actually be listed?
11
+ *
12
+ * `exists()` is `access(F_OK)`, which only answers "something is there" — a
13
+ * mode-000 directory and a regular file both pass it. Everything this group
14
+ * reports about an install is read out of the directory, so a directory that
15
+ * cannot be listed has to be named as such.
16
+ */
17
+ function canOpen(dir) {
18
+ try {
19
+ fs.readdirSync(dir);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ const MIN_NODE_MAJOR = 20;
27
+
28
+ function launcherHint(status, found) {
29
+ if (status === "ok") return undefined;
30
+ if (found) return "Shell shims and scripts that call `pi` by name may resolve to a different install.";
31
+ return "No Pi launcher was found. This report describes the files on disk; install Pi or run it from the directory that has one.";
32
+ }
33
+
34
+ /**
35
+ * The directory the free-space figure belongs to.
36
+ *
37
+ * `statfs` reports the filesystem holding the path, so on a machine with a
38
+ * separate /home or a mounted workspace the number under "disk" is not the
39
+ * number for `/`. Walking up while `dev` stays the same finds the mount root —
40
+ * the same thing `df` prints — and a path that cannot be stat'ed falls back to
41
+ * the filesystem root rather than dropping the row.
42
+ */
43
+ function mountPointOf(target) {
44
+ if (!target) return path.parse(process.cwd()).root;
45
+ if (isWindows) return path.parse(target).root;
46
+ let dir = path.resolve(target);
47
+ let dev;
48
+ try {
49
+ dev = fs.statSync(dir).dev;
50
+ } catch {
51
+ return path.parse(dir).root;
52
+ }
53
+ for (;;) {
54
+ const up = path.dirname(dir);
55
+ if (up === dir) return dir;
56
+ try {
57
+ if (fs.statSync(up).dev !== dev) return dir;
58
+ } catch {
59
+ return dir;
60
+ }
61
+ dir = up;
62
+ }
63
+ }
64
+
65
+ export async function run({ offline = false, agentDir: root, env = process.env, agentDirExplicit = false } = {}) {
66
+ const results = [];
67
+
68
+ // --- agent directory ---------------------------------------------------
69
+ // Without this row a container that never ran Pi reported "2 ok, 12 skipped"
70
+ // and a green verdict: every check had skipped, so nothing had been looked at,
71
+ // and the reader was told the install was fine.
72
+ if (root && isMissing(root)) {
73
+ // Two situations, and only one of them is a problem with this run. An absent
74
+ // default ~/.pi/agent just means Pi has not been started here yet; a path
75
+ // the user passed in and got wrong is a broken invocation, and reporting it
76
+ // as a warning left a mistyped PI_CODING_AGENT_DIR exiting 0 in CI over an
77
+ // install that was never opened.
78
+ // Still a warning, not a failure: a directory Pi has not created yet is not
79
+ // damage, and `fail` is reserved for confirmed breakage. What changes when
80
+ // the path was named explicitly is where the reader should look — at the
81
+ // variable they typed, not at a default location this run never used.
82
+ results.push({
83
+ group,
84
+ id: "agent-dir",
85
+ title: "agent dir",
86
+ status: "warn",
87
+ detail: agentDirExplicit
88
+ ? `${root} does not exist — PI_CODING_AGENT_DIR names a directory that is not there, so nothing was inspected`
89
+ : `${root} does not exist — nothing about an install was inspected`,
90
+ hint: agentDirExplicit
91
+ ? "Check the value of PI_CODING_AGENT_DIR, or unset it to use ~/.pi/agent. Nothing was read from the install you meant to check."
92
+ : "Run `pi` once to create it, or set PI_CODING_AGENT_DIR to the directory your install uses.",
93
+ });
94
+ } else if (root && !canOpen(root)) {
95
+ // A directory this process cannot open is not a missing one, and "run pi
96
+ // once to create it" is the wrong advice for a permissions problem. The
97
+ // test is a read, not an existence check: `access(F_OK)` succeeds on a
98
+ // mode-000 directory, so the old branch never fired for the case it was
99
+ // written for.
100
+ results.push({
101
+ group,
102
+ id: "agent-dir",
103
+ title: "agent dir",
104
+ status: "warn",
105
+ // The directory could not be listed. `isMissing()` already ruled out a
106
+ // path that is not there, so the finding is about access, not creation.
107
+ detail: `${root} could not be opened — nothing about an install was inspected`,
108
+ hint: "A permissions or filesystem problem. Check ownership of the directory, or set PI_CODING_AGENT_DIR to the one your install uses.",
109
+ });
110
+ }
111
+
112
+ // --- node --------------------------------------------------------------
113
+ const major = Number(process.versions.node.split(".")[0]);
114
+ results.push(
115
+ major >= MIN_NODE_MAJOR
116
+ ? { group, id: "node", title: "node", status: "ok", detail: `v${process.versions.node}` }
117
+ : {
118
+ group,
119
+ id: "node",
120
+ title: "node",
121
+ status: "fail",
122
+ detail: `v${process.versions.node} is below the minimum v${MIN_NODE_MAJOR}`,
123
+ hint: "Upgrade Node. Older releases miss APIs that packages here assume.",
124
+ },
125
+ );
126
+
127
+ // --- pi binary ---------------------------------------------------------
128
+ const piBin = path.join(root, "bin", isWindows ? "pi.cmd" : "pi");
129
+ const piBinAlt = path.join(root, "bin", "pi");
130
+ const found = [piBin, piBinAlt].find(exists);
131
+ let executable = false;
132
+ if (found && !isWindows) {
133
+ try {
134
+ executable = (fs.statSync(found).mode & 0o111) !== 0;
135
+ } catch {
136
+ executable = false;
137
+ }
138
+ } else {
139
+ executable = Boolean(found);
140
+ }
141
+ let launcherDetail;
142
+ let launcherStatus;
143
+ if (!found) {
144
+ // A pi installed by npm lives on PATH, not under the agent dir, so warning
145
+ // on the absence of ~/.pi/agent/bin/pi told those users their launcher was
146
+ // missing when `pi` was one PATH lookup away.
147
+ const onPath = whichAll("pi", env, { windows: isWindows }).find((c) => exists(c));
148
+ if (onPath) {
149
+ launcherStatus = "ok";
150
+ launcherDetail = `${onPath} (on PATH)`;
151
+ } else {
152
+ launcherStatus = "warn";
153
+ launcherDetail = `no launcher at ${path.join(root, "bin")} and no \`pi\` on PATH`;
154
+ }
155
+ } else if (!executable) {
156
+ launcherStatus = "warn";
157
+ launcherDetail = `${found} is not executable`;
158
+ } else {
159
+ launcherStatus = "ok";
160
+ launcherDetail = found;
161
+ }
162
+ results.push({
163
+ group,
164
+ id: "pi-bin",
165
+ title: "pi launcher",
166
+ status: launcherStatus,
167
+ detail: launcherDetail,
168
+ // "may resolve to a different install" presumes one exists. On a machine
169
+ // with no Pi at all — the first `npx pi-checkup` — that sentence is wrong.
170
+ hint: launcherHint(launcherStatus, found),
171
+ });
172
+
173
+ // --- disk --------------------------------------------------------------
174
+ try {
175
+ const stats = fs.statfsSync(root);
176
+ // Guarded separately from blocks: a mock or an exotic filesystem can report
177
+ // a block count without a block size, and NaN B free reads as a bug.
178
+ const free = Number(stats.bavail) * Number(stats.bsize) || 0;
179
+ // blocks is 0 on some virtual filesystems; 1 - 0/0 is NaN, which used to
180
+ // sail past the threshold check and print "NaN% used" as a green tick.
181
+ const blocks = Number(stats.blocks);
182
+ const usedPct = blocks > 0 ? Math.round((1 - Number(stats.bavail) / blocks) * 100) : null;
183
+ if (usedPct === null) {
184
+ results.push({
185
+ group,
186
+ id: "disk",
187
+ title: "disk",
188
+ status: "skip",
189
+ detail: `${humanBytes(free)} free on ${mountPointOf(root)}; this filesystem does not report a capacity`,
190
+ });
191
+ } else {
192
+ results.push(
193
+ usedPct >= 95
194
+ ? {
195
+ group,
196
+ id: "disk",
197
+ title: "disk",
198
+ status: "fail",
199
+ detail: `${usedPct}% used on ${mountPointOf(root)}, ${humanBytes(free)} free`,
200
+ hint: "Session files and package installs both fail when the volume fills up.",
201
+ }
202
+ : // The volume, not just a percentage: the report is scoped to one agent
203
+ // directory, and "6% used" on a machine with three mounted disks left
204
+ // the reader to guess which one had to have room.
205
+ { group, id: "disk", title: "disk", status: "ok", detail: `${usedPct}% used on ${mountPointOf(root)}, ${humanBytes(free)} free` },
206
+ );
207
+ }
208
+ } catch (e) {
209
+ results.push({
210
+ group,
211
+ id: "disk",
212
+ title: "disk",
213
+ status: "skip",
214
+ // A bare errno is the least polished line a first run ever shows, and on a
215
+ // machine that never ran Pi it is the expected state, not an error.
216
+ detail: isMissing(root) ? "agent directory does not exist yet — no volume to report on" : `could not stat ${root}: ${describeFsError(e)}`,
217
+ });
218
+ }
219
+
220
+ // --- proxy -------------------------------------------------------------
221
+ // Only meaningful when the user configured one. The default test is a plain
222
+ // TCP connect to the proxy's own host:port: that answers "is the proxy
223
+ // process running" without sending a single byte to a third party, which is
224
+ // the only version of this check that matches the promise that pi-checkup
225
+ // generates no traffic of its own. Set PI_CHECKUP_PROBE_URL to opt into a
226
+ // real end-to-end forwarding test through the proxy.
227
+ // Shared with the provider probe: the two used to disagree about what "a proxy
228
+ // is configured" means, so one reported a proxy the other silently ignored.
229
+ const proxy = proxyFromEnv(env);
230
+ if (!proxy) {
231
+ results.push({ group, id: "proxy", title: "proxy", status: "skip", detail: "none configured" });
232
+ } else if (offline) {
233
+ results.push({
234
+ group,
235
+ id: "proxy",
236
+ title: "proxy",
237
+ status: "skip",
238
+ detail: `configured (${redactProxy(proxy)}); not tested in offline mode`,
239
+ });
240
+ } else {
241
+ results.push(await checkProxy(proxy, env));
242
+ }
243
+
244
+ return results;
245
+ }
246
+
247
+ const DEFAULT_PROXY_PORT = { "http:": 80, "https:": 443, "socks5:": 1080, "socks5h:": 1080, "socks4:": 1080 };
248
+
249
+ // Re-exported so the report and the tests have one redaction primitive to reach
250
+ // for, and so the implementation lives next to redactUrl rather than beside the
251
+ // check that happened to need it first.
252
+ export { redactProxy };
253
+
254
+ /** Does something accept connections at this proxy's own address? */
255
+ async function checkProxy(proxy, env = process.env) {
256
+ const shown = redactProxy(proxy);
257
+ const row = (status, detail, hint) => ({ group, id: "proxy", title: "proxy", status, detail, hint });
258
+ let parsed;
259
+ try {
260
+ parsed = new URL(proxy);
261
+ } catch {
262
+ return row("warn", `configured (${shown}) but not a parsable URL`, "Expected something like http://127.0.0.1:10808.");
263
+ }
264
+ let hostname = parsed.hostname;
265
+ let port = Number(parsed.port) || DEFAULT_PROXY_PORT[parsed.protocol] || 0;
266
+ if (!hostname || !port) {
267
+ // `proxy.corp:8080` with no scheme is a shape curl accepts and this check
268
+ // used to warn about — the same env value, judged two different ways.
269
+ // Userinfo is stripped first: `user:pass@proxy.corp:8080` is a shape curl
270
+ // also accepts, and the credential is not part of the address. The last `@`
271
+ // is the separator, because a password may contain one — cutting at the
272
+ // first left `proxy.corp:8080` unparsed and reported a working proxy as
273
+ // having no usable host.
274
+ const trimmed = proxy.trim();
275
+ const at = trimmed.lastIndexOf("@");
276
+ const bare = at === -1 ? trimmed : trimmed.slice(at + 1);
277
+ const textual = /^([a-z0-9._\-[\]]+):(\d{1,5})$/i.exec(bare);
278
+ if (textual) {
279
+ hostname = textual[1];
280
+ port = Number(textual[2]);
281
+ }
282
+ }
283
+ if (!hostname || !port) {
284
+ return row("warn", `configured (${shown}) without a usable host and port`);
285
+ }
286
+
287
+ // Opt-in end-to-end test: an explicit probe URL means the user asked for
288
+ // real traffic through the proxy.
289
+ const probeUrl = env.PI_CHECKUP_PROBE_URL;
290
+ if (probeUrl) {
291
+ // curl reads a leading "-" as an option, so an inherited value like
292
+ // `--output=/etc/passwd` would be executed as one. Only an http(s) URL is
293
+ // something this check knows how to test anyway.
294
+ if (!/^https?:\/\//i.test(probeUrl)) {
295
+ return row("warn", `PI_CHECKUP_PROBE_URL is not an http(s) URL; forwarding was not tested`);
296
+ }
297
+ const probe = await curlProbe(probeUrl, { proxy });
298
+ // A non-zero curl exit is not "curl is missing": curl prints 000 to stdout
299
+ // for a refused or timed-out connection and exits 7 or 28. Reading only
300
+ // sh().ok turned a dead proxy into "skipped, tool unavailable".
301
+ if (probe.setupFailed) {
302
+ // The request never left the machine, so accusing the proxy would be a
303
+ // finding this check did not establish.
304
+ return row("skip", `configured (${shown}); the probe could not be set up: ${probe.error}`);
305
+ }
306
+ if (probe.missing) {
307
+ return row("skip", `configured (${shown}); curl is not installed so forwarding was not tested`);
308
+ }
309
+ const code = probe.code;
310
+ const numeric = Number(code);
311
+ // A code is not proof of forwarding: 407 is the proxy demanding its own
312
+ // credentials and 502/503/504 are what it emits when it could not reach the
313
+ // target, so "forwarded a request" over those would be a claim this check
314
+ // never established.
315
+ const forwarded = numeric > 0 && numeric !== 407 && ![502, 503, 504].includes(numeric);
316
+ if (forwarded) return row("ok", `${shown} forwarded a request (HTTP ${code})`);
317
+ const hint = "The proxy is reachable but did not pass the request through: check its own auth settings, or whether it allows this target.";
318
+ if (numeric > 0) return row("warn", `${shown} answered HTTP ${code} for the probe — the request was not forwarded`, hint);
319
+ return row("fail", `${shown} did not forward (HTTP ${code || "000"})`, hint);
320
+ }
321
+
322
+ const probe = await tcpProbe(bareHost(hostname), port);
323
+ if (probe.ok) {
324
+ return row("ok", `${shown} accepting connections`, "Set PI_CHECKUP_PROBE_URL to test real forwarding through it.");
325
+ }
326
+ // The 3s deadline is this process's. Folding it into the same branch as a
327
+ // refused connection reported a slow or firewalled proxy as "nothing
328
+ // listening" — the same local-timeout-blamed-on-the-remote mistake the
329
+ // network checks were rewritten to avoid.
330
+ if (probe.timedOut) {
331
+ return row("warn", `${shown} did not answer within ${Math.round(tcpProbeTimeoutMs / 1000)}s — not proven down`, "A slow or firewalled proxy looks like this. If it is supposed to be running, check that host and port directly.");
332
+ }
333
+ return row("fail", `nothing listening on ${shown} (${probe.error})`, "The proxy process is probably not running.");
334
+ }
335
+
336
+ const tcpProbeTimeoutMs = 3000;
337
+
338
+ /** Plain TCP connect, so the proxy check causes no third-party traffic. */
339
+ function tcpProbe(host, port, timeout = tcpProbeTimeoutMs) {
340
+ return new Promise((resolve) => {
341
+ let socket;
342
+ let settled = false;
343
+ const finish = (result) => {
344
+ if (settled) return;
345
+ settled = true;
346
+ socket?.destroy();
347
+ resolve(result);
348
+ };
349
+ try {
350
+ socket = net.connect({ host, port });
351
+ } catch (e) {
352
+ finish({ ok: false, error: String(e?.message ?? e) });
353
+ return;
354
+ }
355
+ socket.setTimeout(timeout);
356
+ socket.once("connect", () => finish({ ok: true }));
357
+ socket.once("timeout", () => finish({ ok: false, timedOut: true, error: "timed out" }));
358
+ socket.once("error", (e) => finish({ ok: false, error: String(e?.message ?? e) }));
359
+ });
360
+ }
package/src/context.js ADDED
@@ -0,0 +1,79 @@
1
+ // One place that decides where a check gets its inputs from.
2
+ //
3
+ // Inside a Pi session the authoritative source is the running process, so
4
+ // settings come from `pi.getSettings()` and the registry from the live tool and
5
+ // command tables. Outside a session (the standalone CLI) there is no process to
6
+ // ask, so settings are read from disk and the registry checks report skipped.
7
+ // Keeping that decision here means every check module stays ignorant of which
8
+ // mode it is running in, and can be pointed at a temporary root in tests.
9
+ import path from "node:path";
10
+ import { agentDir, readJson } from "./util.js";
11
+
12
+ /**
13
+ * @param {{ pi?: object, cwd?: string, offline?: boolean, agentDir?: string, env?: object, agentDirExplicit?: boolean }} input
14
+ */
15
+ export function buildContext({ pi, cwd, offline = false, agentDir: rootOverride, env, agentDirExplicit = false } = {}) {
16
+ const root = rootOverride ?? agentDir();
17
+ const settingsFile = path.join(root, "settings.json");
18
+ const modelsFile = path.join(root, "models.json");
19
+
20
+ // A Pi session exposes getSettings(); the CLI does not.
21
+ const hasRuntime = Boolean(pi && typeof pi.getSettings === "function");
22
+
23
+ let settings = null;
24
+ let settingsSource = "file";
25
+ // A live session whose settings could not be read is not the same thing as a
26
+ // session whose settings match the file: falling back to the file keeps the
27
+ // report useful, but a repair would then edit a file the running process may
28
+ // disagree with, so the source is recorded as neither.
29
+ let runtimeUnreadable = false;
30
+ // `settings === null` is two different states: a file that could not be read,
31
+ // and a file whose JSON is literally `null`. Only this flag separates them.
32
+ let settingsLoaded = false;
33
+ if (hasRuntime) {
34
+ try {
35
+ settings = pi.getSettings() ?? null;
36
+ settingsSource = "runtime";
37
+ settingsLoaded = true;
38
+ } catch {
39
+ settings = null;
40
+ runtimeUnreadable = true;
41
+ }
42
+ }
43
+ if (!settings) {
44
+ const parsed = readJson(settingsFile);
45
+ settings = parsed.ok ? parsed.data : null;
46
+ settingsLoaded = parsed.ok;
47
+ if (runtimeUnreadable) settingsSource = "runtime-unreadable";
48
+ else settingsSource = parsed.ok ? "file" : "unavailable";
49
+ }
50
+
51
+ return {
52
+ pi: pi ?? null,
53
+ cwd: cwd ?? process.cwd(),
54
+ agentDir: root,
55
+ settingsFile,
56
+ modelsFile,
57
+ // The two paths a check would otherwise rebuild itself. Deriving them here
58
+ // keeps the agent layout in one place and lets a test describe a project
59
+ // config or a .bin directory without touching the real tree.
60
+ projectSettingsFile: path.join(cwd ?? process.cwd(), ".pi", "settings.json"),
61
+ binDir: path.join(root, "npm", "node_modules", ".bin"),
62
+ settings,
63
+ settingsLoaded,
64
+ settingsSource,
65
+ hasRuntime,
66
+ // Whether the agent directory came from an explicit `PI_CODING_AGENT_DIR`
67
+ // rather than the default location. A directory that is missing answers two
68
+ // different questions depending on that: on a machine that never ran Pi it is
69
+ // simply the first use, but a path the user typed and got wrong is a problem
70
+ // with this run — and the two must not both report as a warning.
71
+ agentDirExplicit: Boolean(agentDirExplicit),
72
+ // Checks read the environment through the context, not from process.env, so
73
+ // a test can describe a proxy or an unset variable without touching the
74
+ // environment the suite itself runs in.
75
+ env: env ?? process.env,
76
+ // Set when the caller asked for checks that must not touch the network.
77
+ offline,
78
+ };
79
+ }
package/src/index.js ADDED
@@ -0,0 +1,109 @@
1
+ // pi-checkup — health checks for a Pi installation.
2
+ //
3
+ // `/checkup` answers the question `pi list` cannot: are the things you installed
4
+ // actually loaded, reachable, and running under the names you think they are?
5
+ //
6
+ // This file is the in-session command only. The checks live in ./checks/ and are
7
+ // shared with the standalone CLI in ../bin/checkup.js.
8
+ import { runChecks } from "./checks/index.js";
9
+ import { buildContext } from "./context.js";
10
+ import { renderReport, renderVerdict, toJson } from "./render.js";
11
+ import { packageVersion } from "./util.js";
12
+
13
+ const USAGE = [
14
+ "/checkup run every check",
15
+ "/checkup verbose run every check and show how to fix each finding",
16
+ "/checkup json print the report as JSON",
17
+ "/checkup fix apply the available repairs (asks first)",
18
+ "",
19
+ "Suppress rows you have accepted with PI_CHECKUP_IGNORE=id,Group:id,title",
20
+ ].join("\n");
21
+
22
+ export default function checkupExtension(pi) {
23
+ pi.registerCommand("checkup", {
24
+ description: "Health-check this Pi installation: registry, packages, binaries, network, config, runtime",
25
+ handler: async (rawArgs, ctx) => {
26
+ const words = (rawArgs ?? "")
27
+ .trim()
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .filter(Boolean);
31
+
32
+ if (words.includes("help") || words.includes("-h") || words.includes("--help")) {
33
+ ctx.ui.notify(USAGE, "info");
34
+ return;
35
+ }
36
+
37
+ const verbose = words.includes("verbose") || words.includes("-v") || words.includes("--verbose");
38
+ const asJson = words.includes("json") || words.includes("--json");
39
+ const wantsFix = words.includes("fix");
40
+
41
+ // Kept, not discarded: the repair needs the agent directory, and the
42
+ // Pi command context is not documented to carry one.
43
+ const checkCtx = buildContext({ pi, cwd: ctx.cwd });
44
+ const results = await runChecks(checkCtx);
45
+
46
+ if (asJson) {
47
+ const payload = toJson(results, { version: packageVersion(), node: process.versions.node, platform: process.platform });
48
+ ctx.ui.notify(JSON.stringify(payload, null, 2), "info");
49
+ return;
50
+ }
51
+
52
+ const report = renderReport(results, { verbose });
53
+ ctx.ui.setStatus?.("checkup", renderVerdict(results));
54
+
55
+ if (!wantsFix) {
56
+ const failed = results.filter((r) => r.status === "fail").length;
57
+ ctx.ui.notify(report, failed ? "error" : "info");
58
+ return;
59
+ }
60
+
61
+ // --- fix mode ---------------------------------------------------
62
+ const fixable = results.filter((r) => r.fix && r.fixTarget);
63
+ if (!fixable.length) {
64
+ ctx.ui.notify(`${report}\n\nNothing to repair automatically.`, "info");
65
+ return;
66
+ }
67
+
68
+ const summary = fixable.map((r) => ` • ${r.title}: ${r.detail}`).join("\n");
69
+ const approved = await ctx.ui.confirm(
70
+ `Apply ${fixable.length} repair(s)? A backup is written first.`,
71
+ `${summary}\n\nEvery file is backed up before it is written.`,
72
+ );
73
+ if (!approved) {
74
+ ctx.ui.notify(`${report}\n\nNo changes made.`, "info");
75
+ return;
76
+ }
77
+
78
+ const { applyRepairs } = await import("./repair.js");
79
+ const applied = await applyRepairs(fixable, { agentDir: checkCtx.agentDir });
80
+
81
+ const log = applied.map((a) => ` ${a.ok ? "✓" : "✗"} ${a.message}`).join("\n");
82
+ // The verdict has to describe the tree after the repair, for the same
83
+ // reason the CLI's exit code does: reporting the pre-repair findings
84
+ // under a "repaired" message tells the user the fix did nothing.
85
+ let verdict = renderVerdict(results);
86
+ // The label has to follow the fact, not the intent: when every repair
87
+ // failed there was no second run, and calling the pre-repair verdict
88
+ // "re-checked" is the same contradiction the CLI avoids.
89
+ // `changed`, not `ok`: a repair that found nothing to do still reports
90
+ // success, and re-checking plus asking for a reload for a file nobody
91
+ // wrote is the same "did something" claim in a different place.
92
+ const wrote = applied.some((a) => a.changed);
93
+ let rechecked = false;
94
+ if (wrote) {
95
+ const after = await runChecks(buildContext({ pi, cwd: ctx.cwd }));
96
+ verdict = renderVerdict(after);
97
+ rechecked = true;
98
+ ctx.ui.setStatus?.("checkup", verdict);
99
+ }
100
+ // Same rule as the CLI: a refused or empty repair changed nothing, so
101
+ // asking for a reload would be a step that cannot matter.
102
+ const reload = rechecked ? "\n\nRun /reload so Pi picks up the changes." : "";
103
+ ctx.ui.notify(
104
+ `${report}\n\nRepairs\n${log || " (nothing applied)"}\n\n${verdict}${rechecked ? " · re-checked after repair" : ""}${reload}`,
105
+ "info",
106
+ );
107
+ },
108
+ });
109
+ }