@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Install signal + exit handlers that release the per-HOME dashboard lock.
3
+ *
4
+ * Separate from `home-lock.ts` so the pure lock-acquisition logic stays
5
+ * trivially testable. See change: single-dashboard-per-home.
6
+ */
7
+
8
+ export type ReleaseFn = () => Promise<void>;
9
+
10
+ export interface InstallReleaseHandlersOptions {
11
+ /** Inject a fake `process`-like object for tests. */
12
+ proc?: NodeJS.Process;
13
+ /** Inject a logger (defaults to `console`). */
14
+ log?: (msg: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Register SIGINT / SIGTERM / SIGHUP / SIGBREAK handlers and an `exit`
19
+ * fallback that call `release()` exactly once. The handler is idempotent;
20
+ * multiple signals will not double-release.
21
+ *
22
+ * Windows:
23
+ * - SIGINT + SIGBREAK are emitted by Node. SIGBREAK fires on Ctrl+Break.
24
+ * - SIGHUP does not exist on Windows; the registration is a no-op there.
25
+ * - `taskkill /F` bypasses all signals — the stale-detection path in
26
+ * `proper-lockfile` (staleDuration 10s) handles this case on next boot.
27
+ *
28
+ * Returns a function that removes the handlers (useful for tests).
29
+ */
30
+ export function installReleaseHandlers(
31
+ release: ReleaseFn,
32
+ options: InstallReleaseHandlersOptions = {},
33
+ ): () => void {
34
+ const proc = options.proc ?? process;
35
+ const log = options.log ?? ((m: string) => console.log(m));
36
+
37
+ let releasing = false;
38
+ const doRelease = async (signal: string) => {
39
+ if (releasing) return;
40
+ releasing = true;
41
+ try {
42
+ await release();
43
+ } catch (err) {
44
+ log(`[home-lock] release on ${signal} failed: ${(err as Error).message ?? err}`);
45
+ }
46
+ };
47
+
48
+ const sigintHandler = () => { void doRelease("SIGINT").then(() => proc.exit(0)); };
49
+ const sigtermHandler = () => { void doRelease("SIGTERM").then(() => proc.exit(0)); };
50
+ const sighupHandler = () => { void doRelease("SIGHUP").then(() => proc.exit(0)); };
51
+ const sigbreakHandler = () => { void doRelease("SIGBREAK").then(() => proc.exit(0)); };
52
+ // `exit` is synchronous — we can't await. Best effort: fire and move on;
53
+ // the async release will race the exit. `proper-lockfile` also removes its
54
+ // own lockfile on exit via its own exit hook as a safety net.
55
+ const exitHandler = () => { void release().catch(() => { /* ignore */ }); };
56
+
57
+ proc.on("SIGINT", sigintHandler);
58
+ proc.on("SIGTERM", sigtermHandler);
59
+ // SIGHUP + SIGBREAK may be undefined on Windows / some environments —
60
+ // registering still works (Node just never fires them there).
61
+ proc.on("SIGHUP", sighupHandler);
62
+ proc.on("SIGBREAK" as NodeJS.Signals, sigbreakHandler);
63
+ proc.on("exit", exitHandler);
64
+
65
+ return () => {
66
+ proc.off("SIGINT", sigintHandler);
67
+ proc.off("SIGTERM", sigtermHandler);
68
+ proc.off("SIGHUP", sighupHandler);
69
+ proc.off("SIGBREAK" as NodeJS.Signals, sigbreakHandler);
70
+ proc.off("exit", exitHandler);
71
+ };
72
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Per-HOME advisory lock for the dashboard server.
3
+ *
4
+ * Ensures one dashboard instance per HOME (`<realpath(os.homedir())>/.pi/`).
5
+ * See change: single-dashboard-per-home.
6
+ *
7
+ * Responsibilities:
8
+ * - Canonicalize HOME (avoid symlink/Git-Bash drift)
9
+ * - Acquire the lock via `proper-lockfile` (non-blocking, stale-aware)
10
+ * - Write / read an atomic metadata sidecar
11
+ * - Verify a held lock's liveness via identity-checked health probe
12
+ * - Return an `acquired` or `attach` result for the caller to dispatch
13
+ *
14
+ * Signal handlers and release-on-exit plumbing live in
15
+ * `home-lock-release.ts` to keep this module pure + testable.
16
+ */
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { randomUUID } from "node:crypto";
21
+ import properLockfile from "proper-lockfile";
22
+ import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
23
+ import { isProcessAlive } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
24
+
25
+ // ──────────────────────────────────────────────────────────
26
+ // Types
27
+ // ──────────────────────────────────────────────────────────
28
+
29
+ /** Metadata written alongside the lock file. JSON-serialized. */
30
+ export interface LockMetadata {
31
+ pid: number;
32
+ ppid: number;
33
+ httpPort: number;
34
+ piPort: number;
35
+ startedAt: number;
36
+ /** Stable per-instance identifier. Verified against /api/health to detect
37
+ * "port in use by unrelated dashboard or stale process with same pid." */
38
+ identity: string;
39
+ version: string;
40
+ url: string;
41
+ hostname: string;
42
+ }
43
+
44
+ /** Result of `acquireOrAttach`. Callers branch on `mode`. */
45
+ export type LockAcquireResult =
46
+ | {
47
+ mode: "acquired";
48
+ meta: LockMetadata;
49
+ /** Release the lock + remove the metadata sidecar. Idempotent. */
50
+ release: () => Promise<void>;
51
+ }
52
+ | {
53
+ mode: "attach";
54
+ meta: LockMetadata;
55
+ };
56
+
57
+ /** Thrown when port is held by an unrelated process. Non-fatal to this
58
+ * module; caller decides (exit with message / retry / override). */
59
+ export class InstanceLockMismatchError extends Error {
60
+ readonly code = "E_INSTANCE_MISMATCH";
61
+ constructor(readonly meta: LockMetadata, readonly observedIdentity: string | null) {
62
+ super(
63
+ `Port ${meta.httpPort} is in use by an unrelated process (PID ${meta.pid}). ` +
64
+ `Configure a different port or stop that process.`,
65
+ );
66
+ }
67
+ }
68
+
69
+ export interface AcquireConfig {
70
+ httpPort: number;
71
+ piPort: number;
72
+ version: string;
73
+ identity?: string;
74
+ /** Injection hooks for tests. Production callers pass no options. */
75
+ hooks?: AcquireHooks;
76
+ }
77
+
78
+ export interface AcquireHooks {
79
+ now?: () => number;
80
+ hostname?: () => string;
81
+ lockPath?: string;
82
+ metaPath?: string;
83
+ probeHealth?: (port: number) => Promise<{ running: boolean; pid?: number; identity?: string } | null>;
84
+ isProcessAlive?: (pid: number) => boolean;
85
+ /** Stale threshold forwarded to `proper-lockfile`. Default 10s. */
86
+ staleMs?: number;
87
+ }
88
+
89
+ // ──────────────────────────────────────────────────────────
90
+ // Paths
91
+ // ──────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Canonical HOME directory.
95
+ *
96
+ * Uses `os.userInfo().homedir` in preference to `os.homedir()` because on
97
+ * POSIX the latter honors the `$HOME` environment variable (Node docs say:
98
+ * "On POSIX, it uses the `$HOME` environment variable if defined"), which
99
+ * the design (§4) explicitly prohibits — a GUI-launched process and a
100
+ * shell-launched process would otherwise disagree on "where HOME is".
101
+ * `userInfo().homedir` consults `getpwuid(3)` on POSIX, immune to `$HOME`.
102
+ *
103
+ * On Windows, both APIs ultimately use `USERPROFILE`, so the Git Bash
104
+ * drift case (`$HOME=/c/Users/R` vs `USERPROFILE=C:\Users\R`) is handled
105
+ * either way; keeping `userInfo().homedir` first is still correct.
106
+ *
107
+ * Result is then passed through `fs.realpathSync` to collapse symlinks,
108
+ * FileVault migrations, and other canonicalization drift. Tolerant: falls
109
+ * back to the raw path if realpath fails.
110
+ */
111
+ export function canonicalHomedir(): string {
112
+ let raw: string;
113
+ try {
114
+ raw = os.userInfo().homedir;
115
+ } catch {
116
+ raw = os.homedir();
117
+ }
118
+ try {
119
+ return fs.realpathSync(raw);
120
+ } catch {
121
+ return raw;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Lock file path. This is what `proper-lockfile` locks.
127
+ */
128
+ export function getLockPath(homedir: string = canonicalHomedir()): string {
129
+ return path.join(homedir, ".pi", "dashboard", "server.lock");
130
+ }
131
+
132
+ /**
133
+ * Metadata sidecar path (`<lockPath>.meta.json`).
134
+ */
135
+ export function getMetaPath(lockPath: string = getLockPath()): string {
136
+ return `${lockPath}.meta.json`;
137
+ }
138
+
139
+ // ──────────────────────────────────────────────────────────
140
+ // Metadata I/O
141
+ // ──────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Atomically write the metadata sidecar via tmp + rename.
145
+ * Never leaves a partial file visible.
146
+ */
147
+ export function writeMetadataAtomic(meta: LockMetadata, metaPath: string = getMetaPath()): void {
148
+ const dir = path.dirname(metaPath);
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ const tmpPath = `${metaPath}.tmp-${process.pid}-${Date.now()}`;
151
+ fs.writeFileSync(tmpPath, JSON.stringify(meta, null, 2));
152
+ fs.renameSync(tmpPath, metaPath);
153
+ }
154
+
155
+ /**
156
+ * Read the metadata sidecar. Returns null on any failure (missing, corrupt,
157
+ * permission-denied). Callers MUST treat null as "assume stale."
158
+ */
159
+ export function readMetadata(metaPath: string = getMetaPath()): LockMetadata | null {
160
+ try {
161
+ const raw = fs.readFileSync(metaPath, "utf-8");
162
+ const parsed = JSON.parse(raw) as unknown;
163
+ if (!isLockMetadata(parsed)) return null;
164
+ return parsed;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function isLockMetadata(value: unknown): value is LockMetadata {
171
+ if (!value || typeof value !== "object") return false;
172
+ const m = value as Record<string, unknown>;
173
+ return (
174
+ typeof m.pid === "number" &&
175
+ typeof m.httpPort === "number" &&
176
+ typeof m.piPort === "number" &&
177
+ typeof m.startedAt === "number" &&
178
+ typeof m.identity === "string" &&
179
+ typeof m.version === "string" &&
180
+ typeof m.url === "string"
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Remove the metadata sidecar. Silent on any error (missing is fine).
186
+ */
187
+ export function removeMetadata(metaPath: string = getMetaPath()): void {
188
+ try {
189
+ fs.unlinkSync(metaPath);
190
+ } catch {
191
+ /* ignore */
192
+ }
193
+ }
194
+
195
+ // ──────────────────────────────────────────────────────────
196
+ // Liveness
197
+ // ──────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Determine if the recorded lock holder is a responsive, identity-matching
201
+ * dashboard. Returns:
202
+ * - `"alive-match"`: attach to it
203
+ * - `"alive-mismatch"`: someone else is on that port
204
+ * - `"dead"`: treat as stale, proceed to acquire
205
+ */
206
+ export async function isLockHolderResponsive(
207
+ meta: LockMetadata,
208
+ hooks: Pick<AcquireHooks, "probeHealth" | "isProcessAlive"> = {},
209
+ ): Promise<"alive-match" | "alive-mismatch" | "dead"> {
210
+ const aliveCheck = hooks.isProcessAlive ?? isProcessAlive;
211
+ if (!aliveCheck(meta.pid)) return "dead";
212
+
213
+ const probe = hooks.probeHealth ?? defaultProbeHealth;
214
+ const res = await probe(meta.httpPort);
215
+ if (!res || !res.running) return "dead";
216
+
217
+ // Identity check: `identity` field is preferred; fall back to PID match
218
+ // to stay compatible with older dashboards that predate identity.
219
+ if (res.identity) {
220
+ return res.identity === meta.identity ? "alive-match" : "alive-mismatch";
221
+ }
222
+ if (typeof res.pid === "number") {
223
+ return res.pid === meta.pid ? "alive-match" : "alive-mismatch";
224
+ }
225
+ // Running but no verifiable identity — conservative: mismatch.
226
+ return "alive-mismatch";
227
+ }
228
+
229
+ async function defaultProbeHealth(port: number) {
230
+ const status = await isDashboardRunning(port);
231
+ if (!status.running) return { running: false };
232
+ // `isDashboardRunning` doesn't expose identity today. Re-fetch to peek at
233
+ // the full health body for the `identity` field. Best-effort.
234
+ try {
235
+ const res = await fetch(`http://localhost:${port}/api/health`, {
236
+ signal: AbortSignal.timeout(1500),
237
+ });
238
+ if (res.ok) {
239
+ const body = (await res.json()) as { pid?: number; identity?: string };
240
+ return { running: true, pid: body.pid, identity: body.identity };
241
+ }
242
+ } catch {
243
+ /* fall through */
244
+ }
245
+ return { running: true, pid: status.pid };
246
+ }
247
+
248
+ // ──────────────────────────────────────────────────────────
249
+ // Acquire
250
+ // ──────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Acquire the per-HOME lock, or fall back to attach semantics if a live
254
+ * dashboard already holds it.
255
+ *
256
+ * Flow:
257
+ * 1. Ensure `~/.pi/dashboard/` exists (proper-lockfile requires parent).
258
+ * 2. `proper-lockfile.lock(path, { stale, retries: 0 })`
259
+ * ↪ on success: write metadata, return { mode: "acquired", release }
260
+ * ↪ on ELOCKED: read metadata, check liveness
261
+ * - dead: steal via `proper-lockfile.lock({ realpath:false, stale: 0 })`
262
+ * (Note: proper-lockfile already does stale-stealing when
263
+ * `stale` is configured — we just retry once.)
264
+ * - alive-match: return { mode: "attach", meta }
265
+ * - alive-mismatch: throw InstanceLockMismatchError
266
+ */
267
+ export async function acquireOrAttach(config: AcquireConfig): Promise<LockAcquireResult> {
268
+ const hooks = config.hooks ?? {};
269
+ const lockPath = hooks.lockPath ?? getLockPath();
270
+ const metaPath = hooks.metaPath ?? getMetaPath(lockPath);
271
+ const staleMs = hooks.staleMs ?? 10_000;
272
+ const now = hooks.now ?? Date.now;
273
+ const hostname = hooks.hostname ?? os.hostname;
274
+
275
+ // Ensure the lock file's parent directory exists. proper-lockfile wants
276
+ // either the target file (which it creates alongside as `<path>.lock/`)
277
+ // or an existing file — we create an empty sentinel so the API is
278
+ // deterministic.
279
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
280
+ if (!fs.existsSync(lockPath)) {
281
+ fs.writeFileSync(lockPath, "# pi-dashboard per-HOME advisory lock\n");
282
+ }
283
+
284
+ const buildMeta = (): LockMetadata => ({
285
+ pid: process.pid,
286
+ ppid: process.ppid,
287
+ httpPort: config.httpPort,
288
+ piPort: config.piPort,
289
+ startedAt: now(),
290
+ identity: config.identity ?? randomUUID(),
291
+ version: config.version,
292
+ url: `http://localhost:${config.httpPort}`,
293
+ hostname: hostname(),
294
+ });
295
+
296
+ const tryAcquire = async () => {
297
+ const release = await properLockfile.lock(lockPath, {
298
+ stale: staleMs,
299
+ retries: 0,
300
+ // proper-lockfile uses realpath by default; we already pass a
301
+ // realpath-based directory, so this is a no-op but kept explicit.
302
+ realpath: false,
303
+ });
304
+ const meta = buildMeta();
305
+ writeMetadataAtomic(meta, metaPath);
306
+ const releaseOnce = (() => {
307
+ let released = false;
308
+ return async () => {
309
+ if (released) return;
310
+ released = true;
311
+ try {
312
+ await release();
313
+ } catch {
314
+ /* ignore — lock may have been compromised */
315
+ }
316
+ removeMetadata(metaPath);
317
+ };
318
+ })();
319
+ return { mode: "acquired" as const, meta, release: releaseOnce };
320
+ };
321
+
322
+ try {
323
+ return await tryAcquire();
324
+ } catch (err: unknown) {
325
+ if (!isELocked(err)) throw err;
326
+ // Someone else holds the lock. Decide: attach or error.
327
+ //
328
+ // Concurrent-launch race: if two callers race, the winner writes the
329
+ // metadata sidecar a few ms after acquiring. The loser hits ELOCKED
330
+ // faster and can read the sidecar BEFORE the winner has written it.
331
+ // Short-poll for metadata to land before concluding "no metadata = stale."
332
+ let meta: LockMetadata | null = null;
333
+ for (let i = 0; i < 20; i++) {
334
+ meta = readMetadata(metaPath);
335
+ if (meta) break;
336
+ await new Promise(r => setTimeout(r, 25));
337
+ }
338
+ if (!meta) {
339
+ // Truly no metadata after 500ms → assume stale/corrupt. Force steal.
340
+ removeMetadata(metaPath);
341
+ try {
342
+ return await tryAcquire();
343
+ } catch (err2) {
344
+ if (!isELocked(err2)) throw err2;
345
+ try {
346
+ await properLockfile.unlock(lockPath, { realpath: false });
347
+ } catch {
348
+ /* ignore */
349
+ }
350
+ return await tryAcquire();
351
+ }
352
+ }
353
+
354
+ const liveness = await isLockHolderResponsive(meta, hooks);
355
+ if (liveness === "alive-match") {
356
+ return { mode: "attach", meta };
357
+ }
358
+ if (liveness === "alive-mismatch") {
359
+ throw new InstanceLockMismatchError(meta, null);
360
+ }
361
+ // Dead holder — steal.
362
+ try {
363
+ await properLockfile.unlock(lockPath, { realpath: false });
364
+ } catch {
365
+ /* ignore */
366
+ }
367
+ removeMetadata(metaPath);
368
+ return await tryAcquire();
369
+ }
370
+ }
371
+
372
+ function isELocked(err: unknown): boolean {
373
+ if (!err || typeof err !== "object") return false;
374
+ const code = (err as { code?: string }).code;
375
+ return code === "ELOCKED";
376
+ }
377
+
378
+ // ──────────────────────────────────────────────────────────
379
+ // Escape hatch
380
+ // ──────────────────────────────────────────────────────────
381
+
382
+ /**
383
+ * True when the user has opted out of the per-HOME lock. Caller should
384
+ * log a warning and skip acquireOrAttach when set.
385
+ */
386
+ export function isLockDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
387
+ const raw = env.PI_DASHBOARD_ALLOW_MULTIPLE;
388
+ return raw === "1" || raw === "true";
389
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure predicate + message builder for nodejs/node#58515 affected versions.
3
+ *
4
+ * The bug (`ERR_INTERNAL_ASSERTION: Unexpected module status 3`) fires when
5
+ * Fastify loads its internal ajv-compiler under affected Node versions.
6
+ *
7
+ * Affected: Node v22.0–v22.17 and v24.1–v24.2.
8
+ * Fixed in: v22.18+, v24.3+, v25.x.
9
+ *
10
+ * Rationale for a preflight refuse-to-start (instead of a preload workaround):
11
+ * see openspec/changes/adapt-windows-integration-pr9/proposal.md and
12
+ * BRANCH-COMPARISON.md §10 on origin/windows-integration.
13
+ */
14
+
15
+ export function isAffectedNode(version: string): boolean {
16
+ const m = version.match(/^v?(\d+)\.(\d+)\.(\d+)/);
17
+ if (!m) return false;
18
+ const major = Number(m[1]);
19
+ const minor = Number(m[2]);
20
+ if (major === 22 && minor < 18) return true;
21
+ if (major === 24 && minor >= 1 && minor < 3) return true;
22
+ return false;
23
+ }
24
+
25
+ export function buildNodeUpgradeMessage(version: string): string {
26
+ return [
27
+ ``,
28
+ `❌ pi-dashboard cannot start on Node ${version}.`,
29
+ ``,
30
+ ` This Node version has a bug that crashes Fastify at startup:`,
31
+ ` https://github.com/nodejs/node/issues/58515`,
32
+ ``,
33
+ ` Fix: upgrade Node to >=22.18.0 (LTS) or >=24.3.0.`,
34
+ ` Install:`,
35
+ ` nvm: nvm install 22 && nvm use 22`,
36
+ ` brew: brew upgrade node`,
37
+ ` Win: https://nodejs.org/ -> current 22.x LTS installer`,
38
+ ``,
39
+ ].join("\n");
40
+ }
41
+
42
+ /**
43
+ * Call at the top of every server entry point (cmdStart, runForeground).
44
+ * Writes the upgrade message to stderr and exits with code 1 when the
45
+ * running Node is in the affected range.
46
+ */
47
+ export function assertNodeVersionSupported(): void {
48
+ if (isAffectedNode(process.version)) {
49
+ console.error(buildNodeUpgradeMessage(process.version));
50
+ process.exit(1);
51
+ }
52
+ }