@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
@@ -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";
@@ -12,6 +13,7 @@ import type { EditorInstanceStatus, EditorDetectionResult } from "@blackbelt-tec
12
13
  import type { EditorConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
14
  import { detectCodeServerBinary, resetDetectionCache } from "./editor-detection.js";
14
15
  import { buildSpawnEnv } from "./process-manager.js";
16
+ import type { EditorPidRegistry } from "./editor-pid-registry.js";
15
17
 
16
18
  export interface EditorInstanceInfo {
17
19
  id: string;
@@ -37,6 +39,8 @@ export interface EditorManagerOptions {
37
39
  onStatusChange?: (cwd: string, id: string, status: EditorInstanceStatus) => void;
38
40
  /** Override re-detection (for testing). When false, skip runtime re-detection. */
39
41
  allowRedetection?: boolean;
42
+ /** Optional persistent PID registry for orphan cleanup across restarts. */
43
+ pidRegistry?: EditorPidRegistry;
40
44
  }
41
45
 
42
46
  export interface EditorManager {
@@ -141,7 +145,7 @@ function toInfo(inst: InternalInstance): EditorInstanceInfo {
141
145
  }
142
146
 
143
147
  export function createEditorManager(options: EditorManagerOptions): EditorManager {
144
- const { config, detection, onStatusChange, allowRedetection = true } = options;
148
+ const { config, detection, onStatusChange, allowRedetection = true, pidRegistry } = options;
145
149
  const instances = new Map<string, InternalInstance>();
146
150
  const cwdIndex = new Map<string, string>(); // cwd → id
147
151
  const idleTimeoutMs = (config.idleTimeoutMinutes ?? 10) * 60 * 1000;
@@ -279,6 +283,7 @@ export function createEditorManager(options: EditorManagerOptions): EditorManage
279
283
  child.on("error", (err) => {
280
284
  console.error(`[editor-manager] code-server error for ${cwd}:`, err.message);
281
285
  setStatus(inst, "stopped");
286
+ pidRegistry?.remove(id);
282
287
  cleanup(id);
283
288
  });
284
289
 
@@ -287,6 +292,7 @@ export function createEditorManager(options: EditorManagerOptions): EditorManage
287
292
  console.log(`[editor-manager] code-server exited (code=${code}) for ${cwd}`);
288
293
  setStatus(inst, "stopped");
289
294
  }
295
+ pidRegistry?.remove(id);
290
296
  cleanup(id);
291
297
  });
292
298
 
@@ -298,6 +304,16 @@ export function createEditorManager(options: EditorManagerOptions): EditorManage
298
304
  }
299
305
 
300
306
  setStatus(inst, "ready");
307
+ if (pidRegistry && typeof child.pid === "number") {
308
+ pidRegistry.register({
309
+ id,
310
+ pid: child.pid,
311
+ port,
312
+ cwd,
313
+ dataDir,
314
+ spawnedAt: inst.lastHeartbeat,
315
+ });
316
+ }
301
317
  startIdleTimer(inst);
302
318
  return toInfo(inst);
303
319
  }
@@ -306,13 +322,31 @@ export function createEditorManager(options: EditorManagerOptions): EditorManage
306
322
  const inst = instances.get(id);
307
323
  if (!inst) return;
308
324
 
325
+ // Remove from persistent registry FIRST so a crash mid-stop
326
+ // leaves the registry consistent on the next boot.
327
+ pidRegistry?.remove(id);
328
+
309
329
  clearIdleTimer(inst);
310
330
  setStatus(inst, "stopped");
311
331
 
312
332
  if (inst.process && !inst.process.killed) {
313
- try {
314
- inst.process.kill("SIGTERM");
315
- } 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
+ }
316
350
  }
317
351
 
318
352
  cleanup(id);