@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -11,6 +11,8 @@ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/type
11
11
  import { spawnPiSession } from "./process-manager.js";
12
12
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
13
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
14
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
15
+ import type { BootstrapQueue } from "./bootstrap-queue.js";
14
16
 
15
17
  export interface SessionApiDeps {
16
18
  sessionManager: SessionManager;
@@ -18,6 +20,13 @@ export interface SessionApiDeps {
18
20
  browserGateway: BrowserGateway;
19
21
  pendingForkRegistry?: PendingForkRegistry;
20
22
  pendingDashboardSpawns?: Map<string, number>;
23
+ /**
24
+ * Bootstrap state + queue for degraded-mode gating. When omitted,
25
+ * session operations run normally (legacy behavior for tests that
26
+ * don't exercise the bootstrap flow). See change: unified-bootstrap-install.
27
+ */
28
+ bootstrapState?: BootstrapStateStore;
29
+ bootstrapQueue?: BootstrapQueue;
21
30
  }
22
31
 
23
32
  type IdParams = { Params: { id: string } };
@@ -30,7 +39,54 @@ function getSessionOrFail(sessionManager: SessionManager, id: string): { session
30
39
  }
31
40
 
32
41
  export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
33
- const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns } = deps;
42
+ const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue } = deps;
43
+
44
+ /**
45
+ * Gate pi-dependent operations on bootstrap status. Returns:
46
+ * - null when ready (proceed).
47
+ * - `{ code: 202, body: { status: "queued", ticketId } }` when installing;
48
+ * the operation is enqueued and will run once status flips to "ready".
49
+ * - `{ code: 503, body: { error } }` when failed.
50
+ * See change: unified-bootstrap-install §5.
51
+ */
52
+ function gateOrEnqueue<T>(handler: () => Promise<T>):
53
+ | null
54
+ | { code: 202; body: { status: "queued"; ticketId: string } }
55
+ | { code: 503; body: { error: string; bootstrap: "failed" | "version-too-old" } } {
56
+ if (!bootstrapState) return null;
57
+ const snap = bootstrapState.get();
58
+ // Block when pi version is below the configured minimum —
59
+ // even when status is "ready", a too-old pi must not run sessions.
60
+ // See change: unified-bootstrap-install §9.3.
61
+ if (
62
+ snap.status === "ready"
63
+ && snap.error?.message?.startsWith("pi version ")
64
+ ) {
65
+ return {
66
+ code: 503,
67
+ body: { error: snap.error.message, bootstrap: "version-too-old" },
68
+ };
69
+ }
70
+ if (snap.status === "ready") return null;
71
+ if (snap.status === "installing") {
72
+ if (!bootstrapQueue) {
73
+ return {
74
+ code: 202,
75
+ body: { status: "queued", ticketId: "" },
76
+ };
77
+ }
78
+ const ticket = bootstrapQueue.enqueue(handler);
79
+ return {
80
+ code: 202,
81
+ body: { status: "queued", ticketId: ticket.ticketId },
82
+ };
83
+ }
84
+ // status === "failed"
85
+ return {
86
+ code: 503,
87
+ body: { error: "pi not installed (bootstrap failed)", bootstrap: "failed" },
88
+ };
89
+ }
34
90
 
35
91
  // POST /api/session/:id/prompt
36
92
  fastify.post<IdParams & { Body: { text?: string; images?: any[] } }>(
@@ -160,14 +216,27 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
160
216
  reply.code(400);
161
217
  return { success: false, error: "cwd is required" } satisfies ApiResponse;
162
218
  }
163
- const config = loadConfig();
164
- const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
165
- if (spawnResult.process && spawnResult.pid) {
166
- browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
167
- }
168
- if (spawnResult.dashboardSpawned && spawnResult.success) {
169
- pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
219
+
220
+ const doSpawn = async () => {
221
+ const config = loadConfig();
222
+ const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
223
+ if (spawnResult.process && spawnResult.pid) {
224
+ browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
225
+ }
226
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
227
+ pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
228
+ }
229
+ return spawnResult;
230
+ };
231
+
232
+ // Bootstrap gate: if pi isn't ready, queue the spawn and return 202.
233
+ const gate = gateOrEnqueue(doSpawn);
234
+ if (gate) {
235
+ reply.code(gate.code);
236
+ return gate.body;
170
237
  }
