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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * ToolRegistry — single-source resolver for every external binary, module,
3
+ * and directory the dashboard depends on.
4
+ *
5
+ * Design (see change: consolidate-tool-resolution, design §1-§4):
6
+ * - Ordered strategy chain per tool (override → managed → bare-import →
7
+ * npm-global → where).
8
+ * - One Resolution record per tool, cached in the registry.
9
+ * - Rescan invalidates one or all cached Resolutions.
10
+ * - Module resolution dynamically imports the resolved entry and caches
11
+ * the loaded ES module alongside the Resolution.
12
+ */
13
+ import { pathToFileURL } from "node:url";
14
+ import {
15
+ type ExecutorResolution,
16
+ ModuleResolutionError,
17
+ type Resolution,
18
+ type Source,
19
+ type StrategyCtx,
20
+ type ToolDefinition,
21
+ UnknownToolError,
22
+ type TriedEntry,
23
+ } from "./types.js";
24
+ import { OverridesStore } from "./overrides.js";
25
+
26
+ /**
27
+ * Minimal platform-environment snapshot injected into strategies.
28
+ * Production leaves this undefined and strategies fall back to
29
+ * `os.homedir()` / `process.cwd()`. Tests inject fakes so the bootstrap
30
+ * harness can reason about alternate HOME directories.
31
+ */
32
+ export interface PlatformEnv {
33
+ homedir?: string;
34
+ cwd?: string;
35
+ }
36
+
37
+ export interface ToolRegistryDeps {
38
+ overrides?: OverridesStore;
39
+ platform?: NodeJS.Platform;
40
+ /** Injected for tests; default uses native dynamic `import(url)`. */
41
+ importModule?: (url: string) => Promise<unknown>;
42
+ /** Clock injector (used by tests for deterministic `resolvedAt`). */
43
+ now?: () => number;
44
+ /** Environment overrides threaded into `StrategyCtx.env` (see types.ts). */
45
+ env?: PlatformEnv;
46
+ }
47
+
48
+ /** Default strategy → source mapping when a definition doesn't override it. */
49
+ const DEFAULT_CLASSIFY = (strategyName: string): Source => {
50
+ switch (strategyName) {
51
+ case "override":
52
+ return "override";
53
+ case "managed":
54
+ return "managed";
55
+ case "bare-import":
56
+ return "bare-import";
57
+ case "npm-global":
58
+ return "npm-global";
59
+ default:
60
+ return "system";
61
+ }
62
+ };
63
+
64
+ export class ToolRegistry {
65
+ private readonly definitions = new Map<string, ToolDefinition>();
66
+ private readonly cache = new Map<string, Resolution>();
67
+ private readonly moduleCache = new Map<string, unknown>();
68
+ private readonly overrides: OverridesStore;
69
+ private readonly platform: NodeJS.Platform;
70
+ private readonly importModule: (url: string) => Promise<unknown>;
71
+ private readonly now: () => number;
72
+ private readonly env: PlatformEnv | undefined;
73
+
74
+ constructor(deps: ToolRegistryDeps = {}) {
75
+ this.overrides = deps.overrides ?? new OverridesStore();
76
+ this.platform = deps.platform ?? process.platform;
77
+ this.importModule = deps.importModule ?? ((url) => import(/* @vite-ignore */ url));
78
+ this.now = deps.now ?? (() => Date.now());
79
+ this.env = deps.env;
80
+ }
81
+
82
+ /**
83
+ * Platform the registry was created for (or the current runtime's
84
+ * `process.platform`). Exposed so platform-conditional tool registration
85
+ * (e.g. skip `ps`/`pgrep` on Windows) in `registerDefaultTools` honours
86
+ * the test's injected platform instead of always reading the host.
87
+ */
88
+ getPlatform(): NodeJS.Platform {
89
+ return this.platform;
90
+ }
91
+
92
+ /** Register a tool definition. Last registration wins (tests re-register). */
93
+ register(def: ToolDefinition): void {
94
+ this.definitions.set(def.name, def);
95
+ this.cache.delete(def.name);
96
+ this.moduleCache.delete(def.name);
97
+ }
98
+
99
+ /** True when the name has a registered definition. */
100
+ has(name: string): boolean {
101
+ return this.definitions.has(name);
102
+ }
103
+
104
+ /** Snapshot of every registered tool's resolution. Triggers resolution as needed. */
105
+ list(): Resolution[] {
106
+ return Array.from(this.definitions.keys()).map((n) => this.resolve(n));
107
+ }
108
+
109
+ /** Resolve a binary/directory/module-path. Uses cached result when present. */
110
+ resolve(name: string): Resolution {
111
+ const def = this.definitions.get(name);
112
+ if (!def) throw new UnknownToolError(name);
113
+
114
+ const cached = this.cache.get(name);
115
+ if (cached) return cached;
116
+
117
+ const ctx: StrategyCtx = {
118
+ overrides: this.overrides.list(),
119
+ platform: this.platform,
120
+ env: this.env,
121
+ };
122
+
123
+ const tried: TriedEntry[] = [];
124
+ let winner: { strategy: string; path: string } | null = null;
125
+
126
+ // Platform-specific strategy chain overrides the default when
127
+ // present. Use case: tool resolution chain itself differs per OS
128
+ // (e.g. `pi` on Windows finds pi-coding-agent's cli.js; on Unix
129
+ // finds the `pi` binary on PATH).
130
+ const strategies = def.platformStrategies?.[this.platform] ?? def.strategies;
131
+
132
+ for (const strategy of strategies) {
133
+ const result = strategy.run(ctx);
134
+ if (!result.ok) {
135
+ tried.push({ strategy: strategy.name, result: result.reason });
136
+ continue;
137
+ }
138
+ // Optional validation (existence check, "must end in dist/index.js", ...).
139
+ if (def.validate) {
140
+ const v = def.validate(result.path);
141
+ if (!v.ok) {
142
+ tried.push({ strategy: strategy.name, result: `invalid: ${v.reason}` });
143
+ continue;
144
+ }
145
+ }
146
+ tried.push({ strategy: strategy.name, result: "ok" });
147
+ winner = { strategy: strategy.name, path: result.path };
148
+ break;
149
+ }
150
+
151
+ const classify = def.classify ?? DEFAULT_CLASSIFY;
152
+ const resolution: Resolution = winner
153
+ ? {
154
+ name,
155
+ ok: true,
156
+ path: winner.path,
157
+ source: classify(winner.strategy),
158
+ tried,
159
+ resolvedAt: this.now(),
160
+ }
161
+ : {
162
+ name,
163
+ ok: false,
164
+ path: null,
165
+ source: null,
166
+ tried,
167
+ resolvedAt: this.now(),
168
+ };
169
+
170
+ this.cache.set(name, resolution);
171
+ return resolution;
172
+ }
173
+
174
+ /**
175
+ * Resolve a tool and return its spawn-ready argv.
176
+ *
177
+ * Uses `resolve()` to find the artifact path, then applies the
178
+ * definition's `toArgv` transform (if any) to produce argv. Default:
179
+ * `argv = [path]` — appropriate for binary-kind tools resolved to an
180
+ * absolute path on PATH.
181
+ *
182
+ * For executor-kind tools with platform-specific interpreter needs
183
+ * (e.g. pi on Windows → `[node.exe, cli.js]`), `toArgv` does the
184
+ * assembly. `toArgv` may call `this.resolve(peer)` to find peer
185
+ * tools (e.g. `node`) and MUST fall back to `[path]` if peers are
186
+ * missing.
187
+ *
188
+ * Callers spawn via `spawn(argv[0], [...argv.slice(1), ...userArgs])`.
189
+ *
190
+ * See change: consolidate-windows-spawn-and-platform-handlers.
191
+ */
192
+ resolveExecutor(name: string): ExecutorResolution {
193
+ const def = this.definitions.get(name);
194
+ if (!def) throw new UnknownToolError(name);
195
+
196
+ const resolution = this.resolve(name);
197
+ if (!resolution.ok || !resolution.path) {
198
+ return { ...resolution, argv: [] };
199
+ }
200
+
201
+ const argv = def.toArgv
202
+ ? def.toArgv(resolution.path, { platform: this.platform, registry: this })
203
+ : [resolution.path];
204
+
205
+ return { ...resolution, argv };
206
+ }
207
+
208
+ /**
209
+ * Resolve AND dynamically import a registered module-kind tool.
210
+ * Throws `ModuleResolutionError` with the full `tried[]` trail when
211
+ * every strategy fails. The loaded ES module is cached alongside the
212
+ * Resolution; `rescan(name)` invalidates both.
213
+ */
214
+ async resolveModule<T = unknown>(name: string): Promise<{ resolution: Resolution; module: T }> {
215
+ const def = this.definitions.get(name);
216
+ if (!def) throw new UnknownToolError(name);
217
+ if (def.kind !== "module") {
218
+ throw new Error(`Tool "${name}" is not kind: "module"; use resolve() instead.`);
219
+ }
220
+
221
+ const resolution = this.resolve(name);
222
+ if (!resolution.ok || !resolution.path) {
223
+ throw new ModuleResolutionError(resolution);
224
+ }
225
+
226
+ const cached = this.moduleCache.get(name) as T | undefined;
227
+ if (cached) return { resolution, module: cached };
228
+
229
+ const url = pathToFileURL(resolution.path).href;
230
+ const loaded = (await this.importModule(url)) as T;
231
+ this.moduleCache.set(name, loaded);
232
+ return { resolution, module: loaded };
233
+ }
234
+
235
+ /** Drop cached Resolution(s). Next resolve() re-runs strategies. */
236
+ rescan(name?: string): void {
237
+ if (name === undefined) {
238
+ this.cache.clear();
239
+ this.moduleCache.clear();
240
+ this.overrides.invalidate();
241
+ return;
242
+ }
243
+ this.cache.delete(name);
244
+ this.moduleCache.delete(name);
245
+ }
246
+
247
+ /** Set a path override. Invalidates the target's cache. */
248
+ setOverride(name: string, overridePath: string): void {
249
+ if (!this.definitions.has(name)) throw new UnknownToolError(name);
250
+ this.overrides.set(name, overridePath);
251
+ this.cache.delete(name);
252
+ this.moduleCache.delete(name);
253
+ }
254
+
255
+ /** Clear a path override. Invalidates the target's cache. */
256
+ clearOverride(name: string): void {
257
+ if (!this.definitions.has(name)) throw new UnknownToolError(name);
258
+ this.overrides.clear(name);
259
+ this.cache.delete(name);
260
+ this.moduleCache.delete(name);
261
+ }
262
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Reusable resolution strategies shared across tool definitions.
3
+ *
4
+ * Strategies are pure functions over their `StrategyCtx` — filesystem
5
+ * access (`existsSync`) is the only side effect. They never spawn; PATH
6
+ * search delegates to `ToolResolver.which()` which is injectable for
7
+ * tests via the `lookup` parameter.
8
+ *
9
+ * See change: consolidate-tool-resolution (design §2).
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import path from "node:path";
14
+ import { ToolResolver } from "../platform/binary-lookup.js";
15
+ import { getManagedBin, getManagedDir } from "../managed-paths.js";
16
+ import * as npm from "../platform/npm.js";
17
+ import type { Strategy, StrategyCtx, StrategyResult } from "./types.js";
18
+
19
+ /**
20
+ * Injectable surfaces used by strategies.
21
+ *
22
+ * - `exists` — fs existence probe (memfs in tests).
23
+ * - `which` — PATH search.
24
+ * - `npmRootGlobal` — result of `npm root -g` (tests inject to avoid spawn).
25
+ * - `resolveModule` — node-module resolution (id, from) → absolute path.
26
+ * Production uses `createRequire(from).resolve(id)`; tests walk fake
27
+ * node_modules trees.
28
+ */
29
+ export interface StrategyDeps {
30
+ exists?(p: string): boolean;
31
+ which?(name: string): string | null;
32
+ npmRootGlobal?(): string;
33
+ resolveModule?(id: string, from: string): string | null;
34
+ }
35
+
36
+ function defaultResolveModule(id: string, from: string): string | null {
37
+ try {
38
+ return createRequire(from).resolve(id);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function defaults(): Required<StrategyDeps> {
45
+ const resolver = new ToolResolver({
46
+ processExecPath: process.execPath,
47
+ useLoginShell: true,
48
+ });
49
+ return {
50
+ exists: existsSync,
51
+ which: (name) => resolver.which(name),
52
+ npmRootGlobal: () => npm.rootGlobalOr(""),
53
+ resolveModule: defaultResolveModule,
54
+ };
55
+ }
56
+
57
+ /** Merge caller-supplied deps over the live defaults. */
58
+ function d(deps?: StrategyDeps): Required<StrategyDeps> {
59
+ const base = defaults();
60
+ if (!deps) return base;
61
+ return {
62
+ exists: deps.exists ?? base.exists,
63
+ which: deps.which ?? base.which,
64
+ npmRootGlobal: deps.npmRootGlobal ?? base.npmRootGlobal,
65
+ resolveModule: deps.resolveModule ?? base.resolveModule,
66
+ };
67
+ }
68
+
69
+ // ── Strategies ──────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Look up a registered path override by tool name. Existence is checked
73
+ * here so invalid overrides fall through with reason `invalid: <...>`
74
+ * without requiring callers to wire a separate validator.
75
+ */
76
+ export function overrideStrategy(toolName: string, deps?: StrategyDeps): Strategy {
77
+ const { exists } = d(deps);
78
+ return {
79
+ name: "override",
80
+ run(ctx): StrategyResult {
81
+ const p = ctx.overrides[toolName];
82
+ if (!p) return { ok: false, reason: "no override set" };
83
+ if (!exists(p)) return { ok: false, reason: `invalid: path does not exist: ${p}` };
84
+ return { ok: true, path: p };
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Managed install: `~/.pi-dashboard/node_modules/.bin/<name>(.cmd)` for
91
+ * binaries, or any explicit relative path under `MANAGED_DIR` for
92
+ * modules/directories.
93
+ */
94
+ export function managedBinStrategy(
95
+ binaryName: string,
96
+ deps?: StrategyDeps,
97
+ ): Strategy {
98
+ const { exists } = d(deps);
99
+ return {
100
+ name: "managed",
101
+ run(ctx): StrategyResult {
102
+ const ext = ctx.platform === "win32" ? ".cmd" : "";
103
+ const candidate = path.join(getManagedBin(ctx.env), binaryName + ext);
104
+ if (exists(candidate)) return { ok: true, path: candidate };
105
+ return { ok: false, reason: `missing: ${candidate}` };
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Managed module entry: `~/.pi-dashboard/node_modules/<pkg>/dist/index.js`
112
+ * (or a caller-specified relative entry).
113
+ */
114
+ export function managedModuleStrategy(
115
+ pkgName: string,
116
+ entryRelative: string = path.join("dist", "index.js"),
117
+ deps?: StrategyDeps,
118
+ ): Strategy {
119
+ const { exists } = d(deps);
120
+ return {
121
+ name: "managed",
122
+ run(ctx: StrategyCtx): StrategyResult {
123
+ const candidate = path.join(getManagedDir(ctx.env), "node_modules", pkgName, entryRelative);
124
+ if (exists(candidate)) return { ok: true, path: candidate };
125
+ return { ok: false, reason: `missing: ${candidate}` };
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Global npm install: `<npm root -g>/<pkg>/<entry>`. Falls back to
132
+ * `{ ok: false }` when `npm root -g` fails or the file is absent.
133
+ */
134
+ export function npmGlobalStrategy(
135
+ pkgName: string,
136
+ entryRelative: string = path.join("dist", "index.js"),
137
+ deps?: StrategyDeps,
138
+ ): Strategy {
139
+ const { exists, npmRootGlobal } = d(deps);
140
+ return {
141
+ name: "npm-global",
142
+ run(): StrategyResult {
143
+ const root = npmRootGlobal();
144
+ if (!root) return { ok: false, reason: "npm root -g failed" };
145
+ const candidate = path.join(root, pkgName, entryRelative);
146
+ if (exists(candidate)) return { ok: true, path: candidate };
147
+ return { ok: false, reason: `missing: ${candidate}` };
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * PATH search via `ToolResolver.which()`. This is the plain-old "is it
154
+ * on PATH" strategy and should appear last in most chains.
155
+ */
156
+ export function whereStrategy(binaryName: string, deps?: StrategyDeps): Strategy {
157
+ const { which } = d(deps);
158
+ return {
159
+ name: "where",
160
+ run(): StrategyResult {
161
+ const p = which(binaryName);
162
+ if (p) return { ok: true, path: p };
163
+ return { ok: false, reason: `not found on PATH` };
164
+ },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Bare `import("<pkg>")` — succeeds when the package is reachable from
170
+ * the caller's node_modules tree. We probe synchronously via
171
+ * `createRequire(import.meta.url).resolve(pkgName)`, which follows the
172
+ * same module-resolution algorithm as `import()` but returns a path.
173
+ *
174
+ * The returned path is the resolved entry file; `resolveModule()` then
175
+ * dynamically imports it via `pathToFileURL`. This keeps strategies
176
+ * uniformly sync and keeps the diagnostic trail honest (if the package
177
+ * isn't resolvable, we record the reason here instead of letting it
178
+ * surface as an opaque `import()` throw later).
179
+ *
180
+ * `anchor` determines which node_modules tree we search. Default is
181
+ * this file's URL (i.e. the shared package) — which is typically what
182
+ * callers want: "is pi a dependency of the dashboard?"
183
+ */
184
+ export function bareImportStrategy(
185
+ pkgName: string,
186
+ anchor: string = import.meta.url,
187
+ deps?: StrategyDeps,
188
+ ): Strategy {
189
+ const { resolveModule } = d(deps);
190
+ return {
191
+ name: "bare-import",
192
+ run(): StrategyResult {
193
+ const resolved = resolveModule(pkgName, anchor);
194
+ if (!resolved) return { ok: false, reason: `cannot resolve ${pkgName} from ${anchor}` };
195
+ return { ok: true, path: resolved };
196
+ },
197
+ };
198
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Public type contract for the tool registry.
3
+ *
4
+ * The registry resolves every external binary, module, or directory the
5
+ * dashboard depends on through an ordered list of strategies. Each
6
+ * resolution records a diagnostic trail (what was tried + why it
7
+ * succeeded/failed) alongside the winning path.
8
+ *
9
+ * See change: consolidate-tool-resolution.
10
+ */
11
+
12
+ /** What kind of artifact a tool definition resolves. */
13
+ export type ToolKind = "binary" | "module" | "directory" | "executor";
14
+
15
+ /**
16
+ * How a resolved path was obtained. Strategy name → source mapping is
17
+ * declared by each tool definition's `classify()` (see registry).
18
+ */
19
+ export type Source =
20
+ | "override"
21
+ | "managed"
22
+ | "system"
23
+ | "npm-global"
24
+ | "bare-import";
25
+
26
+ /** Result returned by a single strategy attempt. */
27
+ export type StrategyResult =
28
+ | { ok: true; path: string }
29
+ | { ok: false; reason: string };
30
+
31
+ /** One attempt recorded on a Resolution's `tried[]` list. */
32
+ export interface TriedEntry {
33
+ /** Strategy name, e.g. "override", "managed", "npm-global". */
34
+ strategy: string;
35
+ /** "ok" on success, the strategy's failure reason on miss. */
36
+ result: "ok" | string;
37
+ }
38
+
39
+ /** Output of `ToolRegistry.resolve(name)`. */
40
+ export interface Resolution {
41
+ /** Tool name as registered. */
42
+ name: string;
43
+ /** True if any strategy produced a valid path. */
44
+ ok: boolean;
45
+ /** Absolute path (binary / module entry / directory). Null on failure. */
46
+ path: string | null;
47
+ /** Source classification of the winning strategy. Null on failure. */
48
+ source: Source | null;
49
+ /** Ordered diagnostic trail — one entry per attempted strategy. */
50
+ tried: TriedEntry[];
51
+ /** Epoch ms when resolution completed. */
52
+ resolvedAt: number;
53
+ }
54
+
55
+ /**
56
+ * Output of `ToolRegistry.resolveExecutor(name)` — extends Resolution
57
+ * with `argv`, the ready-to-spawn command array.
58
+ *
59
+ * Callers spawn via `spawn(argv[0], [...argv.slice(1), ...userArgs])`.
60
+ * For simple binaries `argv = [path]`; for scripts that need an
61
+ * interpreter (e.g. pi's `node dist/cli.js` on Windows) `argv =
62
+ * [interpreter, scriptPath]`.
63
+ *
64
+ * See change: consolidate-windows-spawn-and-platform-handlers.
65
+ */
66
+ export interface ExecutorResolution extends Resolution {
67
+ argv: string[];
68
+ }
69
+
70
+ /** Context passed to every strategy function. */
71
+ export interface StrategyCtx {
72
+ /** Per-registry override map, { [toolName]: absolutePath }. */
73
+ overrides: Readonly<Record<string, string>>;
74
+ /** Platform discriminator (injectable for tests). */
75
+ platform: NodeJS.Platform;
76
+ /**
77
+ * Environment overrides used by HOME-sensitive strategies (managed/*,
78
+ * npm-global under APPDATA on win32, etc.). Production registries
79
+ * populate from `os.homedir()` + `process.cwd()`; tests inject fakes
80
+ * so the harness can reason about alternate HOME directories without
81
+ * mutating globals.
82
+ */
83
+ env?: {
84
+ homedir?: string;
85
+ cwd?: string;
86
+ };
87
+ }
88
+
89
+ /** A single resolution strategy. Pure function of its ctx + the tool's data. */
90
+ export interface Strategy {
91
+ /** Name recorded in `tried[]`. */
92
+ name: string;
93
+ /** Attempt resolution. Never throws — signal failure via { ok: false }. */
94
+ run(ctx: StrategyCtx): StrategyResult;
95
+ }
96
+
97
+ /**
98
+ * Transform a successfully-resolved path into an argv ready to pass
99
+ * to `spawn(argv[0], argv.slice(1))`.
100
+ *
101
+ * Default behaviour (when a definition omits `toArgv`): `argv = [path]`.
102
+ * Scripts that need an interpreter (e.g. pi's `cli.js` on Windows)
103
+ * return `[interpreterPath, scriptPath]`.
104
+ *
105
+ * The function may call `registry.resolve(...)` to look up peer tools
106
+ * (e.g. `node`). It MUST NOT throw — return the path-only fallback
107
+ * `[path]` if a peer tool is missing.
108
+ */
109
+ export type ToArgvFn = (resolvedPath: string, ctx: { platform: NodeJS.Platform; registry: ToolRegistryLike }) => string[];
110
+
111
+ /** Minimal interface used by `toArgv` functions to look up peer tools. */
112
+ export interface ToolRegistryLike {
113
+ resolve(name: string): Resolution;
114
+ }
115
+
116
+ /** Declarative tool registration. */
117
+ export interface ToolDefinition {
118
+ /** Registry key — unique within the registry. */
119
+ name: string;
120
+ /** Kind drives how `resolveModule()` / path validation behaves. */
121
+ kind: ToolKind;
122
+ /**
123
+ * Ordered strategies used when no platform-specific override exists
124
+ * for the current OS. First successful strategy wins.
125
+ */
126
+ strategies: Strategy[];
127
+ /**
128
+ * Optional per-platform strategy override. Present key wins over
129
+ * `strategies`. Absent key falls back to `strategies`.
130
+ *
131
+ * Use this when the chain itself is OS-dependent — e.g. pi on
132
+ * Windows looks for a JS entry (dist/cli.js via module strategies),
133
+ * while on Unix it looks for a `pi` binary on PATH.
134
+ */
135
+ platformStrategies?: Partial<Record<NodeJS.Platform, Strategy[]>>;
136
+ /**
137
+ * Optional transform from resolved path → ready-to-spawn argv.
138
+ * Used by `resolveExecutor(name)`. Omitted → argv defaults to `[path]`.
139
+ */
140
+ toArgv?: ToArgvFn;
141
+ /**
142
+ * Map a winning strategy name → Source. Unknown strategy names fall
143
+ * back to the default ("system"). Definitions usually set this so
144
+ * that e.g. "managed" → "managed", "bare-import" → "bare-import".
145
+ */
146
+ classify?(strategyName: string): Source;
147
+ /**
148
+ * Optional post-resolution validation (e.g. "dist/index.js exists").
149
+ * If provided and returns a reason, the strategy is demoted to a
150
+ * failure carrying that reason, and the next strategy is tried.
151
+ */
152
+ validate?(resolvedPath: string): { ok: true } | { ok: false; reason: string };
153
+ }
154
+
155
+ // ── Errors ──────────────────────────────────────────────────────────────────
156
+
157
+ /** Thrown by `resolve()` / `resolveModule()` for unregistered names. */
158
+ export class UnknownToolError extends Error {
159
+ public readonly tool: string;
160
+ constructor(tool: string) {
161
+ super(`Unknown tool: ${tool}`);
162
+ this.name = "UnknownToolError";
163
+ this.tool = tool;
164
+ }
165
+ }
166
+
167
+ /** Thrown by `resolveModule()` when every strategy fails. */
168
+ export class ModuleResolutionError extends Error {
169
+ public readonly resolution: Resolution;
170
+ constructor(resolution: Resolution) {
171
+ const trail = resolution.tried
172
+ .map((t) => ` - ${t.strategy}: ${t.result}`)
173
+ .join("\n");
174
+ super(
175
+ `Could not resolve module "${resolution.name}". Tried:\n${trail}`,
176
+ );
177
+ this.name = "ModuleResolutionError";
178
+ this.resolution = resolution;
179
+ }
180
+ }
@@ -140,6 +140,13 @@ export interface OpenSpecChange {
140
140
  completedTasks: number;
141
141
  totalTasks: number;
142
142
  artifacts: OpenSpecArtifact[];
143
+ /**
144
+ * Artifact-authoring completeness reported by `openspec status --change <name> --json`.
145
+ * `true` when all required artifacts for the change's workflow are present/done.
146
+ * Orthogonal to task-tally completeness; used by the dashboard to surface an
147
+ * "Archive anyway" escape hatch when artifacts are authored but tasks remain unchecked.
148
+ */
149
+ isComplete?: boolean;
143
150
  }
144
151
 
145
152
  /** Lifecycle state of an OpenSpec change, derived from artifacts + task status */