@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
@@ -73,8 +73,22 @@ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps):
73
73
  } as any);
74
74
  });
75
75
 
76
- // Initial OpenSpec poll for all known directories
77
- await Promise.all(
78
- directoryService.knownDirectories().map((cwd) => directoryService.refreshOpenSpec(cwd)),
76
+ // Initial OpenSpec poll for all known directories.
77
+ //
78
+ // NOTE: `refreshOpenSpec` / `pollOpenSpec` is currently synchronous internally
79
+ // (spawnSync per change) — on Windows with many active changes (~19) and
80
+ // multiple pinned directories this can block the event loop for minutes,
81
+ // making the HTTP server unresponsive during startup. We intentionally do
82
+ // NOT await it here so HTTP + WebSocket startup completes immediately;
83
+ // openspec data populates in the background and pushes `openspec_update`
84
+ // broadcasts to browsers as each directory finishes.
85
+ //
86
+ // A proper fix is to migrate the openspec Recipe to async spawn; tracked
87
+ // separately. See change: consolidate-tool-resolution.
88
+ void Promise.all(
89
+ directoryService.knownDirectories().map(async (cwd) => {
90
+ try { await directoryService.refreshOpenSpec(cwd); }
91
+ catch (err) { console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err); }
92
+ }),
79
93
  );
80
94
  }
@@ -2,8 +2,9 @@
2
2
  * Session diff extraction — scans session events for file changes
3
3
  * and optionally enriches with git diffs.
4
4
  */
5
- import { execSync } from "node:child_process";
6
- import { resolve, relative, isAbsolute } from "node:path";
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { resolve, relative, isAbsolute, sep as pathSep } from "node:path";
7
+ import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
7
8
  import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
8
9
  import type { FileChangeEvent, FileDiffEntry, EditOperation } from "@blackbelt-technology/pi-dashboard-shared/diff-types.js";
9
10
  import { isGitRepo } from "./git-operations.js";
@@ -105,7 +106,11 @@ function normalizePath(rawPath: string, cwd: string): string | null {
105
106
  return null;
106
107
  }
107
108
 
108
- return rel;
109
+ // Normalize to posix separators. These paths are embedded into git diff
110
+ // headers (`diff --git a/<path> b/<path>`) which expect forward slashes,
111
+ // and are also used by the client for display and URL construction.
112
+ // See change: fix-windows-server-parity.
113
+ return pathSep === "/" ? rel : rel.split(pathSep).join("/");
109
114
  }
110
115
 