238
+
239
+ const spawnResult = await doSpawn();
171
240
  if (!spawnResult.success) {
172
241
  reply.code(500);
173
242
  return { success: false, error: spawnResult.message } satisfies ApiResponse;
@@ -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}`,
@@ -10,13 +10,19 @@ import type { WebSocket } from "ws";
10
10
 
11
11
  const DEFAULT_BUFFER_SIZE = 256 * 1024; // 256KB
12
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
+
13
22
  /** Detect the appropriate shell for the current platform. */
14
23
  export function detectShell(platform?: string): string {
15
- const p = platform ?? process.platform;
16
- if (p === "win32") {
17
- return process.env.COMSPEC || "powershell.exe";
18
- }
19
- 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);
20
26
  }
21
27
 
22
28
  /** Circular buffer for PTY output replay. */
@@ -110,10 +116,7 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
110
116
  const shell = detectShell();
111
117
  const id = generateId();
112
118
 
113
- const env = { ...process.env } as Record<string, string>;
114
- if (process.platform === "win32" && !env.TERM) {
115
- env.TERM = "cygwin";
116
- }
119
+ const env = { ...process.env, ...platformTerminalEnvHints() } as Record<string, string>;
117
120
 
118
121
  const p = pty.spawn(shell, [], {
119
122
  cwd,
@@ -211,18 +214,56 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
211
214
  function kill(id: string): void {
212
215
  const entry = entries.get(id);
213
216
  if (!entry) throw new Error(`Terminal ${id} not found`);
214
- // Interactive shells (e.g. bash on Linux) ignore SIGTERM.
215
- // Use SIGHUP which shells honor, then escalate to SIGKILL.
216
- entry.pty.kill("SIGHUP");
217
- const escalation = setTimeout(() => {
218
- if (entries.has(id)) {
219
- 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 */ }
234
+ }
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 */ }
220
259
  }
221
- }, 1000);
222
- // Clear escalation timeout if the process exits promptly
223
- const dispose = entry.pty.onExit(() => {
224
- clearTimeout(escalation);
225
- dispose.dispose();
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();
226
267
  });
227
268
  }
228
269
 
@@ -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
 
@@ -37,13 +45,10 @@ let pendingCreate: Promise<string | null> | null = null;
37
45
  // ── Binary Detection ────────────────────────────────────────────────
38
46
 
39
47
  function checkZrokOnPath(): boolean {
40
- const cmd = process.platform === "win32" ? "where zrok" : "which zrok";
41
- try {
42
- execSync(cmd, { stdio: "ignore" });
43
- return true;
44
- } catch {
45
- return false;
46
- }
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;
47
52
  }
48
53
 
49
54
  /**
@@ -93,30 +98,22 @@ export function removeZrokPid(): void {
93
98
 
94
99
  // ── Stale Process Cleanup ───────────────────────────────────────────
95
100
 
96
- /**
97
- * Check if a process is alive by sending signal 0.
98
- */
99
- function isProcessAlive(pid: number): boolean {
100
- try {
101
- process.kill(pid, 0);
102
- return true;
103
- } catch {
104
- return false;
105
- }
106
- }
107
-
108
101
  /**
109
102
  * Clean up stale zrok processes from previous server runs.
110
- * 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.
111
106
  */
112
- export function cleanupStaleZrok(): void {
107
+ export async function cleanupStaleZrok(): Promise<void> {
113
108
  const pid = readZrokPid();
114
109
  if (pid === null) return;
115
110
 
116
111
  if (isProcessAlive(pid)) {
117
112
  try {
118
- process.kill(pid, "SIGTERM");
119
- 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
+ }
120
117
  } catch (err: any) {
121
118
  console.warn(`Failed to kill stale zrok process (PID ${pid}): ${err.message}`);
122
119
  }
@@ -222,7 +219,10 @@ export function scavengeOrphanZrokProcesses(port: number): number[] {
222
219
  if (!Number.isFinite(pid) || pid <= 0) continue;
223
220
  if (pid === process.pid) continue; // never kill ourselves
224
221
  try {
225
- process.kill(pid, "SIGTERM");
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
226
  killed.push(pid);
227
227
  console.log(`Scavenged orphan zrok process (PID ${pid})`);
228
228
  } catch {
@@ -336,8 +336,16 @@ function _createTunnelInner(
336
336
  if (!resolved) {
337
337
  resolved = true;
338
338
  console.warn("zrok tunnel creation timed out (30s)");
339
- try { child.kill("SIGTERM"); } catch {}
340
- setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2_000);
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);
341
349
  if (token && !callerProvidedToken) releaseShare(token);
342
350
  removeZrokPid();
343
351
  resolve(null);
@@ -417,7 +425,13 @@ export async function deleteTunnel(port?: number): Promise<void> {
417
425
 
418
426
  if (child) {
419
427
  try {
420
- 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
+ }
421
435
  } catch (err: any) {
422
436
  console.warn(`zrok tunnel cleanup failed: ${err.message}`);
423
437
  }
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.3.0",
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
  }
@@ -5,28 +5,37 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
7
7
 
8
- const { mockExecSync, mockExistsSync } = vi.hoisted(() => ({
8
+ const { mockExecSync, mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
9
9
  mockExecSync: vi.fn(),
10
+ mockSpawnSync: vi.fn(),
10
11
  mockExistsSync: vi.fn(),
11
12
  }));
12
13
 
13
- vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
14
+ vi.mock("node:child_process", () => ({ execSync: mockExecSync, spawnSync: mockSpawnSync }));
14
15
  vi.mock("node:fs", () => ({ existsSync: mockExistsSync }));
15
16
 
16
- import { ToolResolver } from "../tool-resolver.js";
17
+ import { ToolResolver } from "../platform/binary-lookup.js";
17
18
 
18
19
  const MANAGED_BIN = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin");
19
20
 
21
+ // On Windows, ToolResolver.which() appends ".cmd" to the binary name when
22
+ // probing managed bin / extra dirs (shim convention for npm-installed bins).
23
+ // Unix has no extension. Tests must mirror this so assertions line up with
24
+ // what the implementation actually queries.
25
+ const BIN_EXT = process.platform === "win32" ? ".cmd" : "";
26
+
20
27
  describe("ToolResolver", () => {
21
28
  beforeEach(() => {
22
29
  vi.clearAllMocks();
23
30
  mockExistsSync.mockReturnValue(false);
24
31
  mockExecSync.mockImplementation(() => { throw new Error("not found"); });
32
+ // Default: spawnSync (used by whereAllLines) reports not found.
33
+ mockSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
25
34
  });
26
35
 
27
36
  describe("which()", () => {
28
37
  it("finds binary in managed bin first", () => {
29
- const managedPi = path.join(MANAGED_BIN, "pi");
38
+ const managedPi = path.join(MANAGED_BIN, "pi" + BIN_EXT);
30
39
  mockExistsSync.mockImplementation((p: string) => p === managedPi);
31
40
 
32
41
  const resolver = new ToolResolver();
@@ -35,21 +44,28 @@ describe("ToolResolver", () => {
35
44
 
36
45
  it("finds binary in extra bin dirs before system PATH", () => {
37
46
  const extraDir = "/custom/bin";
38
- const extraPi = path.join(extraDir, "pi");
47
+ const extraPi = path.join(extraDir, "pi" + BIN_EXT);
39
48
  mockExistsSync.mockImplementation((p: string) => p === extraPi);
40
49
 
41
50
  const resolver = new ToolResolver({ extraBinDirs: [extraDir] });
42
51
  expect(resolver.which("pi")).toBe(extraPi);
43
52
  });
44
53
 
45
- it("falls back to system PATH via which", () => {
46
- mockExecSync.mockImplementation((cmd: string) => {
47
- if (typeof cmd === "string" && cmd.includes("which pi")) return "/usr/bin/pi\n";
48
- throw new Error("not found");
54
+ it("falls back to system PATH via which/where", () => {
55
+ // Resolver uses `where` on Windows, `which` on Unix via spawnSync
56
+ // (not execSync see whereAllLines in platform/tools.ts).
57
+ const lookupCmd = process.platform === "win32" ? "where" : "which";
58
+ const expected = process.platform === "win32" ? "C:\\Windows\\pi.exe" : "/usr/bin/pi";
59
+ mockSpawnSync.mockImplementation((cmd: string, args: string[]) => {
60
+ // argv[0] is 'where'/'which', argv[1] is the target binary.
61
+ if (cmd === lookupCmd && args?.[0] === "pi") {
62
+ return { status: 0, stdout: expected + "\n", stderr: "" };
63
+ }
64
+ return { status: 1, stdout: "", stderr: "" };
49
65
  });
50
66
 
51
67
  const resolver = new ToolResolver();
52
- expect(resolver.which("pi")).toBe("/usr/bin/pi");
68
+ expect(resolver.which("pi")).toBe(expected);
53
69
  });
54
70
 
55
71
  it("tries login shell when enabled and PATH fails", () => {
@@ -123,7 +139,7 @@ describe("ToolResolver", () => {
123
139
  });
124
140
 
125
141
  it("falls back to which(node) when no context paths", () => {
126
- const managedNode = path.join(MANAGED_BIN, "node");
142
+ const managedNode = path.join(MANAGED_BIN, "node" + BIN_EXT);
127
143
  mockExistsSync.mockImplementation((p: string) => p === managedNode);
128
144
 
129
145
  const resolver = new ToolResolver();
@@ -143,7 +159,11 @@ describe("ToolResolver", () => {
143
159
 
144
160
  it("does not duplicate managed bin if already present", () => {
145
161
  const resolver = new ToolResolver();
146
- const env = resolver.buildSpawnEnv({ PATH: `${MANAGED_BIN}:/usr/bin` });
162
+ // Use the platform's PATH delimiter (`;` on Windows, `:` on Unix) so
163
+ // MANAGED_BIN is parsed as its own PATH entry — otherwise on Windows
164
+ // `${MANAGED_BIN}:/usr/bin` is treated as one single (broken) path.
165
+ const existingPath = [MANAGED_BIN, "/usr/bin"].join(path.delimiter);
166
+ const env = resolver.buildSpawnEnv({ PATH: existingPath });
147
167
  const count = env.PATH!.split(path.delimiter).filter(p => p === MANAGED_BIN).length;
148
168
  expect(count).toBe(1);
149
169
  });