@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
@@ -1,19 +1,34 @@
1
1
  /**
2
2
  * DirectoryService — server-side directory-scoped operations.
3
- * Handles session discovery, event loading, and OpenSpec polling
4
- * directly on the server without requiring bridge connections.
3
+ *
4
+ * Responsibilities:
5
+ * - Session discovery and event loading (unchanged).
6
+ * - OpenSpec polling with an mtime-gated cache, configurable interval,
7
+ * concurrency cap (semaphore), and deterministic per-cwd jitter to
8
+ * flatten the CPU envelope.
9
+ * - Pi resources scanning on its own slower cadence (5× openspec interval)
10
+ * so it does not stack onto the openspec burst.
11
+ *
12
+ * See change: optimize-openspec-poll-burst for the cost model.
5
13
  */
6
- import { pollOpenSpecAsync } from "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js";
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import {
17
+ buildOpenSpecData,
18
+ pollOpenSpecAsync,
19
+ runOpenSpecList,
20
+ runOpenSpecStatus,
21
+ } from "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js";
22
+ import { DEFAULT_OPENSPEC_POLL, type OpenSpecPollConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
23
+ import { createSemaphore, type Semaphore } from "@blackbelt-technology/pi-dashboard-shared/semaphore.js";
7
24
  import { discoverSessionsForCwd } from "./session-discovery.js";
8
25
  import { replayEntriesAsEvents } from "@blackbelt-technology/pi-dashboard-shared/state-replay.js";
9
26
  import { scanPiResources } from "./pi-resource-scanner.js";
10
- import type { OpenSpecData } from "@blackbelt-technology/pi-dashboard-shared/types.js";
27
+ import type { OpenSpecData, OpenSpecChange } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
28
  import type { PiResourcesResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
12
29
  import type { PreferencesStore } from "./preferences-store.js";
13
30
  import type { SessionManager } from "./memory-session-manager.js";
14
31
 
15
- const POLL_INTERVAL = 30_000;
16
-
17
32
  import type { DiscoveredSession } from "./session-discovery.js";
18
33
  export type { DiscoveredSession } from "./session-discovery.js";
19
34
 
@@ -33,34 +48,88 @@ export interface DirectoryService {
33
48
  discoverSessions(cwd: string): DiscoveredSession[];
34
49
  loadSessionEvents(sessionId: string, sessionFile: string): Promise<LoadResult>;
35
50
  getOpenSpecData(cwd: string): OpenSpecData | undefined;
51
+ /** Force refresh: bypasses the mtime gate. Still honors the semaphore. */
36
52
  refreshOpenSpec(cwd: string): Promise<OpenSpecData>;
53
+ /** Gated poll: respects `changeDetection` config and the semaphore. Returns cached data. */
54
+ pollDirectoryGated(cwd: string): Promise<OpenSpecData>;
37
55
  getPiResources(cwd: string): PiResourcesResult | undefined;
38
56
  refreshPiResources(cwd: string): Promise<PiResourcesResult>;
39
57
  startPolling(onChange: (cwd: string, data: OpenSpecData) => void): void;
40
58
  stopPolling(): void;
59
+ /** Apply a new OpenSpecPollConfig without losing cache. Safe to call mid-stream. */
60
+ reconfigurePolling(config: OpenSpecPollConfig): void;
41
61
  onDirectoryAdded(cwd: string): Promise<DirectoryAddedResult>;
42
62
  }
43
63
 
64
+ // ── Jitter ─────────────────────────────────────────────────────────
65
+ // 32-bit FNV-1a hash — cheap, stable, well-distributed for short strings.
66
+ export function fnv1a32(s: string): number {
67
+ let h = 0x811c9dc5;
68
+ for (let i = 0; i < s.length; i++) {
69
+ h ^= s.charCodeAt(i);
70
+ h = Math.imul(h, 0x01000193);
71
+ }
72
+ return h >>> 0;
73
+ }
74
+
75
+ export function phaseOffsetMs(cwd: string, jitterSeconds: number): number {
76
+ if (!Number.isFinite(jitterSeconds) || jitterSeconds <= 0) return 0;
77
+ return fnv1a32(cwd) % (jitterSeconds * 1000);
78
+ }
79
+
80
+ // ── mtime helpers ──────────────────────────────────────────────────
81
+ function statMtimeOr(p: string): number | undefined {
82
+ try {
83
+ return fs.statSync(p).mtimeMs;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ // ── Per-directory cache ────────────────────────────────────────────
90
+ type PerChangeEntry = {
91
+ mtimeMs: number | undefined;
92
+ change: OpenSpecChange;
93
+ };
94
+
95
+ type DirCache = {
96
+ /** mtime of `<cwd>/openspec/changes/` when we last ran `openspec list`. */
97
+ listMtimeMs: number | undefined;
98
+ /** Cached list-result entries (raw shape from openspec list). */
99
+ listResult: Array<{ name: string; status: string; completedTasks: number; totalTasks: number }> | undefined;
100
+ changes: Map<string, PerChangeEntry>;
101
+ /** Last built OpenSpecData (what we broadcast). */
102
+ data: OpenSpecData | undefined;
103
+ };
104
+
105
+ function emptyDirCache(): DirCache {
106
+ return { listMtimeMs: undefined, listResult: undefined, changes: new Map(), data: undefined };
107
+ }
108
+
44
109
  export function createDirectoryService(
45
110
  preferencesStore: PreferencesStore,
46
111
  sessionManager: SessionManager,
112
+ initialConfig?: Partial<OpenSpecPollConfig>,
47
113
  ): DirectoryService {
48
- const openspecCache = new Map<string, OpenSpecData>();
114
+ let cfg: OpenSpecPollConfig = { ...DEFAULT_OPENSPEC_POLL, ...(initialConfig ?? {}) };
115
+
116
+ const caches = new Map<string, DirCache>();
49
117
  const piResourcesCache = new Map<string, PiResourcesResult>();
118
+
119
+ let semaphore: Semaphore = createSemaphore(cfg.maxConcurrentSpawns);
120
+
50
121
  let pollTimer: ReturnType<typeof setInterval> | null = null;
122
+ let piResourcesTimer: ReturnType<typeof setInterval> | null = null;
51
123
  let onChangeCallback: ((cwd: string, data: OpenSpecData) => void) | null = null;
124
+ const scheduledPhaseTimers = new Set<ReturnType<typeof setTimeout>>();
52
125
 
53
126
  // In-progress session loads for dedup
54
127
  const loadingSet = new Set<string>();
55
128
 
56
129
  function computeKnownDirectories(): string[] {
57
130
  const dirs = new Set<string>();
58
- for (const dir of preferencesStore.getPinnedDirectories()) {
59
- dirs.add(dir);
60
- }
61
- for (const session of sessionManager.listAll()) {
62
- dirs.add(session.cwd);
63
- }
131
+ for (const dir of preferencesStore.getPinnedDirectories()) dirs.add(dir);
132
+ for (const session of sessionManager.listAll()) dirs.add(session.cwd);
64
133
  return Array.from(dirs);
65
134
  }
66
135
 
@@ -69,7 +138,6 @@ export function createDirectoryService(
69
138
  }
70
139
 
71
140
  async function loadSessionEvents(sessionId: string, sessionFile: string): Promise<LoadResult> {
72
- // Dedup: wait if already loading
73
141
  if (loadingSet.has(sessionId)) {
74
142
  return { success: false, events: [], error: "already_loading" };
75
143
  }
@@ -88,34 +156,190 @@ export function createDirectoryService(
88
156
  }
89
157
  }
90
158
 
91
- async function refreshOpenSpec(cwd: string): Promise<OpenSpecData> {
92
- const data = await pollOpenSpecAsync(cwd);
93
- openspecCache.set(cwd, data);
159
+ // ── Core gated poll ──────────────────────────────────────────────
160
+ // Contract:
161
+ // - `force=true` bypasses both the list-mtime and per-change-mtime gates.
162
+ // - Every CLI spawn goes through the shared semaphore.
163
+ // - Cache is updated atomically per directory: on any failure the
164
+ // old cache stays intact.
165
+ async function pollOne(cwd: string, force: boolean): Promise<OpenSpecData> {
166
+ const cache = caches.get(cwd) ?? emptyDirCache();
167
+ const gateEnabled = cfg.changeDetection === "mtime" && !force;
168
+
169
+ const changesRoot = path.join(cwd, "openspec", "changes");
170
+ const rootMtime = statMtimeOr(changesRoot);
171
+
172
+ // If the directory doesn't exist, short-circuit (matches old behavior).
173
+ if (rootMtime === undefined) {
174
+ const empty: OpenSpecData = { initialized: false, changes: [] };
175
+ cache.data = empty;
176
+ cache.listMtimeMs = undefined;
177
+ cache.listResult = undefined;
178
+ cache.changes.clear();
179
+ caches.set(cwd, cache);
180
+ return empty;
181
+ }
182
+
183
+ // ── Step 1: list (gated) ──
184
+ let listResult: typeof cache.listResult = cache.listResult;
185
+ const listCacheValid = gateEnabled && cache.listMtimeMs === rootMtime && cache.listResult !== undefined;
186
+ if (!listCacheValid) {
187
+ const raw = await semaphore.run(() => runOpenSpecList(cwd));
188
+ if (!raw || !Array.isArray(raw.changes)) {
189
+ const empty: OpenSpecData = { initialized: false, changes: [] };
190
+ cache.data = empty;
191
+ cache.listMtimeMs = rootMtime;
192
+ cache.listResult = undefined;
193
+ cache.changes.clear();
194
+ caches.set(cwd, cache);
195
+ return empty;
196
+ }
197
+ listResult = raw.changes;
198
+ cache.listMtimeMs = rootMtime;
199
+ cache.listResult = listResult;
200
+ }
201
+
202
+ // Prune cache for changes no longer present.
203
+ const liveNames = new Set((listResult ?? []).map((c) => c.name));
204
+ for (const key of Array.from(cache.changes.keys())) {
205
+ if (!liveNames.has(key)) cache.changes.delete(key);
206
+ }
207
+
208
+ // ── Step 2: per-change status (gated) ──
209
+ const statusResults = new Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>();
210
+
211
+ await Promise.all((listResult ?? []).map(async (c) => {
212
+ const changeDir = path.join(changesRoot, c.name);
213
+ const changeMtime = statMtimeOr(changeDir);
214
+ const cached = cache.changes.get(c.name);
215
+
216
+ if (gateEnabled && cached && cached.mtimeMs !== undefined && cached.mtimeMs === changeMtime) {
217
+ // Cache hit. Reuse the artifacts/isComplete from the cached OpenSpecChange.
218
+ statusResults.set(c.name, {
219
+ artifacts: cached.change.artifacts.map((a) => ({ id: a.id, status: a.status })),
220
+ ...(cached.change.isComplete !== undefined ? { isComplete: cached.change.isComplete } : {}),
221
+ });
222
+ return;
223
+ }
224
+
225
+ const status = await semaphore.run(() => runOpenSpecStatus(cwd, c.name));
226
+ statusResults.set(c.name, status);
227
+ }));
228
+
229
+ // ── Step 3: build + cache + return ──
230
+ const data = buildOpenSpecData({ changes: listResult ?? [] }, statusResults);
231
+
232
+ // Update per-change cache with the mtimes we just observed.
233
+ for (const change of data.changes) {
234
+ const changeDir = path.join(changesRoot, change.name);
235
+ const changeMtime = statMtimeOr(changeDir);
236
+ cache.changes.set(change.name, { mtimeMs: changeMtime, change });
237
+ }
238
+ cache.data = data;
239
+ caches.set(cwd, cache);
94
240
  return data;
95
241
  }
96
242
 
243
+ async function refreshOpenSpec(cwd: string): Promise<OpenSpecData> {
244
+ try {
245
+ return await pollOne(cwd, true);
246
+ } catch {
247
+ // Fall back to the legacy monolithic path so "refresh" never silently fails.
248
+ const data = await pollOpenSpecAsync(cwd);
249
+ const cache = caches.get(cwd) ?? emptyDirCache();
250
+ cache.data = data;
251
+ caches.set(cwd, cache);
252
+ return data;
253
+ }
254
+ }
255
+
256
+ async function pollDirectoryGated(cwd: string): Promise<OpenSpecData> {
257
+ return pollOne(cwd, false);
258
+ }
259
+
97
260
  async function refreshPiResourcesInternal(cwd: string): Promise<PiResourcesResult> {
98
261
  const data = await scanPiResources(cwd);
99
262
  piResourcesCache.set(cwd, data);
100
263
  return data;
101
264
  }
102
265
 
103
- async function pollAllDirectories() {
104
- const dirs = computeKnownDirectories();
105
- // Poll all directories in parallel, non-blocking
106
- await Promise.all(dirs.map(async (cwd) => {
107
- const [data] = await Promise.all([
108
- pollOpenSpecAsync(cwd),
109
- refreshPiResourcesInternal(cwd),
110
- ]);
111
- const prev = openspecCache.get(cwd);
112
- const prevJson = prev ? JSON.stringify(prev) : undefined;
113
- const newJson = JSON.stringify(data);
114
- openspecCache.set(cwd, data);
115
- if (newJson !== prevJson) {
116
- onChangeCallback?.(cwd, data);
266
+ // ── Scheduler ────────────────────────────────────────────────────
267
+ const TICK_SLOW_WARN_MS = 5000;
268
+ const DEBUG_ENABLED =
269
+ typeof process !== "undefined" && typeof process.env?.DEBUG === "string" && /pi-dashboard|openspec-poll/.test(process.env.DEBUG);
270
+
271
+ let openspecTickInFlight = false;
272
+ async function scheduleOpenSpecTick() {
273
+ if (openspecTickInFlight) return;
274
+ openspecTickInFlight = true;
275
+ const tickStart = Date.now();
276
+ let spawnsBefore = 0;
277
+ let spawnsAfter = 0;
278
+ try {
279
+ const dirs = computeKnownDirectories();
280
+ // Track spawn count by hooking the semaphore's size(). Approximation.
281
+ spawnsBefore = semaphore.size();
282
+ await Promise.all(dirs.map((cwd) => new Promise<void>((resolve) => {
283
+ const delay = phaseOffsetMs(cwd, cfg.jitterSeconds);
284
+ const timer = setTimeout(async () => {
285
+ scheduledPhaseTimers.delete(timer);
286
+ try {
287
+ const prev = caches.get(cwd)?.data;
288
+ const prevJson = prev ? JSON.stringify(prev) : undefined;
289
+ const next = await pollDirectoryGated(cwd);
290
+ const nextJson = JSON.stringify(next);
291
+ if (nextJson !== prevJson) onChangeCallback?.(cwd, next);
292
+ } catch (err) {
293
+ // Swallow — the next tick will retry.
294
+ console.error(`[openspec-poll] tick failed for ${cwd}:`, err);
295
+ } finally {
296
+ resolve();
297
+ }
298
+ }, delay);
299
+ scheduledPhaseTimers.add(timer);
300
+ })));
301
+ spawnsAfter = semaphore.size();
302
+ } finally {
303
+ openspecTickInFlight = false;
304
+ const durationMs = Date.now() - tickStart;
305
+ if (DEBUG_ENABLED) {
306
+ const dirs = computeKnownDirectories().length;
307
+ // eslint-disable-next-line no-console
308
+ console.log(`[openspec-poll] tick dirs=${dirs} queueBefore=${spawnsBefore} queueAfter=${spawnsAfter} durationMs=${durationMs}`);
117
309
  }
118
- }));
310
+ if (durationMs > TICK_SLOW_WARN_MS) {
311
+ console.warn(`[openspec-poll] slow tick: ${durationMs}ms (threshold ${TICK_SLOW_WARN_MS}ms). Consider raising pollIntervalSeconds or lowering maxConcurrentSpawns.`);
312
+ }
313
+ }
314
+ }
315
+
316
+ let piResourcesInFlight = false;
317
+ async function schedulePiResourcesTick() {
318
+ if (piResourcesInFlight) return;
319
+ piResourcesInFlight = true;
320
+ try {
321
+ await Promise.all(computeKnownDirectories().map(async (cwd) => {
322
+ try { await refreshPiResourcesInternal(cwd); }
323
+ catch { /* ignore, next tick retries */ }
324
+ }));
325
+ } finally {
326
+ piResourcesInFlight = false;
327
+ }
328
+ }
329
+
330
+ function installTimers() {
331
+ if (pollTimer) clearInterval(pollTimer);
332
+ if (piResourcesTimer) clearInterval(piResourcesTimer);
333
+ pollTimer = setInterval(scheduleOpenSpecTick, cfg.pollIntervalSeconds * 1000);
334
+ // Pi resources change far less often; poll at 5× the openspec interval.
335
+ piResourcesTimer = setInterval(schedulePiResourcesTick, cfg.pollIntervalSeconds * 5 * 1000);
336
+ }
337
+
338
+ function stopTimers() {
339
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
340
+ if (piResourcesTimer) { clearInterval(piResourcesTimer); piResourcesTimer = null; }
341
+ for (const t of scheduledPhaseTimers) clearTimeout(t);
342
+ scheduledPhaseTimers.clear();
119
343
  }
120
344
 
121
345
  return {
@@ -124,10 +348,11 @@ export function createDirectoryService(
124
348
  loadSessionEvents,
125
349
 
126
350
  getOpenSpecData(cwd: string): OpenSpecData | undefined {
127
- return openspecCache.get(cwd);
351
+ return caches.get(cwd)?.data;
128
352
  },
129
353
 
130
354
  refreshOpenSpec,
355
+ pollDirectoryGated,
131
356
 
132
357
  getPiResources(cwd: string): PiResourcesResult | undefined {
133
358
  return piResourcesCache.get(cwd);
@@ -139,18 +364,24 @@ export function createDirectoryService(
139
364
 
140
365
  startPolling(onChange: (cwd: string, data: OpenSpecData) => void) {
141
366
  onChangeCallback = onChange;
142
- if (pollTimer) clearInterval(pollTimer);
143
- pollTimer = setInterval(pollAllDirectories, POLL_INTERVAL);
367
+ installTimers();
144
368
  },
145
369
 
146
370
  stopPolling() {
147
- if (pollTimer) {
148
- clearInterval(pollTimer);
149
- pollTimer = null;
150
- }
371
+ stopTimers();
151
372
  onChangeCallback = null;
152
373
  },
153
374
 
375
+ reconfigurePolling(newCfg: OpenSpecPollConfig) {
376
+ const oldInterval = cfg.pollIntervalSeconds;
377
+ cfg = { ...newCfg };
378
+ semaphore.setMax(cfg.maxConcurrentSpawns);
379
+ // Only re-install timers if they were running and the interval actually changed.
380
+ if (pollTimer && oldInterval !== cfg.pollIntervalSeconds) {
381
+ installTimers();
382
+ }
383
+ },
384
+
154
385
  async onDirectoryAdded(cwd: string): Promise<DirectoryAddedResult> {
155
386
  const [sessions, openspecData] = await Promise.all([
156
387
  discoverSessions(cwd),
@@ -2,21 +2,24 @@
2
2
  * Auto-detection of code-server / openvscode-server binary.
3
3
  * Checks config override first, then PATH.
4
4
  */
5
- import { execSync } from "node:child_process";
6
5
  import type { EditorDetectionResult } from "@blackbelt-technology/pi-dashboard-shared/editor-types.js";
7
6
  import type { EditorConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
- import { buildSpawnEnv } from "./process-manager.js";
7
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
9
8
 
10
9
  export const BINARIES_TO_CHECK = ["code-server", "openvscode-server"] as const;
11
10
 
11
+ /**
12
+ * Look up a binary using the unified ToolResolver, which handles the
13
+ * where/which split (Windows vs Unix), managed-bin paths, and login-shell
14
+ * fallback for GUI apps. Previously used raw `which` which silently failed
15
+ * on Windows. See change: fix-windows-server-parity.
16
+ */
12
17
  export function whichBinary(name: string): string | null {
13
- try {
14
- // Use buildSpawnEnv to include ~/.local/bin and other user dirs
15
- // that Electron apps miss (no shell profile sourced)
16
- return execSync(`which ${name}`, { stdio: "pipe", encoding: "utf-8", env: buildSpawnEnv() }).trim() || null;
17
- } catch {
18
- return null;
19
- }
18
+ const resolver = new ToolResolver({
19
+ processExecPath: process.execPath,
20
+ useLoginShell: true,
21
+ });
22
+ return resolver.which(name);
20
23
  }
21
24
 
22
25
  let cachedResult: EditorDetectionResult | null = null;
@@ -2,7 +2,8 @@
2
2
  * Server-side lifecycle manager for code-server child processes.
3
3
  * Spawns per-folder instances, tracks heartbeats, enforces idle timeout and max instances.
4
4
  */
5
- import { spawn, type ChildProcess } from "node:child_process";
5
+ import { spawn, type ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
6
+ import { killPidWithGroup, killProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
6
7
  import { createServer as createNetServer, Socket as NetSocket } from "node:net";
7
8
  import { createHash, randomBytes } from "node:crypto";
8
9
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
@@ -329,9 +330,23 @@ export function createEditorManager(options: EditorManagerOptions): EditorManage
329
330
  setStatus(inst, "stopped");
330
331
 
331
332
  if (inst.process && !inst.process.killed) {
332
- try {
333
- inst.process.kill("SIGTERM");
334
- } catch {}
333
+ const pid = inst.process.pid;
334
+ if (pid != null) {
335
+ // Tree-kill the code-server subtree. On Windows this becomes
336
+ // `taskkill /F /T /PID` (async); on POSIX it's SIGTERM → 2s → SIGKILL
337
+ // of the process group. Fire-and-forget: `stop()` is synchronous
338
+ // by convention and callers don't await. See change:
339
+ // route-kill-paths-through-platform.
340
+ void killProcess(pid, { timeoutMs: 2000 }).catch(() => {
341
+ // Fallback to a direct pgroup SIGTERM if the platform helper
342
+ // couldn't complete (rare; mostly for already-dead processes).
343
+ try { killPidWithGroup(pid, "SIGTERM"); } catch { /* already dead */ }
344
+ });
345
+ } else {
346
+ // No PID yet (process hasn't started). Fall back to the raw
347
+ // ChildProcess.kill() which only signals the leaf.
348
+ try { inst.process.kill("SIGTERM"); } catch { /* already dead */ }
349
+ }
335
350
  }
336
351
 
337
352
  cleanup(id);
@@ -12,10 +12,14 @@
12
12
  */
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { execSync } from "node:child_process";
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
16
  import { readFileSync, existsSync } from "node:fs";
17
17
  import { readJsonFile, writeJsonFile } from "./json-store.js";
18
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";
19
23
 
20
24
  const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "editor-pids.json");
21
25
 
@@ -86,18 +90,15 @@ function defaultGetCmdline(pid: number): string | null {
86
90
  return null;
87
91
  }
88
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. */
89
95
  function defaultIsProcessAlive(pid: number): boolean {
90
- try {
91
- process.kill(pid, 0);
92
- return true;
93
- } catch {
94
- return false;
95
- }
96
+ return platformIsProcessAlive(pid);
96
97
  }
97
98
 
98
99
  function defaultKill(pid: number, signal: NodeJS.Signals): boolean {
99
100
  try {
100
- process.kill(pid, signal);
101
+ killPidWithGroup(pid, signal);
101
102
  return true;
102
103
  } catch {
103
104
  return false;
@@ -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 });
@@ -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,9 +3,10 @@
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";
11
12
  import { isUnsafeTestHomeScan } from "./test-env-guard.js";
@@ -82,15 +83,6 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
82
83
  return data.entries ?? [];
83
84
  }
84
85
 
85
- function isProcessAlive(pid: number): boolean {
86
- try {
87
- process.kill(pid, 0);
88
- return true;
89
- } catch {
90
- return false;
91
- }
92
- }
93
-
94
86
  return {
95
87
  register(pid: number, cwd: string, proc: ChildProcess) {
96
88
  entries.set(pid, { pid, cwd, process: proc, spawnedAt: Date.now() });
@@ -124,12 +116,9 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
124
116
  for (const entry of entries.values()) {
125
117
  if (entry.sessionId === sessionId) {
126
118
  try {
127
- // On Unix, kill the entire process group (negative PID) so the
128
- // wrapper shell, sleep, and pi processes are all terminated.
129
- // On Windows, process groups aren't supported — kill directly.
130
- const signal = "SIGTERM";
131
- const pid = process.platform === "win32" ? entry.pid : -entry.pid;
132
- 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");
133
122
  entries.delete(entry.pid);
134
123
  persist();
135
124
  return true;
@@ -153,10 +142,9 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
153
142
  console.warn("[headless-pid-registry] killAll() blocked: running under vitest with real HOME");
154
143
  return;
155
144
  }
156
- const useGroup = process.platform !== "win32";
157
145
  for (const [pid] of entries) {
158
146
  try {
159
- process.kill(useGroup ? -pid : pid, "SIGTERM");
147
+ killPidWithGroup(pid, "SIGTERM");
160
148
  } catch {
161
149
  // Process may have already exited
162
150
  }
@@ -190,8 +178,7 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
190
178
  if (age > MAX_ORPHAN_AGE_MS) {
191
179
  // Very old orphan — kill (process group on Unix, direct on Windows)
192
180
  try {
193
- const pid = process.platform === "win32" ? entry.pid : -entry.pid;
194
- process.kill(pid, "SIGTERM");
181
+ killPidWithGroup(entry.pid, "SIGTERM");
195
182
  } catch {
196
183
  // Already dead
197
184
  }