111
116
  /**
@@ -130,32 +135,27 @@ export function enrichWithGitDiff(
130
135
 
131
136
  const enriched = files.map((file) => {
132
137
  try {
133
- const diff = execSync(`git diff HEAD -- ${JSON.stringify(file.path)}`, {
134
- cwd,
135
- encoding: "utf-8",
136
- stdio: ["pipe", "pipe", "pipe"],
137
- timeout: GIT_TIMEOUT,
138
- }).trim();
138
+ // Delegate to the shared git tool module. The runner handles
139
+ // windowsHide, timeout, argv-array escaping (no shell), and the
140
+ // "no diff" exit-1 tolerance. See change: platform-command-executor.
141
+ const diff = git.diffOr({ cwd, path: file.path }).trim();
139
142
 
140
143
  if (diff) {
141
144
  return { ...file, gitDiff: diff };
142
145
  }
143
146
 
144
147
  // No diff from HEAD — try untracked (new file)
145
- const status = execSync(`git status --porcelain -- ${JSON.stringify(file.path)}`, {
146
- cwd,
147
- encoding: "utf-8",
148
- stdio: ["pipe", "pipe", "pipe"],
149
- timeout: GIT_TIMEOUT,
150
- }).trim();
148
+ const status = git.statusPorcelainOr({ cwd, path: file.path }).trim();
151
149
 
152
150
  if (status.startsWith("??") || status.startsWith("A")) {
153
- // Untracked or newly added — generate synthetic diff
154
- const content = execSync(`cat ${JSON.stringify(resolve(cwd, file.path))}`, {
155
- encoding: "utf-8",
156
- stdio: ["pipe", "pipe", "pipe"],
157
- timeout: GIT_TIMEOUT,
158
- });
151
+ // Untracked or newly added — generate synthetic diff.
152
+ // Read via fs.readFileSync rather than `cat` for cross-platform
153
+ // support (Windows has no `cat`). See change: fix-windows-server-parity.
154
+ const absPath = resolve(cwd, file.path);
155
+ if (!existsSync(absPath)) {
156
+ return file;
157
+ }
158
+ const content = readFileSync(absPath, "utf-8");
159
159
  const lines = content.split("\n");
160
160
  const diffLines = [
161
161
  `diff --git a/${file.path} b/${file.path}`,
@@ -4,18 +4,25 @@
4
4
  import * as pty from "node-pty";
5
5
  import type { IPty } from "node-pty";
6
6
  import { randomBytes } from "node:crypto";
7
+ import { fixPtyPermissions } from "./fix-pty-permissions.js";
7
8
  import type { TerminalSession, TerminalControlMessage } from "@blackbelt-technology/pi-dashboard-shared/terminal-types.js";
8
9
  import type { WebSocket } from "ws";
9
10
 
10
11
  const DEFAULT_BUFFER_SIZE = 256 * 1024; // 256KB
11
12
 
13
+ // Delegate shell detection to the shared platform primitive. Back-compat
14
+ // wrapper preserved so callers (and tests) that import `detectShell` from
15
+ // this module continue to work. See change: consolidate-platform-handlers.
16
+ import {
17
+ detectShell as platformDetectShell,
18
+ getTerminalEnvHints as platformTerminalEnvHints,
19
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/shell.js";
20
+ import { killProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
21
+
12
22
  /** Detect the appropriate shell for the current platform. */
13
23
  export function detectShell(platform?: string): string {
14
- const p = platform ?? process.platform;
15
- if (p === "win32") {
16
- return process.env.COMSPEC || "powershell.exe";
17
- }
18
- return process.env.SHELL || "/bin/bash";
24
+ // Keep the old `platform?: string` signature; coerce to the shared primitive's opts.
25
+ return platformDetectShell(platform ? { platform: platform as NodeJS.Platform } : undefined);
19
26
  }
20
27
 
21
28
  /** Circular buffer for PTY output replay. */
@@ -99,6 +106,9 @@ function generateId(): string {
99
106
  }
100
107
 
