@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.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.
Files changed (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -0,0 +1,821 @@
1
+ /**
2
+ * Doctor core — shared diagnostic primitives used by both the Electron app
3
+ * (`packages/electron/src/lib/doctor.ts`) and the dashboard server route
4
+ * (`packages/server/src/routes/doctor-routes.ts`).
5
+ *
6
+ * Hosts the canonical type system, section taxonomy, suggestion mapping,
7
+ * fault-tolerance helpers (`safeCheck` / `safeExec` / `assumedMandatory`),
8
+ * a shared `runSharedChecks` for non-Electron checks, and the Markdown
9
+ * report formatter.
10
+ *
11
+ * See change: doctor-rich-output (proposal.md, design.md).
12
+ */
13
+ import { execSync } from "./platform/exec.js";
14
+ import { existsSync, readFileSync, statSync, renameSync, appendFileSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ // ─── Types ─────────────────────────────────────────────────────────────
18
+
19
+ export type DoctorSection = "runtime" | "pi-tooling" | "server" | "setup" | "diagnostics";
20
+
21
+ export type DoctorStatus = "ok" | "warning" | "error";
22
+
23
+ export type ExecFailureKind =
24
+ | "not-found"
25
+ | "permission-denied"
26
+ | "timeout"
27
+ | "non-zero-exit"
28
+ | "unknown";
29
+
30
+ export interface DoctorCheck {
31
+ name: string;
32
+ status: DoctorStatus;
33
+ section: DoctorSection;
34
+ message: string;
35
+ detail?: string;
36
+ suggestion?: string;
37
+ fixable?: boolean;
38
+ /** Populated when the check ran an external command and it failed. */
39
+ kind?: ExecFailureKind;
40
+ }
41
+
42
+ export interface DoctorReport {
43
+ checks: DoctorCheck[];
44
+ summary: { ok: number; warnings: number; errors: number };
45
+ generatedAt?: number;
46
+ }
47
+
48
+ // ─── stripAnsi ─────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Strip standard ANSI CSI / OSC escape sequences. No external dependency.
52
+ */
53
+ export function stripAnsi(input: string): string {
54
+ if (!input) return "";
55
+ // CSI sequences: ESC [ ... letter (incl. SGR colors, cursor moves)
56
+ // OSC sequences: ESC ] ... BEL or ESC \
57
+ // Plus a few standalone escapes (ESC = ESC + char like ESC ( B).
58
+ // eslint-disable-next-line no-control-regex
59
+ const csi = /\u001b\[[0-?]*[ -/]*[@-~]/g;
60
+ // eslint-disable-next-line no-control-regex
61
+ const osc = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
62
+ // eslint-disable-next-line no-control-regex
63
+ const single = /\u001b[@-Z\\-_]/g;
64
+ return input.replace(csi, "").replace(osc, "").replace(single, "");
65
+ }
66
+
67
+ // ─── safeExec ──────────────────────────────────────────────────────────
68
+
69
+ export interface SafeExecOk {
70
+ ok: true;
71
+ stdout: string;
72
+ }
73
+ export interface SafeExecErr {
74
+ ok: false;
75
+ kind: ExecFailureKind;
76
+ message: string;
77
+ detail: string;
78
+ exitCode?: number;
79
+ stderrTail?: string;
80
+ /** Whatever timeoutMs was used for the call (ms). */
81
+ timeoutMs: number;
82
+ }
83
+ export type SafeExecResult = SafeExecOk | SafeExecErr;
84
+
85
+ export interface SafeExecOpts {
86
+ timeoutMs?: number;
87
+ env?: NodeJS.ProcessEnv;
88
+ cwd?: string;
89
+ }
90
+
91
+ /**
92
+ * Run a command via `execSync`, classify failures, and capture a stderr tail.
93
+ *
94
+ * Defaults: 5000 ms timeout, `windowsHide: true`. Cold-start probes (bundled
95
+ * Node, server-launch test) pass `timeoutMs: 15000`.
96
+ */
97
+ export function safeExec(cmd: string, opts: SafeExecOpts = {}): SafeExecResult {
98
+ const timeoutMs = opts.timeoutMs ?? 5000;
99
+ try {
100
+ const stdout = execSync(cmd, {
101
+ encoding: "utf-8",
102
+ stdio: ["pipe", "pipe", "pipe"],
103
+ timeout: timeoutMs,
104
+ env: opts.env,
105
+ cwd: opts.cwd,
106
+ });
107
+ return { ok: true, stdout: stdout.toString() };
108
+ } catch (err) {
109
+ return classifyExecError(err, cmd, timeoutMs);
110
+ }
111
+ }
112
+
113
+ function classifyExecError(err: unknown, cmd: string, timeoutMs: number): SafeExecErr {
114
+ const e = err as NodeJS.ErrnoException & {
115
+ status?: number;
116
+ signal?: NodeJS.Signals | null;
117
+ stdout?: Buffer | string;
118
+ stderr?: Buffer | string;
119
+ };
120
+ const stderrRaw = e.stderr ? e.stderr.toString() : "";
121
+ const stderrTail = stripAnsi(stderrRaw).slice(-500);
122
+ const stdoutRaw = e.stdout ? e.stdout.toString() : "";
123
+ const code = e.code ?? "";
124
+ const errno = (e as { errno?: number }).errno;
125
+ const status = e.status;
126
+ const signal = e.signal;
127
+ const baseMsg = e.message || String(err);
128
+
129
+ // ENOENT — binary not found / file missing
130
+ if (code === "ENOENT") {
131
+ return {
132
+ ok: false,
133
+ kind: "not-found",
134
+ message: "Command not found",
135
+ detail: `${cmd}\n${baseMsg}`,
136
+ stderrTail: stderrTail || undefined,
137
+ timeoutMs,
138
+ };
139
+ }
140
+ // EACCES / EPERM — permission denied
141
+ if (code === "EACCES" || code === "EPERM") {
142
+ return {
143
+ ok: false,
144
+ kind: "permission-denied",
145
+ message: "Permission denied",
146
+ detail: `${cmd}\n${baseMsg}`,
147
+ stderrTail: stderrTail || undefined,
148
+ timeoutMs,
149
+ };
150
+ }
151
+ // Timeout — execSync throws ETIMEDOUT (errno -2 on linux, signal SIGTERM, code "ETIMEDOUT")
152
+ if (
153
+ code === "ETIMEDOUT" ||
154
+ signal === "SIGTERM" ||
155
+ errno === -2 ||
156
+ /timed?\s*out/i.test(baseMsg)
157
+ ) {
158
+ return {
159
+ ok: false,
160
+ kind: "timeout",
161
+ message: `Command did not respond within ${Math.round(timeoutMs / 1000)}s`,
162
+ detail: `${cmd}\nDeadline: ${timeoutMs}ms`,
163
+ stderrTail: stderrTail || undefined,
164
+ timeoutMs,
165
+ };
166
+ }
167
+ // Non-zero exit
168
+ if (typeof status === "number" && status !== 0) {
169
+ return {
170
+ ok: false,
171
+ kind: "non-zero-exit",
172
+ message: `Command exited with status ${status}`,
173
+ detail: `${cmd}${stdoutRaw ? `\nstdout: ${stripAnsi(stdoutRaw).slice(-200)}` : ""}`,
174
+ exitCode: status,
175
+ stderrTail: stderrTail || undefined,
176
+ timeoutMs,
177
+ };
178
+ }
179
+ // Unknown
180
+ return {
181
+ ok: false,
182
+ kind: "unknown",
183
+ message: "Command failed",
184
+ detail: `${cmd}\n${baseMsg}`,
185
+ stderrTail: stderrTail || undefined,
186
+ timeoutMs,
187
+ };
188
+ }
189
+
190
+ // ─── safeCheck ─────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Per-check fault-isolation wrapper. Catches any throw / rejection from
194
+ * `fn` and returns a `diagnostics`-section error row that carries a
195
+ * non-empty `message` / `detail` / `suggestion`. Never propagates.
196
+ */
197
+ export async function safeCheck(
198
+ name: string,
199
+ section: DoctorSection,
200
+ fn: () => DoctorCheck | Promise<DoctorCheck>,
201
+ ): Promise<DoctorCheck> {
202
+ try {
203
+ const result = await fn();
204
+ // If caller forgot to set section, default it.
205
+ if (!result.section) result.section = section;
206
+ return result;
207
+ } catch (err) {
208
+ const e = err instanceof Error ? err : new Error(String(err));
209
+ const stack = (e.stack || "").split("\n").slice(0, 4).join("\n");
210
+ return {
211
+ name,
212
+ section,
213
+ status: "error",
214
+ message: "Check failed to run",
215
+ detail: `${e.message}\n${stack}`,
216
+ suggestion:
217
+ "This is a doctor-internal failure. Please file an issue with the Markdown export attached.",
218
+ };
219
+ }
220
+ }
221
+
222
+ // ─── assumedMandatory ─────────────────────────────────────────────────
223
+
224
+ export interface AssumedDeps {
225
+ /** Managed install dir. `<managedDir>/doctor.log` is the log path. */
226
+ managedDir: string;
227
+ }
228
+
229
+ const DOCTOR_LOG_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
230
+
231
+ /**
232
+ * Wrap a "should-never-fail" operation. On throw:
233
+ * 1. Append a JSON line to `<managedDir>/doctor.log` (with prior ring rotation if >1MB).
234
+ * 2. Return a diagnostics-section error row labelled "Doctor internal: <label>".
235
+ *
236
+ * Both rotation and append are wrapped in try/catch and silently drop
237
+ * on failure — a broken log file MUST never cascade into the report.
238
+ */
239
+ export function assumedMandatory<T>(
240
+ label: string,
241
+ fn: () => T,
242
+ deps: AssumedDeps,
243
+ ): { ok: true; value: T } | { ok: false; row: DoctorCheck } {
244
+ try {
245
+ return { ok: true, value: fn() };
246
+ } catch (err) {
247
+ const e = err instanceof Error ? err : new Error(String(err));
248
+ appendDoctorLog(deps.managedDir, label, e);
249
+ return {
250
+ ok: false,
251
+ row: {
252
+ name: `Doctor internal: ${label}`,
253
+ section: "diagnostics",
254
+ status: "error",
255
+ message: "An assumed-safe operation failed",
256
+ detail: `${e.message}\n${(e.stack || "").split("\n").slice(0, 4).join("\n")}`,
257
+ suggestion:
258
+ "Open `~/.pi-dashboard/doctor.log` for full context, then file an issue with the Markdown export attached.",
259
+ },
260
+ };
261
+ }
262
+ }
263
+
264
+ function appendDoctorLog(managedDir: string, label: string, err: Error): void {
265
+ try {
266
+ const logPath = path.join(managedDir, "doctor.log");
267
+ rotateDoctorLogIfNeeded(logPath);
268
+ const line =
269
+ JSON.stringify({
270
+ ts: new Date().toISOString(),
271
+ label,
272
+ message: err.message,
273
+ stack: (err.stack || "").split("\n").slice(0, 6).join(" | "),
274
+ }) + "\n";
275
+ appendFileSync(logPath, line, { encoding: "utf-8" });
276
+ } catch {
277
+ // logging failure must never propagate
278
+ }
279
+ }
280
+
281
+ function rotateDoctorLogIfNeeded(logPath: string): void {
282
+ try {
283
+ if (!existsSync(logPath)) return;
284
+ const size = statSync(logPath).size;
285
+ if (size <= DOCTOR_LOG_MAX_BYTES) return;
286
+ const rotated = `${logPath}.1`;
287
+ try {
288
+ // Best-effort: rename overwrites on POSIX, but on Windows we may need to remove the old .1 first.
289
+ renameSync(logPath, rotated);
290
+ } catch {
291
+ // try once more after best-effort cleanup
292
+ try {
293
+ const fs = require("node:fs") as typeof import("node:fs");
294
+ if (existsSync(rotated)) fs.rmSync(rotated, { force: true });
295
+ renameSync(logPath, rotated);
296
+ } catch {
297
+ // give up silently
298
+ }
299
+ }
300
+ } catch {
301
+ // never propagate
302
+ }
303
+ }
304
+
305
+ // ─── Section + suggestion taxonomy ────────────────────────────────────
306
+
307
+ /**
308
+ * Canonical check-name → section. Every check name pushed by either
309
+ * `runSharedChecks` (here) or `runDoctor` (Electron) MUST appear here.
310
+ */
311
+ export const SECTION_OF: Record<string, DoctorSection> = {
312
+ // runtime
313
+ Electron: "runtime",
314
+ "System Node.js": "runtime",
315
+ "Bundled Node.js": "runtime",
316
+ "Bundled npm": "runtime",
317
+ "Managed Node runtime": "runtime",
318
+ // pi-tooling
319
+ "pi CLI": "pi-tooling",
320
+ "openspec CLI": "pi-tooling",
321
+ // server
322
+ "Dashboard server code": "server",
323
+ "Offline packages bundle": "server",
324
+ "TypeScript loader (tsx)": "server",
325
+ "Dashboard server": "server",
326
+ "Server starter": "server",
327
+ "Installable list": "server",
328
+ "Server log (~/.pi-dashboard/server.log)": "server",
329
+ "Server launch test": "server",
330
+ // setup
331
+ "Setup wizard": "setup",
332
+ "API key": "setup",
333
+ // diagnostics
334
+ "Managed install (~/.pi-dashboard)": "diagnostics",
335
+ };
336
+
337
+ /**
338
+ * Suggestion factories. Returns a remediation string tailored to the
339
+ * status / failure kind, or `undefined` for ok rows.
340
+ *
341
+ * Strings use only the small Markdown subset `**bold**`,
342
+ * single-backtick `code`, `[text](url)`. Lint-enforced in
343
+ * `doctor-core.test.ts`.
344
+ */
345
+ export type SuggestionFn = (
346
+ status: DoctorStatus,
347
+ detail?: string,
348
+ kind?: ExecFailureKind,
349
+ ) => string | undefined;
350
+
351
+ const reinstallPi = "Reinstall **PI Dashboard** or run the setup wizard from the App menu (Help → Setup).";
352
+
353
+ function execKindSuggestion(label: string, kind?: ExecFailureKind, timeoutSec = 5): string {
354
+ switch (kind) {
355
+ case "not-found":
356
+ return `${label} binary missing. Reinstall **PI Dashboard** or check your PATH.`;
357
+ case "permission-denied":
358
+ return `${label} binary not executable. On Linux run `+"`chmod +x <path>`"+`; on macOS run `+"`xattr -cr <Resources>`"+` to clear quarantine.`;
359
+ case "timeout":
360
+ return `${label} did not respond within ${timeoutSec}s. Antivirus or endpoint security is likely scanning the binary on first launch — wait 30s and re-run, or whitelist the app.`;
361
+ case "non-zero-exit":
362
+ return `${label} executed but reported failure. ${reinstallPi}`;
363
+ default:
364
+ return `${label} failed for an unknown reason. ${reinstallPi}`;
365
+ }
366
+ }
367
+
368
+ export const SUGGESTIONS: Record<string, SuggestionFn> = {
369
+ Electron: () => undefined, // never fails today
370
+ "System Node.js": (status) =>
371
+ status === "ok"
372
+ ? undefined
373
+ : "System Node.js not on PATH. The bundled runtime will be used; this is fine for most users. To install, see [nodejs.org](https://nodejs.org).",
374
+ "Bundled Node.js": (status, _d, kind) =>
375
+ status === "ok" ? undefined : execKindSuggestion("Bundled Node", kind, 15),
376
+ "Bundled npm": (status, _d, kind) =>
377
+ status === "ok" ? undefined : execKindSuggestion("Bundled npm", kind, 5),
378
+ "Managed Node runtime": (status) =>
379
+ status === "ok"
380
+ ? undefined
381
+ : "Managed Node runtime missing under `~/.pi-dashboard/node`. Re-run the setup wizard (Help → Setup).",
382
+ "pi CLI": (status, _d, kind) =>
383
+ status === "ok"
384
+ ? undefined
385
+ : kind
386
+ ? execKindSuggestion("pi CLI", kind, 5)
387
+ : "`pi` not found. Run the setup wizard (Help → Setup) to install it under `~/.pi-dashboard`.",
388
+ "openspec CLI": (status, _d, kind) =>
389
+ status === "ok"
390
+ ? undefined
391
+ : kind
392
+ ? execKindSuggestion("openspec CLI", kind, 5)
393
+ : "`openspec` not found. Optional, but required for OpenSpec workflows. Run the setup wizard.",
394
+ "Dashboard server code": (status) =>
395
+ status === "ok"
396
+ ? undefined
397
+ : "Dashboard server code not found in app resources. Reinstall **PI Dashboard**.",
398
+ "Offline packages bundle": (status) =>
399
+ status === "ok"
400
+ ? undefined
401
+ : "Offline packages bundle absent. First-run install will require network access to `registry.npmjs.org`.",
402
+ "TypeScript loader (tsx)": (status) =>
403
+ status === "ok"
404
+ ? undefined
405
+ : "`tsx` not found. Required to run the dashboard server. Run the setup wizard (Help → Setup).",
406
+ "Dashboard server": (status) =>
407
+ status === "ok"
408
+ ? undefined
409
+ : "Dashboard server not running on `http://localhost:8000`. It will be started automatically when needed.",
410
+ "Server starter": (status) =>
411
+ status === "ok"
412
+ ? undefined
413
+ : "Server starter unknown — older server build. Restart the server.",
414
+ "Installable list": (status) =>
415
+ status === "ok"
416
+ ? undefined
417
+ : "Some installable packages failed to install. Check `~/.pi-dashboard/server.log` for details.",
418
+ "Server log (~/.pi-dashboard/server.log)": (status) =>
419
+ status === "ok"
420
+ ? undefined
421
+ : "Recent server log entries shown — the server may have failed to start. Open the log for full context.",
422
+ "Server launch test": (status, _d, kind) =>
423
+ status === "ok"
424
+ ? undefined
425
+ : kind
426
+ ? execKindSuggestion("Server launch test", kind, 15)
427
+ : "Server failed to start during the doctor's test launch. Check `detail` for the captured stderr.",
428
+ "Setup wizard": (status) =>
429
+ status === "ok"
430
+ ? undefined
431
+ : "Setup wizard has not completed. Open **Help → Setup** in the app menu.",
432
+ "API key": (status) =>
433
+ status === "ok"
434
+ ? undefined
435
+ : "No API key configured. Pi sessions need an LLM provider key. Configure one in **Settings → Providers**.",
436
+ "Managed install (~/.pi-dashboard)": (status) =>
437
+ status === "ok"
438
+ ? undefined
439
+ : "Managed install incomplete. Run the setup wizard (**Help → Setup**) to finish first-run install.",
440
+ };
441
+
442
+ // ─── runSharedChecks ──────────────────────────────────────────────────
443
+
444
+ export interface SharedChecksDeps {
445
+ managedDir: string;
446
+ /** Detector for system Node ({path, found}). */
447
+ detectSystemNode: () => { found: boolean; path?: string };
448
+ /** Detector for pi CLI ({path, source, found}). */
449
+ detectPi: () => { found: boolean; path?: string; source?: string };
450
+ /** Detector for openspec CLI. */
451
+ detectOpenSpec: () => { found: boolean; path?: string; source?: string };
452
+ /** Optional: localhost server probe. Default uses curl-style fetch. */
453
+ probeServer?: () => Promise<{
454
+ running: boolean;
455
+ version?: string;
456
+ mode?: string;
457
+ starter?: string | null;
458
+ installable?: { total: number; installed: number; failed: string[] } | null;
459
+ }>;
460
+ /** Optional: api-key check. */
461
+ isApiKeyConfigured?: () => boolean;
462
+ }
463
+
464
+ export async function runSharedChecks(deps: SharedChecksDeps): Promise<DoctorCheck[]> {
465
+ const checks: DoctorCheck[] = [];
466
+ const managedDir = deps.managedDir;
467
+
468
+ // System Node
469
+ checks.push(
470
+ await safeCheck("System Node.js", "runtime", () => {
471
+ const sys = deps.detectSystemNode();
472
+ if (!sys.found) {
473
+ return {
474
+ name: "System Node.js",
475
+ section: "runtime",
476
+ status: "warning",
477
+ message: "Not found on PATH (bundled Node will be used)",
478
+ detail: "PATH searched without success",
479
+ };
480
+ }
481
+ const ver = safeExec(`"${sys.path}" --version`, { timeoutMs: 5000 });
482
+ if (!ver.ok) {
483
+ return {
484
+ name: "System Node.js",
485
+ section: "runtime",
486
+ status: "warning",
487
+ message: ver.message,
488
+ detail: `${ver.detail}${ver.stderrTail ? `\nstderr: ${ver.stderrTail}` : ""}`,
489
+ kind: ver.kind,
490
+ };
491
+ }
492
+ return {
493
+ name: "System Node.js",
494
+ section: "runtime",
495
+ status: "ok",
496
+ message: `${ver.stdout.trim()} at ${sys.path}`,
497
+ };
498
+ }),
499
+ );
500
+
501
+ // pi CLI
502
+ checks.push(
503
+ await safeCheck("pi CLI", "pi-tooling", () => {
504
+ const pi = deps.detectPi();
505
+ if (!pi.found || !pi.path) {
506
+ return {
507
+ name: "pi CLI",
508
+ section: "pi-tooling",
509
+ status: "error",
510
+ message: "Not found — required to run agent sessions",
511
+ detail: "Searched system PATH and managed install",
512
+ fixable: true,
513
+ };
514
+ }
515
+ const ver = safeExec(`"${pi.path}" --version`, { timeoutMs: 5000 });
516
+ const versionDisplay = ver.ok ? ver.stdout.trim() : "?";
517
+ return {
518
+ name: "pi CLI",
519
+ section: "pi-tooling",
520
+ status: "ok",
521
+ message: `${versionDisplay} (${pi.source ?? "unknown"}) at ${pi.path}`,
522
+ };
523
+ }),
524
+ );
525
+
526
+ // openspec CLI
527
+ checks.push(
528
+ await safeCheck("openspec CLI", "pi-tooling", () => {
529
+ const os = deps.detectOpenSpec();
530
+ if (!os.found || !os.path) {
531
+ return {
532
+ name: "openspec CLI",
533
+ section: "pi-tooling",
534
+ status: "warning",
535
+ message: "Not found — optional, needed for OpenSpec workflows",
536
+ detail: "Searched system PATH and managed install",
537
+ fixable: true,
538
+ };
539
+ }
540
+ const ver = safeExec(`"${os.path}" --version`, { timeoutMs: 5000 });
541
+ const versionDisplay = ver.ok ? ver.stdout.trim() : "?";
542
+ return {
543
+ name: "openspec CLI",
544
+ section: "pi-tooling",
545
+ status: "ok",
546
+ message: `${versionDisplay} (${os.source ?? "unknown"}) at ${os.path}`,
547
+ };
548
+ }),
549
+ );
550
+
551
+ // tsx (TypeScript loader)
552
+ checks.push(
553
+ await safeCheck("TypeScript loader (tsx)", "server", () => {
554
+ const managedTsxPkg = path.join(managedDir, "node_modules", "tsx", "package.json");
555
+ let tsxVersion: string | null = null;
556
+ try {
557
+ if (existsSync(managedTsxPkg)) {
558
+ const pkg = JSON.parse(readFileSync(managedTsxPkg, "utf-8"));
559
+ tsxVersion = pkg.version || null;
560
+ }
561
+ } catch {
562
+ // ignore
563
+ }
564
+ let systemTsx: string | null = null;
565
+ const lookupCmd = process.platform === "win32" ? "where tsx" : "which tsx"; // platform-branch-ok: localised PATH-lookup primitive
566
+ const lookup = safeExec(lookupCmd, { timeoutMs: 5000 });
567
+ if (lookup.ok) {
568
+ systemTsx = lookup.stdout.trim().split("\n")[0] || null;
569
+ }
570
+ const found = !!tsxVersion || !!systemTsx;
571
+ if (!found) {
572
+ return {
573
+ name: "TypeScript loader (tsx)",
574
+ section: "server",
575
+ status: "error",
576
+ message: "Not found — required to run the dashboard server",
577
+ detail: `Looked under ${managedTsxPkg} and on PATH`,
578
+ fixable: true,
579
+ };
580
+ }
581
+ return {
582
+ name: "TypeScript loader (tsx)",
583
+ section: "server",
584
+ status: "ok",
585
+ message: tsxVersion
586
+ ? `v${tsxVersion} (managed) at ${path.dirname(managedTsxPkg)}`
587
+ : `(system) at ${systemTsx}`,
588
+ };
589
+ }),
590
+ );
591
+
592
+ // Dashboard server probe
593
+ checks.push(
594
+ await safeCheck("Dashboard server", "server", async () => {
595
+ if (!deps.probeServer) {
596
+ return {
597
+ name: "Dashboard server",
598
+ section: "server",
599
+ status: "warning",
600
+ message: "Not probed (no probe configured)",
601
+ detail: "deps.probeServer was not provided",
602
+ };
603
+ }
604
+ const r = await deps.probeServer();
605
+ if (!r.running) {
606
+ return {
607
+ name: "Dashboard server",
608
+ section: "server",
609
+ status: "warning",
610
+ message: "Not running — will be started automatically when needed",
611
+ detail: "GET http://localhost:8000/api/health returned no response",
612
+ };
613
+ }
614
+ return {
615
+ name: "Dashboard server",
616
+ section: "server",
617
+ status: "ok",
618
+ message: `Running${r.version ? " v" + r.version : ""}${r.mode ? " (" + r.mode + " mode)" : ""} at http://localhost:8000`,
619
+ };
620
+ }),
621
+ );
622
+
623
+ // Server log presence (filesystem read — assumedMandatory)
624
+ {
625
+ const logPath = path.join(managedDir, "server.log");
626
+ const result = assumedMandatory(
627
+ "read server.log tail",
628
+ () => {
629
+ if (!existsSync(logPath)) return null;
630
+ const content = readFileSync(logPath, "utf-8");
631
+ return content.split("\n").slice(-10).join("\n").trim();
632
+ },
633
+ { managedDir },
634
+ );
635
+ if (!result.ok) {
636
+ checks.push(result.row);
637
+ } else if (result.value) {
638
+ checks.push({
639
+ name: "Server log (~/.pi-dashboard/server.log)",
640
+ section: "server",
641
+ status: "warning",
642
+ message: "Last entries:",
643
+ detail: result.value,
644
+ });
645
+ }
646
+ }
647
+
648
+ // API key
649
+ if (deps.isApiKeyConfigured) {
650
+ checks.push(
651
+ await safeCheck("API key", "setup", () => {
652
+ const has = deps.isApiKeyConfigured!();
653
+ return {
654
+ name: "API key",
655
+ section: "setup",
656
+ status: has ? "ok" : "warning",
657
+ message: has
658
+ ? "Configured in pi settings"
659
+ : "Not configured — pi sessions will need a key to use LLM providers",
660
+ detail: has
661
+ ? undefined
662
+ : `Looked at ~/.pi/agent/settings.json (anthropicApiKey / openaiApiKey / providers[].apiKey)`,
663
+ };
664
+ }),
665
+ );
666
+ }
667
+
668
+ // Managed install
669
+ checks.push(
670
+ await safeCheck("Managed install (~/.pi-dashboard)", "diagnostics", () => {
671
+ const managedExists = existsSync(managedDir);
672
+ const managedModules = existsSync(path.join(managedDir, "node_modules"));
673
+ const okState = managedExists && managedModules;
674
+ return {
675
+ name: "Managed install (~/.pi-dashboard)",
676
+ section: "diagnostics",
677
+ status: okState ? "ok" : "warning",
678
+ message: managedExists
679
+ ? managedModules
680
+ ? `Exists with node_modules at ${managedDir}`
681
+ : "Exists but no node_modules — may need reinstall"
682
+ : "Not created yet — will be set up on first run",
683
+ detail: okState ? undefined : `Path: ${managedDir}`,
684
+ };
685
+ }),
686
+ );
687
+
688
+ return checks;
689
+ }
690
+
691
+ // ─── Stamping helper ──────────────────────────────────────────────────
692
+
693
+ /**
694
+ * Single post-pass. Stamps `section` (using SECTION_OF when not already set)
695
+ * and `suggestion` (when status is non-ok). Mutates in place AND returns.
696
+ */
697
+ export function stampSectionsAndSuggestions(checks: DoctorCheck[]): DoctorCheck[] {
698
+ for (const c of checks) {
699
+ if (!c.section) {
700
+ const inferred = SECTION_OF[c.name];
701
+ if (inferred) c.section = inferred;
702
+ else c.section = "diagnostics";
703
+ }
704
+ if (c.status !== "ok" && !c.suggestion) {
705
+ const fn = SUGGESTIONS[c.name];
706
+ const s = fn?.(c.status, c.detail, c.kind);
707
+ if (s) c.suggestion = s;
708
+ }
709
+ }
710
+ return checks;
711
+ }
712
+
713
+ // ─── Markdown formatter ───────────────────────────────────────────────
714
+
715
+ const SECTION_ORDER: DoctorSection[] = [
716
+ "runtime",
717
+ "pi-tooling",
718
+ "server",
719
+ "setup",
720
+ "diagnostics",
721
+ ];
722
+ const SECTION_LABEL: Record<DoctorSection, string> = {
723
+ runtime: "Runtime",
724
+ "pi-tooling": "PI Tooling",
725
+ server: "Server",
726
+ setup: "Setup",
727
+ diagnostics: "Diagnostics",
728
+ };
729
+
730
+ /** Escape pipe / newline / backtick so cell content cannot break the table. */
731
+ function fenceCell(text: string | undefined): string {
732
+ if (!text) return "";
733
+ // Wrap in fenced text inline. Markdown table cells don't honour real fences,
734
+ // but we wrap with backticks-as-code and replace bar / newline / backtick
735
+ // with safe substitutes so the column count stays intact.
736
+ const safe = text
737
+ .replace(/\\/g, "\\\\")
738
+ .replace(/`/g, "\\`")
739
+ .replace(/\|/g, "\\|")
740
+ .replace(/\r?\n/g, "<br>");
741
+ return "<code>" + safe + "</code>";
742
+ }
743
+
744
+ function statusIcon(s: DoctorStatus): string {
745
+ return s === "ok" ? "✅" : s === "warning" ? "⚠️" : "❌";
746
+ }
747
+
748
+ export function formatDoctorReportMarkdown(report: DoctorReport): string {
749
+ const lines: string[] = [];
750
+ const { ok, warnings, errors } = report.summary;
751
+ lines.push(`# PI Dashboard Doctor`);
752
+ lines.push("");
753
+ lines.push(`**Summary:** ${ok} ok · ${warnings} warning(s) · ${errors} error(s)`);
754
+ lines.push("");
755
+
756
+ for (const section of SECTION_ORDER) {
757
+ const rows = report.checks.filter((c) => c.section === section);
758
+ if (rows.length === 0) continue;
759
+ lines.push(`## ${SECTION_LABEL[section]}`);
760
+ lines.push("");
761
+ lines.push("| Status | Check | Message | Detail |");
762
+ lines.push("| --- | --- | --- | --- |");
763
+ for (const c of rows) {
764
+ const detailCell = c.detail ? fenceCell(c.detail) : "";
765
+ const messageCell = c.message.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
766
+ lines.push(`| ${statusIcon(c.status)} | ${c.name} | ${messageCell} | ${detailCell} |`);
767
+ }
768
+ lines.push("");
769
+ }
770
+
771
+ const nonOk = report.checks.filter((c) => c.status !== "ok" && c.suggestion);
772
+ if (nonOk.length > 0) {
773
+ lines.push(`## Remediation`);
774
+ lines.push("");
775
+ for (const c of nonOk) {
776
+ lines.push(`- **${c.name}** — ${c.suggestion}`);
777
+ }
778
+ lines.push("");
779
+ }
780
+
781
+ return lines.join("\n");
782
+ }
783
+
784
+ // ─── Plain-text formatter ─────────────────────────────────────────────
785
+
786
+ /**
787
+ * Plain-text formatter, byte-compatible with the legacy
788
+ * `formatDoctorReport` in `packages/electron/src/lib/doctor.ts`.
789
+ * Re-exported from there so callers see no change.
790
+ */
791
+ export function formatDoctorReportPlain(report: DoctorReport): string {
792
+ const lines: string[] = [];
793
+ lines.push("PI Dashboard Doctor");
794
+ lines.push("═".repeat(50));
795
+ lines.push("");
796
+
797
+ for (const check of report.checks) {
798
+ const icon = check.status === "ok" ? "✓" : check.status === "warning" ? "⚠" : "✗";
799
+ const fixHint = check.fixable ? " [fixable]" : "";
800
+ lines.push(` ${icon} ${check.name}${fixHint}`);
801
+ lines.push(` ${check.message}`);
802
+ if (check.detail) lines.push(` ${check.detail}`);
803
+ }
804
+
805
+ lines.push("");
806
+ lines.push("─".repeat(50));
807
+ lines.push(
808
+ ` ${report.summary.ok} passed, ${report.summary.warnings} warnings, ${report.summary.errors} errors`,
809
+ );
810
+
811
+ if (report.summary.errors > 0) {
812
+ const fixable = report.checks.filter((c) => c.status === "error" && c.fixable);
813
+ if (fixable.length > 0) {
814
+ lines.push("");
815
+ lines.push(` ${fixable.length} error(s) can be fixed automatically.`);
816
+ lines.push(" Run setup wizard to install missing components.");
817
+ }
818
+ }
819
+
820
+ return lines.join("\n");
821
+ }