@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
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Persistent per-tool path overrides at `~/.pi/dashboard/tool-overrides.json`.
3
+ *
4
+ * Schema:
5
+ * { "version": 1, "overrides": { "<toolName>": { "path": "<abs>" } } }
6
+ *
7
+ * Design notes (see change: consolidate-tool-resolution, design §5):
8
+ * - Separate from `config.json` — path overrides are machine-local and
9
+ * should NOT follow a user's dotfiles across machines.
10
+ * - Atomic write via the same tmp+rename pattern used by
11
+ * `server/src/json-store.ts` (duplicated here to keep `shared`
12
+ * self-contained; the two live in different packages).
13
+ * - Malformed files are treated as empty. No throw, no crash.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+
19
+ /** Path to the overrides file. Exposed for tests and the settings UI. */
20
+ export function defaultOverridesPath(): string {
21
+ return path.join(os.homedir(), ".pi", "dashboard", "tool-overrides.json");
22
+ }
23
+
24
+ /** Internal shape persisted to disk. `version` lets us evolve later. */
25
+ interface OverridesFile {
26
+ version: 1;
27
+ overrides: Record<string, { path: string }>;
28
+ }
29
+
30
+ export interface OverridesStoreDeps {
31
+ filePath?: string;
32
+ /** Logger hook (defaults to console.warn). Tests inject a sink. */
33
+ warn?(message: string): void;
34
+ }
35
+
36
+ /**
37
+ * Read-through + write-through in-memory store. One instance per registry.
38
+ * Keeps the disk read lazy — the file is only touched on first access.
39
+ */
40
+ export class OverridesStore {
41
+ private readonly filePath: string;
42
+ private readonly warn: (message: string) => void;
43
+ private cache: Record<string, string> | null = null;
44
+
45
+ constructor(deps: OverridesStoreDeps = {}) {
46
+ this.filePath = deps.filePath ?? defaultOverridesPath();
47
+ this.warn = deps.warn ?? ((m) => console.warn(`[tool-registry] ${m}`));
48
+ }
49
+
50
+ /** Snapshot of current overrides. Lazy-loads from disk on first call. */
51
+ list(): Readonly<Record<string, string>> {
52
+ if (this.cache === null) this.cache = this.load();
53
+ return this.cache;
54
+ }
55
+
56
+ /** Set one override + persist. */
57
+ set(name: string, overridePath: string): void {
58
+ const current = this.cache ?? this.load();
59
+ current[name] = overridePath;
60
+ this.cache = current;
61
+ this.persist(current);
62
+ }
63
+
64
+ /** Remove one override + persist. No-op if absent. */
65
+ clear(name: string): void {
66
+ const current = this.cache ?? this.load();
67
+ if (!(name in current)) return;
68
+ delete current[name];
69
+ this.cache = current;
70
+ this.persist(current);
71
+ }
72
+
73
+ /** Drop the in-memory cache; next `list()` re-reads the file. */
74
+ invalidate(): void {
75
+ this.cache = null;
76
+ }
77
+
78
+ // ── Internal ─────────────────────────────────────────────────────────
79
+
80
+ private load(): Record<string, string> {
81
+ try {
82
+ if (!fs.existsSync(this.filePath)) return {};
83
+ const raw = fs.readFileSync(this.filePath, "utf-8");
84
+ if (!raw.trim()) return {};
85
+ const parsed = JSON.parse(raw) as Partial<OverridesFile>;
86
+ if (!parsed || typeof parsed !== "object" || !parsed.overrides) {
87
+ this.warn(`malformed overrides file at ${this.filePath}; ignoring`);
88
+ return {};
89
+ }
90
+ const out: Record<string, string> = {};
91
+ for (const [name, entry] of Object.entries(parsed.overrides)) {
92
+ if (entry && typeof entry === "object" && typeof (entry as { path?: unknown }).path === "string") {
93
+ out[name] = (entry as { path: string }).path;
94
+ }
95
+ }
96
+ return out;
97
+ } catch (err) {
98
+ this.warn(
99
+ `failed to read overrides file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
100
+ );
101
+ return {};
102
+ }
103
+ }
104
+
105
+ private persist(overrides: Record<string, string>): void {
106
+ const dir = path.dirname(this.filePath);
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ const data: OverridesFile = {
109
+ version: 1,
110
+ overrides: Object.fromEntries(
111
+ Object.entries(overrides).map(([k, v]) => [k, { path: v }]),
112
+ ),
113
+ };
114
+ const tmpPath = this.filePath + ".tmp";
115
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
116
+ fs.renameSync(tmpPath, this.filePath);
117
+ }
118
+ }
@@ -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
+ }