101
108
  export function createTerminalManager(options?: TerminalManagerOptions): TerminalManager {
109
+ // Fix node-pty spawn-helper permissions at runtime (defense in depth)
110
+ fixPtyPermissions();
111
+
102
112
  const entries = new Map<string, TerminalEntry>();
103
113
  const bufferSize = options?.bufferSize ?? DEFAULT_BUFFER_SIZE;
104
114
 
@@ -106,10 +116,7 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
106
116
  const shell = detectShell();
107
117
  const id = generateId();
108
118
 
109
- const env = { ...process.env } as Record<string, string>;
110
- if (process.platform === "win32" && !env.TERM) {
111
- env.TERM = "cygwin";
112
- }
119
+ const env = { ...process.env, ...platformTerminalEnvHints() } as Record<string, string>;
113
120
 
114
121
  const p = pty.spawn(shell, [], {
115
122
  cwd,
@@ -207,18 +214,56 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
207
214
  function kill(id: string): void {
208
215
  const entry = entries.get(id);
209
216
  if (!entry) throw new Error(`Terminal ${id} not found`);
210
- // Interactive shells (e.g. bash on Linux) ignore SIGTERM.
211
- // Use SIGHUP which shells honor, then escalate to SIGKILL.
212
- entry.pty.kill("SIGHUP");
213
- const escalation = setTimeout(() => {
214
- if (entries.has(id)) {
215
- try { entry.pty.kill("SIGKILL"); } catch {}
217
+
218
+ // Windows: node-pty's kill(signal) uses TerminateProcess on the shell
219
+ // handle, which (a) ignores the signal string, and (b) does not kill
220
+ // child processes of the shell (python.exe, node.exe, etc.). Worse, its
221
+ // onExit callback is not always fired after external kills, so the
222
+ // terminal entry would stay in the map forever — which is exactly why
223
+ // the X button "doesn't work" on Windows. Route through platform's
224
+ // killProcess() so taskkill /F /T /PID does a genuine tree kill.
225
+ //
226
+ // POSIX: keep the SIGHUP → SIGKILL idiom — interactive shells honor
227
+ // SIGHUP, giving them a chance to clean up tty state before we escalate.
228
+ if (process.platform === "win32") {
229
+ const pid = entry.pty.pid;
230
+ if (typeof pid === "number") {
231
+ void killProcess(pid, { timeoutMs: 2000 }).catch(() => { /* surfaced via onExit fallback below */ });
232
+ } else {
233
+ try { entry.pty.kill(); } catch { /* best-effort */ }
216
234
  }
217
- }, 1000);
218
- // Clear escalation timeout if the process exits promptly
219
- const dispose = entry.pty.onExit(() => {
220
- clearTimeout(escalation);
221
- dispose.dispose();
235
+ } else {
236
+ entry.pty.kill("SIGHUP");
237
+ const escalation = setTimeout(() => {
238
+ if (entries.has(id)) {
239
+ try { entry.pty.kill("SIGKILL"); } catch {}
240
+ }
241
+ }, 1000);
242
+ const disposeEsc = entry.pty.onExit(() => {
243
+ clearTimeout(escalation);
244
+ disposeEsc.dispose();
245
+ });
246
+ }
247
+
248
+ // Fallback cleanup: if node-pty's onExit doesn't fire within 3s (common
249
+ // on Windows ConPTY after external termination), simulate it so the
250
+ // terminal entry is removed, clients are disconnected, and the server
251
+ // broadcasts terminal_removed. Without this, the X click never
252
+ // completes from the user's perspective.
253
+ const fallback = setTimeout(() => {
254
+ const stale = entries.get(id);
255
+ if (!stale) return; // onExit already ran
256
+ stale.session = { ...stale.session, status: "ended" };
257
+ for (const ws of stale.clients) {
258
+ try { ws.close(); } catch { /* ignore */ }
259
+ }
260
+ stale.clients.clear();
261
+ entries.delete(id);
262
+ options?.onExit?.(id);
263
+ }, 3000);
264
+ const disposeFb = entry.pty.onExit(() => {
265
+ clearTimeout(fallback);
266
+ disposeFb.dispose();
222
267
  });
223
268
  }
224
269
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Defense-in-depth guard against destructive PID-registry sweeps during tests.
3
+ *
4
+ * Production startup code paths (headlessPidRegistry.cleanupOrphans/killAll,
5
+ * editorPidRegistry.cleanupOrphans) read PID files and send SIGTERM. If they
6
+ * ever run under vitest AGAINST the developer's real $HOME, they can kill
7
+ * live pi sessions.
8
+ *
9
+ * This guard returns true when:
10
+ * - we appear to be inside a vitest run (VITEST env var), AND
11
+ * - HOME still points at the real user home (tripwire missed).
12
+ *
13
+ * Callers SHOULD `console.warn` and return without performing destructive work
14
+ * when this returns true.
15
+ *
16
+ * Normal production servers (VITEST not set) always get `false` and behave as
17
+ * before.
18
+ */
19
+ import os from "node:os";
20
+
21
+ export function isUnsafeTestHomeScan(): boolean {
22
+ if (process.env.VITEST !== "true") return false;
23
+ const currentHome = process.env.HOME ?? "";
24
+ const realHome = os.userInfo().homedir;
25
+ return currentHome === "" || currentHome === realHome;
26
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * createTestServer — boot a real DashboardServer on OS-assigned ports for
3
+ * integration tests, with safe defaults (no auto-shutdown, no tunnel).
4
+ *
5
+ * Use with the `setup-home` vitest setupFile (in @blackbelt-technology/pi-dashboard-shared/test-support)
6
+ * so that HOME is also isolated.
7
+ *
8
+ * Example:
9
+ * const { server, httpPort, piPort, stop } = await createTestServer();
10
+ * const res = await fetch(`http://localhost:${httpPort}/api/health`);
11
+ * ...
12
+ * await stop();
13
+ */
14
+ import { createServer, type DashboardServer, type ServerConfig } from "../server.js";
15
+
16
+ export interface TestServerHandle {
17
+ server: DashboardServer;
18
+ httpPort: number;
19
+ piPort: number;
20
+ stop: () => Promise<void>;
21
+ }
22
+
23
+ export type TestServerOverrides = Partial<ServerConfig>;
24
+
25
+ const DEFAULTS: ServerConfig = {
26
+ port: 0,
27
+ piPort: 0,
28
+ dev: true,
29
+ autoShutdown: false,
30
+ shutdownIdleSeconds: 999,
31
+ tunnel: false,
32
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
33
+ };
34
+
35
+ export async function createTestServer(
36
+ overrides: TestServerOverrides = {},
37
+ ): Promise<TestServerHandle> {
38
+ const config: ServerConfig = { ...DEFAULTS, ...overrides };
39
+ const server = await createServer(config);
40
+ await server.start();
41
+
42
+ const httpPort = server.httpPort();
43
+ const piPort = server.piPort();
44
+ if (httpPort == null || piPort == null) {
45
+ await server.stop();
46
+ throw new Error(
47
+ `createTestServer: failed to resolve ports (httpPort=${httpPort}, piPort=${piPort})`,
48
+ );
49
+ }
50
+
51
+ return {
52
+ server,
53
+ httpPort,
54
+ piPort,
55
+ stop: async () => {
56
+ try {
57
+ await server.stop();
58
+ } catch {
59
+ // best-effort — tests may race on shutdown
60
+ }
61
+ },
62
+ };
63
+ }
@@ -7,7 +7,15 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
- import { execSync, spawn, type ChildProcess } from "node:child_process";
10
+ import { execSync, spawn, type ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
11
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
12
+ import {
13
+ isProcessAlive,
14
+ killProcess,
15
+ killPidWithGroup,
16
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
17
+
18
+ const zrokResolver = new ToolResolver({ processExecPath: process.execPath });
11
19
  import type { TunnelStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
12
20
  import { CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
21
 
@@ -27,17 +35,20 @@ const SPAWN_TIMEOUT_MS = 30_000;
27
35
  let activeProcess: ChildProcess | null = null;
28
36
  let activeTunnelUrl: string | null = null;
29
37
  let zrokAvailable: boolean | null = null;
38
+ // Serialization: any concurrent createTunnel() call while one is already in
39
+ // flight returns the same promise instead of spawning a second zrok process.
40
+ // Without this, a UI double-click or a race between startup auto-connect and
41
+ // an explicit `/api/tunnel-connect` created two parallel reservations and
42
+ // two running `zrok share` processes for the same port.
43
+ let pendingCreate: Promise<string | null> | null = null;
30
44
 
31
45
  // ── Binary Detection ────────────────────────────────────────────────
32
46
 
33
47
  function checkZrokOnPath(): boolean {
34
- const cmd = process.platform === "win32" ? "where zrok" : "which zrok";
35
- try {
36
- execSync(cmd, { stdio: "ignore" });
37
- return true;
38
- } catch {
39
- return false;
40
- }
48
+ // Delegate binary lookup to the shared platform primitive (handles the
49
+ // where/which split on Windows vs Unix, managed-bin search, and login
50
+ // shell fallback). See change: consolidate-platform-handlers.
51
+ return zrokResolver.which("zrok") !== null;
41
52
  }
42
53
 
43
54
  /**
@@ -87,30 +98,22 @@ export function removeZrokPid(): void {
87
98
 
88
99
  // ── Stale Process Cleanup ───────────────────────────────────────────
89
100
 
90
- /**
91
- * Check if a process is alive by sending signal 0.
92
- */
93
- function isProcessAlive(pid: number): boolean {
94
- try {
95
- process.kill(pid, 0);
96
- return true;
97
- } catch {
98
- return false;
99
- }
100
- }
101
-
102
101
  /**
103
102
  * Clean up stale zrok processes from previous server runs.
104
- * Reads PID file, kills process if running, removes PID file.
103
+ * Reads PID file, kills process if running (via the platform helper so
104
+ * Windows uses `taskkill /F /T /PID`), removes PID file.
105
+ * See change: route-kill-paths-through-platform.
105
106
  */
106
- export function cleanupStaleZrok(): void {
107
+ export async function cleanupStaleZrok(): Promise<void> {
107
108
  const pid = readZrokPid();
108
109
  if (pid === null) return;
109
110
 
110
111
  if (isProcessAlive(pid)) {
111
112
  try {
112
- process.kill(pid, "SIGTERM");
113
- console.log(`Killed stale zrok process (PID ${pid})`);
113
+ const result = await killProcess(pid, { timeoutMs: 2000 });
114
+ if (result.ok) {
115
+ console.log(`Killed stale zrok process (PID ${pid})`);
116
+ }
114
117
  } catch (err: any) {
115
118
  console.warn(`Failed to kill stale zrok process (PID ${pid}): ${err.message}`);
116
119
  }
@@ -160,6 +163,75 @@ function saveReservedToken(token: string): void {
160
163
  }
161
164
  }
162
165
 
166
+ /**
167
+ * Release a reserved share via `zrok release <token>`. Best-effort, non-throwing.
168
+ * Returns true if the release command exited cleanly, false otherwise. Callers
169
+ * should invoke this whenever abandoning a reserved token so the zrok edge
170
+ * doesn't keep an orphaned reservation record (which is what causes stale
171
+ * URLs like `tgbdzzvlar6b.share.zrok.io` to persist after the agent dies).
172
+ */
173
+ export function releaseShare(token: string): boolean {
174
+ if (!token) return false;
175
+ try {
176
+ execSync(`zrok release ${token}`, {
177
+ timeout: 10_000,
178
+ stdio: ["ignore", "ignore", "ignore"],
179
+ });
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Scan `ps` for orphan `zrok share` processes that point at the given port
188
+ * via `--override-endpoint http://localhost:<port>` and SIGTERM them.
189
+ *
190
+ * This complements `cleanupStaleZrok` (which only knows about the single PID
191
+ * in our pid-file): when the retry logic in `createTunnel` leaks processes
192
+ * across failures, or when a previous server instance crashed, the pid-file
193
+ * loses track of them. On startup we scavenge them directly from the process
194
+ * table so a fresh tunnel doesn't compete with orphans.
195
+ *
196
+ * Returns the list of PIDs we killed.
197
+ */
198
+ export function scavengeOrphanZrokProcesses(port: number): number[] {
199
+ const killed: number[] = [];
200
+ let output = "";
201
+ try {
202
+ output = execSync("ps -ax -o pid=,args=", {
203
+ encoding: "utf-8",
204
+ timeout: 5_000,
205
+ }).toString();
206
+ } catch {
207
+ return killed;
208
+ }
209
+
210
+ const endpointMarker = `--override-endpoint http://localhost:${port}`;
211
+ for (const line of output.split(/\r?\n/)) {
212
+ const trimmed = line.trim();
213
+ if (!trimmed) continue;
214
+ if (!trimmed.includes("zrok share")) continue;
215
+ if (!trimmed.includes(endpointMarker)) continue;
216
+ const m = trimmed.match(/^(\d+)\s+/);
217
+ if (!m) continue;
218
+ const pid = parseInt(m[1], 10);
219
+ if (!Number.isFinite(pid) || pid <= 0) continue;
220
+ if (pid === process.pid) continue; // never kill ourselves
221
+ try {
222
+ // Group-kill on Unix so zrok's child workers die with it; taskkill /T
223
+ // already handles the tree on Windows (killPidWithGroup routes the
224
+ // platform-correct path).
225
+ killPidWithGroup(pid, "SIGTERM");
226
+ killed.push(pid);
227
+ console.log(`Scavenged orphan zrok process (PID ${pid})`);
228
+ } catch {
229
+ // Process may have exited between ps and kill — ignore
230
+ }
231
+ }
232
+ return killed;
233
+ }
234
+
163
235
  /**
164
236
  * Create a reserved share via `zrok reserve public`.
165
237
  * Returns the share token or null on failure.
@@ -195,7 +267,29 @@ function reserveShare(port: number): Promise<string | null> {
195
267
  * On subsequent runs, reuses the reserved token.
196
268
  * Returns URL or null on failure.
197
269
  */
198
- export function createTunnel(port: number, reservedToken?: string): Promise<string | null> {
270
+ export function createTunnel(
271
+ port: number,
272
+ reservedToken?: string,
273
+ retriesLeft: number = 1,
274
+ ): Promise<string | null> {
275
+ // Fast path: another caller is already creating a tunnel — join that promise.
276
+ if (pendingCreate) return pendingCreate;
277
+ // Fast path: tunnel already up — return its URL without spawning.
278
+ if (activeTunnelUrl) return Promise.resolve(activeTunnelUrl);
279
+
280
+ const promise = _createTunnelInner(port, reservedToken, retriesLeft);
281
+ pendingCreate = promise;
282
+ promise.finally(() => {
283
+ if (pendingCreate === promise) pendingCreate = null;
284
+ });
285
+ return promise;
286
+ }
287
+
288
+ function _createTunnelInner(
289
+ port: number,
290
+ reservedToken?: string,
291
+ retriesLeft: number = 1,
292
+ ): Promise<string | null> {
199
293
  return new Promise(async (resolve) => {
200
294
  if (!detectZrokBinary()) {
201
295
  resolve(null);
@@ -209,7 +303,11 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
209
303
  return;
210
304
  }
211
305
 
212
- // Try to get or create a reserved token
306
+ // Track whether this call reserved the token itself (so we know to
307
+ // release it if we subsequently time out or fail — the caller-provided
308
+ // `reservedToken` is owned by the caller / config and must not be released
309
+ // on transient timeouts).
310
+ const callerProvidedToken = !!reservedToken;
213
311
  let token = reservedToken;
214
312
  if (!token) {
215
313
  token = await reserveShare(port) ?? undefined;
@@ -228,12 +326,27 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
228
326
  detached: false,
229
327
  });
230
328
 
231
- // Timeout: kill process if URL not parsed in time
329
+ // Timeout: kill process if URL not parsed in time. Escalate SIGTERM
330
+ // → SIGKILL after a grace period so a wedged zrok doesn't keep a stale
331
+ // reservation attached after we've moved on. If we reserved the token
332
+ // just-in-time within this call, release it on the zrok edge too so we
333
+ // don't leak a dead reservation (root cause of stale URLs like
334
+ // `tgbdzzvlar6b.share.zrok.io`).
232
335
  const timeout = setTimeout(() => {
233
336
  if (!resolved) {
234
337
  resolved = true;
235
338
  console.warn("zrok tunnel creation timed out (30s)");
236
- try { child.kill("SIGTERM"); } catch {}
339
+ try {
340
+ if (child.pid != null) killPidWithGroup(child.pid, "SIGTERM");
341
+ else child.kill("SIGTERM");
342
+ } catch { /* already dead */ }
343
+ setTimeout(() => {
344
+ try {
345
+ if (child.pid != null) killPidWithGroup(child.pid, "SIGKILL");
346
+ else child.kill("SIGKILL");
347
+ } catch { /* already dead */ }
348
+ }, 2_000);
349
+ if (token && !callerProvidedToken) releaseShare(token);
237
350
  removeZrokPid();
238
351
  resolve(null);
239
352
  }
@@ -270,10 +383,20 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
270
383
  if (!resolved) {
271
384
  resolved = true;
272
385
  clearTimeout(timeout);
273
- // If reserved share failed, token may be expired retry with fresh reservation
274
- if (token) {
275
- console.warn(`Reserved share failed (code ${code}), creating new reservation...`);
276
- resolve(createTunnel(port)); // retry without token
386
+ // If reserved share failed, token may be expired or already attached
387
+ // to an orphan process. Release it on the zrok edge before retrying so
388
+ // we don't leak dead reservations (which is what produced stale URLs
389
+ // like `tgbdzzvlar6b.share.zrok.io` pointing at nothing).
390
+ if (token && retriesLeft > 0) {
391
+ console.warn(`Reserved share failed (code ${code}), releasing token ${token} and creating new reservation...`);
392
+ releaseShare(token);
393
+ // Bypass the mutex wrapper so we don't self-deadlock: call the inner
394
+ // implementation directly for the retry attempt.
395
+ resolve(_createTunnelInner(port, undefined, retriesLeft - 1));
396
+ } else if (token) {
397
+ console.warn(`Reserved share failed (code ${code}) and retry budget exhausted; releasing token ${token}`);
398
+ releaseShare(token);
399
+ resolve(null);
277
400
  } else {
278
401
  console.warn(`zrok process exited before producing URL (code ${code})`);
279
402
  resolve(null);
@@ -291,20 +414,35 @@ export function createTunnel(port: number, reservedToken?: string): Promise<stri
291
414
 
292
415
  /**
293
416
  * Stop the active tunnel. Kills the subprocess and removes PID file.
417
+ * Also sweeps any orphan zrok processes bound to the given port so restart
418
+ * paths (which call `deleteTunnel` then spawn a new server) don't leave
419
+ * dead reservations attached to the zrok edge.
294
420
  */
295
- export async function deleteTunnel(): Promise<void> {
421
+ export async function deleteTunnel(port?: number): Promise<void> {
296
422
  const child = activeProcess;
297
423
  activeProcess = null;
298
424
  activeTunnelUrl = null;
299
425
 
300
426
  if (child) {
301
427
  try {
302
- child.kill("SIGTERM");
428
+ if (child.pid != null) {
429
+ // Route through the platform helper so Windows gets taskkill
430
+ // semantics (tree-kill). See change: route-kill-paths-through-platform.
431
+ await killProcess(child.pid, { timeoutMs: 2000 });
432
+ } else {
433
+ child.kill("SIGTERM");
434
+ }
303
435
  } catch (err: any) {
304
436
  console.warn(`zrok tunnel cleanup failed: ${err.message}`);
305
437
  }
306
438
  }
307
439
  removeZrokPid();
440
+
441
+ // Belt-and-braces: sweep any orphan zrok processes that escaped pid-file
442
+ // tracking (e.g. from a previous crash or a failed retry chain).
443
+ if (typeof port === "number") {
444
+ try { scavengeOrphanZrokProcesses(port); } catch { /* best-effort */ }
445
+ }
308
446
  }
309
447
 
310
448
  /**
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.2.9",
3
+ "version": "0.4.0",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
6
9
  "exports": {
7
10
  "./*.js": "./src/*.ts",
8
11
  "./*": "./src/*"
@@ -10,6 +13,10 @@
10
13
  "files": [
11
14
  "src/"
12
15
  ],
13
- "dependencies": {},
14
- "devDependencies": {}
16
+ "dependencies": {
17
+ "bonjour-service": "^1.3.0"
18
+ },
19
+ "devDependencies": {
20
+ "memfs": "^4.57.2"
21
+ }
15
22
  }