@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,199 @@
1
+ /**
2
+ * Persistent registry of spawned `code-server` editor instances.
3
+ *
4
+ * Persists PIDs to ~/.pi/dashboard/editor-pids.json so that, after a non-graceful
5
+ * dashboard shutdown (SIGKILL, crash, OOM, force-quit), the next server boot can
6
+ * sweep and SIGTERM/SIGKILL orphan code-server processes that were reparented to
7
+ * init/launchd.
8
+ *
9
+ * Mirrors the persistence + boot-sweep pattern of `headless-pid-registry.ts` but
10
+ * KILLS live orphans (not reclaim) — editor instances are dashboard-internal,
11
+ * unreachable after restart, and the user expects a clean state.
12
+ */
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { execSync } from "node:child_process"; // ban:child_process-ok editor orphan sweep uses `ps`/`taskkill` probe for bounded wait; tracked tech debt for migration to platform/process Recipe
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { readJsonFile, writeJsonFile } from "./json-store.js";
18
+ import { isUnsafeTestHomeScan } from "./test-env-guard.js";
19
+ import {
20
+ isProcessAlive as platformIsProcessAlive,
21
+ killPidWithGroup,
22
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
23
+
24
+ const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "editor-pids.json");
25
+
26
+ /** Grace period between SIGTERM and SIGKILL escalation. */
27
+ const SIGKILL_GRACE_MS = 1000;
28
+
29
+ /** Marker that uniquely identifies a dashboard-spawned code-server cmdline. */
30
+ const DASHBOARD_DATA_DIR_MARKER = path.join(os.homedir(), ".pi", "dashboard", "editors") + path.sep;
31
+
32
+ export interface PersistedEditorEntry {
33
+ id: string;
34
+ pid: number;
35
+ port: number;
36
+ cwd: string;
37
+ dataDir: string;
38
+ /** ISO 8601 timestamp */
39
+ spawnedAt: string;
40
+ }
41
+
42
+ interface EditorPidFileData {
43
+ entries: PersistedEditorEntry[];
44
+ }
45
+
46
+ export interface EditorPidRegistry {
47
+ /** Record a newly-ready editor instance. */
48
+ register(entry: Omit<PersistedEditorEntry, "spawnedAt"> & { spawnedAt?: number | string }): void;
49
+ /** Remove an entry by editor id. */
50
+ remove(id: string): void;
51
+ /** Number of in-memory tracked entries (testing aid). */
52
+ size(): number;
53
+ /** Sweep persisted entries on server boot, killing verified orphans. */
54
+ cleanupOrphans(): Promise<void>;
55
+ }
56
+
57
+ export interface EditorPidRegistryOptions {
58
+ pidFilePath?: string;
59
+ /** Override cmdline lookup (testing). */
60
+ getCmdline?: (pid: number) => string | null;
61
+ /** Override process-alive check (testing). */
62
+ isProcessAlive?: (pid: number) => boolean;
63
+ /** Override kill (testing). Returns true if signal was delivered. */
64
+ kill?: (pid: number, signal: NodeJS.Signals) => boolean;
65
+ /** Override grace ms between SIGTERM and SIGKILL (testing). */
66
+ graceMs?: number;
67
+ }
68
+
69
+ /** Default cross-platform process command-line lookup. */
70
+ function defaultGetCmdline(pid: number): string | null {
71
+ try {
72
+ if (process.platform === "linux") {
73
+ const file = `/proc/${pid}/cmdline`;
74
+ if (!existsSync(file)) return null;
75
+ // /proc cmdline is NUL-separated
76
+ return readFileSync(file, "utf-8").replace(/\0/g, " ").trim();
77
+ }
78
+ if (process.platform === "darwin") {
79
+ const out = execSync(`ps -p ${pid} -o command=`, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
80
+ return out.trim() || null;
81
+ }
82
+ if (process.platform === "win32") {
83
+ const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /value`, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
84
+ const m = out.match(/CommandLine=(.*)/);
85
+ return m ? m[1].trim() : null;
86
+ }
87
+ } catch {
88
+ return null;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /** Route through platform/process.ts so lint enforcement and cross-platform
94
+ * semantics (libuv signal 0 check, POSIX group kill) stay in one place. */
95
+ function defaultIsProcessAlive(pid: number): boolean {
96
+ return platformIsProcessAlive(pid);
97
+ }
98
+
99
+ function defaultKill(pid: number, signal: NodeJS.Signals): boolean {
100
+ try {
101
+ killPidWithGroup(pid, signal);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /** Verify that `cmdline` looks like a dashboard-spawned code-server. */
109
+ export function isDashboardOwnedCodeServer(cmdline: string | null): boolean {
110
+ if (!cmdline) return false;
111
+ // Must reference --user-data-dir under ~/.pi/dashboard/editors/
112
+ return cmdline.includes("--user-data-dir") && cmdline.includes(DASHBOARD_DATA_DIR_MARKER);
113
+ }
114
+
115
+ export function createEditorPidRegistry(options: EditorPidRegistryOptions = {}): EditorPidRegistry {
116
+ const pidFilePath = options.pidFilePath ?? DEFAULT_PID_FILE;
117
+ const getCmdline = options.getCmdline ?? defaultGetCmdline;
118
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
119
+ const kill = options.kill ?? defaultKill;
120
+ const graceMs = options.graceMs ?? SIGKILL_GRACE_MS;
121
+
122
+ // In-memory mirror of the file (id → entry).
123
+ const entries = new Map<string, PersistedEditorEntry>();
124
+
125
+ function persist(): void {
126
+ try {
127
+ const data: EditorPidFileData = { entries: [...entries.values()] };
128
+ writeJsonFile(pidFilePath, data);
129
+ } catch {
130
+ // Best-effort: persistence failures must not break editor lifecycle.
131
+ }
132
+ }
133
+
134
+ return {
135
+ register(entry) {
136
+ const spawnedAt =
137
+ typeof entry.spawnedAt === "string"
138
+ ? entry.spawnedAt
139
+ : new Date(entry.spawnedAt ?? Date.now()).toISOString();
140
+ entries.set(entry.id, {
141
+ id: entry.id,
142
+ pid: entry.pid,
143
+ port: entry.port,
144
+ cwd: entry.cwd,
145
+ dataDir: entry.dataDir,
146
+ spawnedAt,
147
+ });
148
+ persist();
149
+ },
150
+
151
+ remove(id) {
152
+ if (entries.delete(id)) persist();
153
+ },
154
+
155
+ size() {
156
+ return entries.size;
157
+ },
158
+
159
+ async cleanupOrphans() {
160
+ if (isUnsafeTestHomeScan()) {
161
+ console.warn("[editor-pid-registry] cleanupOrphans() blocked: running under vitest with real HOME");
162
+ return;
163
+ }
164
+ const data = readJsonFile<EditorPidFileData>(pidFilePath, { entries: [] });
165
+ const persisted = Array.isArray(data?.entries) ? data.entries : [];
166
+
167
+ let killed = 0;
168
+ const toKill: PersistedEditorEntry[] = [];
169
+
170
+ for (const entry of persisted) {
171
+ if (!isAlive(entry.pid)) continue;
172
+ const cmdline = getCmdline(entry.pid);
173
+ if (!isDashboardOwnedCodeServer(cmdline)) continue;
174
+ toKill.push(entry);
175
+ }
176
+
177
+ for (const entry of toKill) {
178
+ kill(entry.pid, "SIGTERM");
179
+ }
180
+
181
+ if (toKill.length > 0) {
182
+ await new Promise((r) => setTimeout(r, graceMs));
183
+ for (const entry of toKill) {
184
+ if (isAlive(entry.pid)) {
185
+ kill(entry.pid, "SIGKILL");
186
+ }
187
+ killed++;
188
+ }
189
+ }
190
+
191
+ // Reset to whatever the new server has registered so far (initially nothing).
192
+ persist();
193
+
194
+ if (killed > 0) {
195
+ console.log(`[editor-pid-registry] cleaned ${killed} orphan${killed === 1 ? "" : "s"}`);
196
+ }
197
+ },
198
+ };
199
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Static editor registry and detection logic.
3
3
  * Detects available editors by checking for running processes + CLI on PATH.
4
+ * Uses shared platform primitives so the win32 / unix split is owned in one
5
+ * place. See change: consolidate-platform-handlers.
4
6
  */
5
- import { execSync } from "node:child_process";
7
+ import { isProcessRunning as platformIsProcessRunning } from "@blackbelt-technology/pi-dashboard-shared/platform/process-scan.js";
8
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
6
9
 
7
10
  export interface EditorEntry {
8
11
  id: string;
@@ -50,32 +53,28 @@ export const EDITORS: EditorEntry[] = [
50
53
  },
51
54
  ];
52
55
 
56
+ // Cached resolver for binary-availability checks (reads PATH via `where`/`which`).
57
+ const resolver = new ToolResolver({ processExecPath: process.execPath });
58
+
59
+ /**
60
+ * Platform-unified process-running check. Re-exported for callers (and tests)
61
+ * that previously imported it from this module.
62
+ */
53
63
  export function isProcessRunning(pattern: string): boolean {
54
- try {
55
- execSync(`pgrep -f "${pattern}"`, { stdio: "pipe" });
56
- return true;
57
- } catch {
58
- return false;
59
- }
64
+ return platformIsProcessRunning(pattern);
60
65
  }
61
66
 
62
- function isCliAvailable(cli: string): boolean {
63
- const cmd = process.platform === "win32" ? `where ${cli}` : `which ${cli}`;
64
- try {
65
- execSync(cmd, { stdio: "pipe" });
66
- return true;
67
- } catch {
68
- return false;
69
- }
67
+ /**
68
+ * @deprecated Use `isProcessRunning(pattern)` the shared primitive now
69
+ * handles the Windows (tasklist) vs Unix (pgrep) split internally. Kept as
70
+ * a thin alias for tests that still call it directly.
71
+ */
72
+ export function isProcessRunningWin32(pattern: string): boolean {
73
+ return platformIsProcessRunning(pattern, { platform: "win32" });
70
74
  }
71
75
 
72
- export function isProcessRunningWin32(pattern: string): boolean {
73
- try {
74
- const result = execSync(`tasklist /FI "IMAGENAME eq ${pattern}" /NH`, { encoding: "utf-8", stdio: "pipe" });
75
- return result.includes(pattern);
76
- } catch {
77
- return false;
78
- }
76
+ function isCliAvailable(cli: string): boolean {
77
+ return resolver.which(cli) !== null;
79
78
  }
80
79
 
81
80
  export function detectEditors(_cwd: string): DetectedEditor[] {
@@ -96,9 +95,7 @@ export function detectEditors(_cwd: string): DetectedEditor[] {
96
95
  cli = editor.cli;
97
96
  }
98
97
 
99
- const running = platform === "win32"
100
- ? isProcessRunningWin32(pattern)
101
- : isProcessRunning(pattern);
98
+ const running = isProcessRunning(pattern);
102
99
 
103
100
  if (running && isCliAvailable(cli)) {
104
101
  results.push({ id: editor.id, name: editor.name });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Runtime fix for node-pty spawn-helper permissions.
3
+ *
4
+ * On macOS/Linux, the prebuilt spawn-helper binary may lack the execute bit
5
+ * (especially in Electron bundles where npm hoisting skips the postinstall fix).
6
+ * This module finds and fixes all spawn-helper binaries at runtime.
7
+ *
8
+ * Called once when the terminal manager is created.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { createRequire } from "node:module";
13
+
14
+ let fixed = false;
15
+
16
+ export function fixPtyPermissions(): void {
17
+ if (fixed || process.platform === "win32") return;
18
+ fixed = true;
19
+
20
+ try {
21
+ // Resolve node-pty's actual location (works with hoisting)
22
+ const require_ = createRequire(import.meta.url);
23
+ const ptyMain = require_.resolve("node-pty");
24
+ const ptyDir = path.dirname(ptyMain);
25
+ const prebuildsDir = path.join(ptyDir, "..", "prebuilds");
26
+
27
+ if (!fs.existsSync(prebuildsDir)) return;
28
+
29
+ for (const dir of fs.readdirSync(prebuildsDir)) {
30
+ const helper = path.join(prebuildsDir, dir, "spawn-helper");
31
+ try {
32
+ const stat = fs.statSync(helper);
33
+ if (!(stat.mode & 0o111)) {
34
+ fs.chmodSync(helper, 0o755);
35
+ console.log(`[pty] Fixed spawn-helper permissions: ${helper}`);
36
+ }
37
+ } catch {
38
+ // spawn-helper doesn't exist for this platform, skip
39
+ }
40
+ }
41
+ } catch {
42
+ // node-pty not installed or not resolvable, skip silently
43
+ }
44
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Server-side git operations — branch listing, checkout, init, stash.
3
3
  */
4
- import { execSync } from "node:child_process";
4
+ import { execSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
5
5
 
6
6
  const GIT_TIMEOUT = 15_000;
7
7
 
@@ -3,11 +3,13 @@
3
3
  * Tracks PID + cwd at spawn time, links to sessionId when the bridge connects.
4
4
  * Persists entries to disk so a restarted server can clean up orphans.
5
5
  */
6
- import type { ChildProcess } from "node:child_process";
6
+ import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
7
7
  import { EventEmitter } from "node:events";
8
8
  import { readJsonFile, writeJsonFile } from "./json-store.js";
9
+ import { killPidWithGroup, isProcessAlive } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
9
10
  import path from "node:path";
10
11
  import os from "node:os";
12
+ import { isUnsafeTestHomeScan } from "./test-env-guard.js";
11
13
 
12
14
  /** Default PID file path */
13
15
  const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "headless-pids.json");
@@ -81,15 +83,6 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
81
83
  return data.entries ?? [];
82
84
  }
83
85
 
84
- function isProcessAlive(pid: number): boolean {
85
- try {
86
- process.kill(pid, 0);
87
- return true;
88
- } catch {
89
- return false;
90
- }
91
- }
92
-
93
86
  return {
94
87
  register(pid: number, cwd: string, proc: ChildProcess) {
95
88
  entries.set(pid, { pid, cwd, process: proc, spawnedAt: Date.now() });
@@ -123,12 +116,9 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
123
116
  for (const entry of entries.values()) {
124
117
  if (entry.sessionId === sessionId) {
125
118
  try {
126
- // On Unix, kill the entire process group (negative PID) so the
127
- // wrapper shell, sleep, and pi processes are all terminated.
128
- // On Windows, process groups aren't supported — kill directly.
129
- const signal = "SIGTERM";
130
- const pid = process.platform === "win32" ? entry.pid : -entry.pid;
131
- process.kill(pid, signal);
119
+ // Delegate platform-specific pid-vs-group-pid handling to the
120
+ // shared primitive. See change: consolidate-platform-handlers.
121
+ killPidWithGroup(entry.pid, "SIGTERM");
132
122
  entries.delete(entry.pid);
133
123
  persist();
134
124
  return true;
@@ -148,10 +138,13 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
148
138
  },
149
139
 
150
140
  killAll() {
151
- const useGroup = process.platform !== "win32";
141
+ if (isUnsafeTestHomeScan()) {
142
+ console.warn("[headless-pid-registry] killAll() blocked: running under vitest with real HOME");
143
+ return;
144
+ }
152
145
  for (const [pid] of entries) {
153
146
  try {
154
- process.kill(useGroup ? -pid : pid, "SIGTERM");
147
+ killPidWithGroup(pid, "SIGTERM");
155
148
  } catch {
156
149
  // Process may have already exited
157
150
  }
@@ -166,6 +159,10 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
166
159
  },
167
160
 
168
161
  cleanupOrphans() {
162
+ if (isUnsafeTestHomeScan()) {
163
+ console.warn("[headless-pid-registry] cleanupOrphans() blocked: running under vitest with real HOME");
164
+ return;
165
+ }
169
166
  const persisted = loadFromDisk();
170
167
  const now = Date.now();
171
168
 
@@ -181,8 +178,7 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
181
178
  if (age > MAX_ORPHAN_AGE_MS) {
182
179
  // Very old orphan — kill (process group on Unix, direct on Windows)
183
180
  try {
184
- const pid = process.platform === "win32" ? entry.pid : -entry.pid;
185
- process.kill(pid, "SIGTERM");
181
+ killPidWithGroup(entry.pid, "SIGTERM");
186
182
  } catch {
187
183
  // Already dead
188
184
  }
@@ -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
+ }