@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -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
+ }
@@ -136,8 +136,79 @@ export class PackageNotFoundError extends Error {
136
136
  }
137
137
  }
138
138
 
139
+ // ── Lightweight metadata helpers for recommended-extensions enrichment ──
140
+
141
+ export interface PackageMeta {
142
+ description?: string;
143
+ version?: string;
144
+ }
145
+
146
+ const metaCache = new Map<string, CacheEntry<PackageMeta | null>>();
147
+
148
+ /**
149
+ * Fetch a minimal package.json-ish blob for an npm package (description,
150
+ * version) from the registry's `/<name>/latest` endpoint. Returns `null`
151
+ * on any network or parse failure.
152
+ */
153
+ export async function fetchPackageMeta(packageName: string): Promise<PackageMeta | null> {
154
+ const cached = metaCache.get(`npm:${packageName}`);
155
+ if (isFresh(cached)) return cached.data;
156
+ try {
157
+ const url = `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
158
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
159
+ if (!res.ok) {
160
+ metaCache.set(`npm:${packageName}`, { data: null, timestamp: Date.now() });
161
+ return null;
162
+ }
163
+ const json: any = await res.json();
164
+ const meta: PackageMeta = {
165
+ description: typeof json?.description === "string" ? json.description : undefined,
166
+ version: typeof json?.version === "string" ? json.version : undefined,
167
+ };
168
+ metaCache.set(`npm:${packageName}`, { data: meta, timestamp: Date.now() });
169
+ return meta;
170
+ } catch {
171
+ metaCache.set(`npm:${packageName}`, { data: null, timestamp: Date.now() });
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Fetch `package.json` from a public GitHub repository's default branch
178
+ * via `raw.githubusercontent.com`. Returns `{description, version}` or
179
+ * `null` on any failure.
180
+ */
181
+ export async function fetchGithubPackageJson(
182
+ owner: string,
183
+ repo: string,
184
+ ): Promise<PackageMeta | null> {
185
+ const key = `gh:${owner}/${repo}`;
186
+ const cached = metaCache.get(key);
187
+ if (isFresh(cached)) return cached.data;
188
+ // HEAD resolves to the default branch on raw.githubusercontent.com.
189
+ const url = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/package.json`;
190
+ try {
191
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
192
+ if (!res.ok) {
193
+ metaCache.set(key, { data: null, timestamp: Date.now() });
194
+ return null;
195
+ }
196
+ const json: any = await res.json();
197
+ const meta: PackageMeta = {
198
+ description: typeof json?.description === "string" ? json.description : undefined,
199
+ version: typeof json?.version === "string" ? json.version : undefined,
200
+ };
201
+ metaCache.set(key, { data: meta, timestamp: Date.now() });
202
+ return meta;
203
+ } catch {
204
+ metaCache.set(key, { data: null, timestamp: Date.now() });
205
+ return null;
206
+ }
207
+ }
208
+
139
209
  /** Clear all caches (for testing). */
140
210
  export function clearCaches() {
141
211
  searchCache.clear();
142
212
  readmeCache.clear();
213
+ metaCache.clear();
143
214
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Parser + writer for an OpenSpec change's `tasks.md` file.
3
+ *
4
+ * `tasks.md` uses a rigid line-level format:
5
+ * ## 1. Group heading
6
+ * - [ ] 1.1 Task text
7
+ * - [x] 1.2 Done task
8
+ *
9
+ * We parse top-level `- [ ]` / `- [x]` lines only; anything else is ignored
10
+ * (indented sublists, free-form prose, etc.).
11
+ *
12
+ * Writes rewrite exactly one line's checkbox marker and preserve everything
13
+ * else byte-for-byte; atomic via write-then-rename.
14
+ */
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+
18
+ export interface OpenSpecTask {
19
+ /** e.g. "1.1", "8.3" */
20
+ id: string;
21
+ /** Text after the id, trimmed. */
22
+ text: string;
23
+ done: boolean;
24
+ /** 1-indexed line number in `tasks.md` — used as an optimistic-concurrency token. */
25
+ line: number;
26
+ /** Nearest preceding `## ` heading text (without the leading "## "). Empty string if none. */
27
+ group: string;
28
+ }
29
+
30
+ export class NotFoundError extends Error {
31
+ readonly code = "NOT_FOUND" as const;
32
+ constructor(message = "tasks.md not found") {
33
+ super(message);
34
+ }
35
+ }
36
+ export class LineMismatchError extends Error {
37
+ readonly code = "LINE_MISMATCH" as const;
38
+ constructor(message = "line mismatch") {
39
+ super(message);
40
+ }
41
+ }
42
+ export class NotACheckboxError extends Error {
43
+ readonly code = "NOT_A_CHECKBOX" as const;
44
+ constructor(message = "target line is not a checkbox") {
45
+ super(message);
46
+ }
47
+ }
48
+
49
+ // Top-level checkbox: allow a single leading `- ` with optional `[ ]`/`[x]`/`[X]`,
50
+ // followed by an id-like token (digits and dots) and remaining text.
51
+ const CHECKBOX_RE = /^- \[([ xX])\] +([0-9]+(?:\.[0-9]+)*)\s+(.*)$/;
52
+ const HEADING_RE = /^##\s+(.*)$/;
53
+
54
+ export function parseTasksMarkdown(content: string): OpenSpecTask[] {
55
+ // Split on \n only; trailing \r is trimmed so we handle CRLF inputs too.
56
+ const lines = content.split("\n");
57
+ const out: OpenSpecTask[] = [];
58
+ let currentGroup = "";
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const raw = lines[i];
61
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
62
+ const h = HEADING_RE.exec(line);
63
+ if (h) {
64
+ currentGroup = h[1].trim();
65
+ continue;
66
+ }
67
+ const m = CHECKBOX_RE.exec(line);
68
+ if (!m) continue;
69
+ const done = m[1] === "x" || m[1] === "X";
70
+ out.push({
71
+ id: m[2],
72
+ text: m[3].trim(),
73
+ done,
74
+ line: i + 1,
75
+ group: currentGroup,
76
+ });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function tasksMdPath(cwd: string, change: string): string {
82
+ return path.join(cwd, "openspec", "changes", change, "tasks.md");
83
+ }
84
+
85
+ export async function readTasks(cwd: string, change: string): Promise<OpenSpecTask[]> {
86
+ const p = tasksMdPath(cwd, change);
87
+ let content: string;
88
+ try {
89
+ content = await fs.readFile(p, "utf-8");
90
+ } catch (err: any) {
91
+ if (err?.code === "ENOENT") throw new NotFoundError();
92
+ throw err;
93
+ }
94
+ return parseTasksMarkdown(content);
95
+ }
96
+
97
+ export async function toggleTask(
98
+ cwd: string,
99
+ change: string,
100
+ id: string,
101
+ done: boolean,
102
+ line: number,
103
+ ): Promise<OpenSpecTask> {
104
+ const p = tasksMdPath(cwd, change);
105
+ let content: string;
106
+ try {
107
+ content = await fs.readFile(p, "utf-8");
108
+ } catch (err: any) {
109
+ if (err?.code === "ENOENT") throw new NotFoundError();
110
+ throw err;
111
+ }
112
+
113
+ // Preserve original line endings by splitting on \n and tracking \r individually.
114
+ const lines = content.split("\n");
115
+ if (line < 1 || line > lines.length) throw new LineMismatchError();
116
+
117
+ const idx = line - 1;
118
+ const raw = lines[idx];
119
+ const hadCR = raw.endsWith("\r");
120
+ const bare = hadCR ? raw.slice(0, -1) : raw;
121
+
122
+ const m = CHECKBOX_RE.exec(bare);
123
+ if (!m) throw new NotACheckboxError();
124
+ if (m[2] !== id) throw new LineMismatchError();
125
+
126
+ const currentDone = m[1] === "x" || m[1] === "X";
127
+ // Optimistic concurrency: the caller's `done` is the *target* state; the line
128
+ // must currently hold the opposite state. If it already matches, we treat
129
+ // that as a line-mismatch — the file changed under us.
130
+ if (currentDone === done) throw new LineMismatchError();
131
+
132
+ const marker = done ? "x" : " ";
133
+ const rewritten = bare.replace(CHECKBOX_RE, `- [${marker}] ${m[2]} ${m[3]}`);
134
+ lines[idx] = hadCR ? rewritten + "\r" : rewritten;
135
+
136
+ const newContent = lines.join("\n");
137
+ const tmp = p + ".tmp";
138
+ await fs.writeFile(tmp, newContent, "utf-8");
139
+ await fs.rename(tmp, p);
140
+
141
+ return {
142
+ id,
143
+ text: m[3].trim(),
144
+ done,
145
+ line,
146
+ group: findGroupForLine(lines, idx),
147
+ };
148
+ }
149
+
150
+ function findGroupForLine(lines: string[], idx: number): string {
151
+ for (let i = idx; i >= 0; i--) {
152
+ const raw = lines[i];
153
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
154
+ const h = HEADING_RE.exec(line);
155
+ if (h) return h[1].trim();
156
+ }
157
+ return "";
158
+ }