@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,234 @@
1
+ /**
2
+ * Provider probe — ping a custom LLM provider's base URL + API key to verify the
3
+ * combination is reachable and authenticated. Used by `POST /api/providers/test`
4
+ * (client Test button) and re-used by the bridge's startup discovery path via
5
+ * the same per-API request builders.
6
+ *
7
+ * Pure helpers first (`buildProbeRequest`, `resolveProbeApiKey`), then the
8
+ * I/O-bearing `probeProvider`. All responses are scrubbed to never echo the
9
+ * resolved api key.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "providers.json");
17
+ const REDACTED = "***";
18
+ const DEFAULT_TIMEOUT_MS = 8000;
19
+ const MAX_ERROR_BODY_CHARS = 500;
20
+ const SAMPLE_LIMIT = 5;
21
+
22
+ // -- Types ----------------------------------------------------------------
23
+
24
+ export type ProbeApi =
25
+ | "openai-completions"
26
+ | "openai-responses"
27
+ | "anthropic-messages"
28
+ | "google-generative-ai";
29
+
30
+ export interface ProbeInput {
31
+ baseUrl: string;
32
+ apiKey: string;
33
+ api: ProbeApi;
34
+ timeoutMs?: number;
35
+ }
36
+
37
+ export interface ProbeRequest {
38
+ url: string;
39
+ headers: Record<string, string>;
40
+ }
41
+
42
+ export type ProbeResult =
43
+ | { ok: true; status: number; modelCount: number; sample: string[] }
44
+ | { ok: false; status?: number; error: string };
45
+
46
+ interface StoredProviderEntry {
47
+ baseUrl: string;
48
+ apiKey: string;
49
+ api?: string;
50
+ }
51
+
52
+ // -- Pure: build per-API-type probe request --------------------------------
53
+
54
+ function stripTrailingSlash(url: string): string {
55
+ return url.endsWith("/") ? url.slice(0, -1) : url;
56
+ }
57
+
58
+ export function buildProbeRequest(input: {
59
+ baseUrl: string;
60
+ apiKey: string;
61
+ api: ProbeApi;
62
+ }): ProbeRequest {
63
+ const base = stripTrailingSlash(input.baseUrl);
64
+ switch (input.api) {
65
+ case "openai-completions":
66
+ case "openai-responses":
67
+ return {
68
+ url: `${base}/models`,
69
+ headers: {
70
+ Authorization: `Bearer ${input.apiKey}`,
71
+ "Content-Type": "application/json",
72
+ },
73
+ };
74
+ case "anthropic-messages":
75
+ return {
76
+ url: `${base}/v1/models`,
77
+ headers: {
78
+ "x-api-key": input.apiKey,
79
+ "anthropic-version": "2023-06-01",
80
+ "Content-Type": "application/json",
81
+ },
82
+ };
83
+ case "google-generative-ai":
84
+ return {
85
+ url: `${base}/models?key=${encodeURIComponent(input.apiKey)}`,
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ },
89
+ };
90
+ default:
91
+ throw new Error(`Unsupported api type: ${String(input.api)}`);
92
+ }
93
+ }
94
+
95
+ // -- Pure: resolve an apiKey value (literal / $ENV / *** REDACTED) --------
96
+
97
+ export type ProvidersReader = () => Record<string, StoredProviderEntry>;
98
+
99
+ export function readProvidersFromDisk(): Record<string, StoredProviderEntry> {
100
+ if (!existsSync(CONFIG_PATH)) return {};
101
+ try {
102
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
103
+ return raw.providers ?? {};
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ export type ResolveResult =
110
+ | { ok: true; key: string }
111
+ | { ok: false; error: string };
112
+
113
+ export function resolveProbeApiKey(args: {
114
+ apiKey: string;
115
+ name?: string;
116
+ readProviders: ProvidersReader;
117
+ }): ResolveResult {
118
+ let raw = args.apiKey;
119
+
120
+ if (!raw) {
121
+ return { ok: false, error: "apiKey is required" };
122
+ }
123
+
124
+ // REDACTED sentinel: look up the real key in providers.json by name
125
+ if (raw === REDACTED) {
126
+ if (!args.name) {
127
+ return { ok: false, error: "No provider name given for saved API key lookup" };
128
+ }
129
+ const providers = args.readProviders();
130
+ const entry = providers[args.name];
131
+ if (!entry) {
132
+ return { ok: false, error: `No saved API key for provider "${args.name}"` };
133
+ }
134
+ raw = entry.apiKey;
135
+ if (!raw) {
136
+ return { ok: false, error: `Stored API key for "${args.name}" is empty` };
137
+ }
138
+ }
139
+
140
+ // $ENV_VAR indirection
141
+ if (raw.startsWith("$")) {
142
+ const envName = raw.slice(1);
143
+ const value = process.env[envName];
144
+ if (!value) {
145
+ return { ok: false, error: `Environment variable ${envName} is not set` };
146
+ }
147
+ return { ok: true, key: value };
148
+ }
149
+
150
+ return { ok: true, key: raw };
151
+ }
152
+
153
+ // -- Helpers --------------------------------------------------------------
154
+
155
+ function redactErrorText(text: string, apiKey: string): string {
156
+ // Belt-and-braces: never let the resolved api key leak back to the caller.
157
+ let out = text;
158
+ if (apiKey && out.includes(apiKey)) {
159
+ out = out.split(apiKey).join("[REDACTED]");
160
+ }
161
+ return out.length > MAX_ERROR_BODY_CHARS ? out.slice(0, MAX_ERROR_BODY_CHARS) : out;
162
+ }
163
+
164
+ function extractModelIds(body: any): string[] {
165
+ // OpenAI-style { data: [{ id }, ...] }
166
+ if (body && Array.isArray(body.data)) {
167
+ return body.data
168
+ .filter((m: any) => m && typeof m.id === "string")
169
+ .map((m: any) => m.id as string);
170
+ }
171
+ // Google-style { models: [{ name: "models/gemini-..." }] }
172
+ if (body && Array.isArray(body.models)) {
173
+ return body.models
174
+ .filter((m: any) => m && typeof m.name === "string")
175
+ .map((m: any) => (m.name as string).replace(/^models\//, ""));
176
+ }
177
+ return [];
178
+ }
179
+
180
+ // -- I/O: probe ----------------------------------------------------------
181
+
182
+ export async function probeProvider(input: ProbeInput): Promise<ProbeResult> {
183
+ let req: ProbeRequest;
184
+ try {
185
+ req = buildProbeRequest(input);
186
+ } catch (err: any) {
187
+ return { ok: false, error: err?.message ?? String(err) };
188
+ }
189
+
190
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
191
+ const controller = new AbortController();
192
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
193
+
194
+ try {
195
+ const response = await fetch(req.url, {
196
+ method: "GET",
197
+ headers: req.headers,
198
+ signal: controller.signal,
199
+ });
200
+ clearTimeout(timer);
201
+
202
+ if (!response.ok) {
203
+ let bodyText = "";
204
+ try {
205
+ bodyText = await response.text();
206
+ } catch {
207
+ bodyText = "";
208
+ }
209
+ const excerpt = redactErrorText(
210
+ bodyText || response.statusText || `HTTP ${response.status}`,
211
+ input.apiKey,
212
+ );
213
+ return { ok: false, status: response.status, error: excerpt };
214
+ }
215
+
216
+ let body: any = null;
217
+ try {
218
+ body = await response.json();
219
+ } catch {
220
+ body = null;
221
+ }
222
+ const ids = extractModelIds(body);
223
+ return {
224
+ ok: true,
225
+ status: response.status,
226
+ modelCount: ids.length,
227
+ sample: ids.slice(0, SAMPLE_LIMIT),
228
+ };
229
+ } catch (err: any) {
230
+ clearTimeout(timer);
231
+ const message = err?.message ?? String(err);
232
+ return { ok: false, error: redactErrorText(message, input.apiKey) };
233
+ }
234
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Cross-platform restart helper for POST /api/restart.
3
+ *
4
+ * Replaces the previous `sh -c` script that depended on `lsof` and `curl` —
5
+ * neither of which exists on Windows. The new implementation spawns a
6
+ * detached plain-Node orchestrator (via `node -e`) that:
7
+ * 1. Polls the port via net.createConnection until free
8
+ * 2. Spawns the new server with the same loader + args as the current run
9
+ * 3. Polls /api/health via http.get until it returns ok
10
+ * 4. On failure, appends a line to ~/.pi/dashboard/restart.log
11
+ *
12
+ * See change: fix-windows-server-parity.
13
+ */
14
+ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ export interface RestartParams {
19
+ /** Absolute path to the server CLI (typically process.argv[1]) */
20
+ cliPath: string;
21
+ /** Loader value from --import (e.g. file:// URL). Empty string = none. */
22
+ loader: string;
23
+ /** Port the server listens on */
24
+ port: number;
25
+ /** Extra args to pass to `cli start` (e.g. ["--dev"]) */
26
+ extraArgs: string[];
27
+ /** Override Node binary (defaults to process.execPath) */
28
+ execPath?: string;
29
+ }
30
+
31
+ /**
32
+ * Build the JS source (to run via `node -e`) that performs the restart
33
+ * orchestration. Exported for testing. Pure function — no I/O.
34
+ */
35
+ export function buildOrchestratorScript(params: RestartParams): string {
36
+ const execPath = params.execPath ?? process.execPath;
37
+ const logPath = path.join(os.homedir(), ".pi", "dashboard", "restart.log");
38
+ const spawnArgs: string[] = [];
39
+ if (params.loader) {
40
+ spawnArgs.push("--import", params.loader);
41
+ }
42
+ spawnArgs.push(params.cliPath, "start", ...params.extraArgs);
43
+
44
+ // The script runs in a fresh Node process. Keep it self-contained and use
45
+ // only built-ins (net, http, fs, child_process). JSON.stringify is used to
46
+ // embed strings safely (handles quotes, backslashes, Windows paths).
47
+ return `
48
+ const net = require("node:net");
49
+ const http = require("node:http");
50
+ const { spawn } = require("node:child_process"); // ban:child_process-ok — runs in a detached 'node -e' process, not in-host
51
+ const fs = require("node:fs");
52
+ const path = require("node:path");
53
+
54
+ const PORT = ${params.port};
55
+ const EXEC = ${JSON.stringify(execPath)};
56
+ const ARGS = ${JSON.stringify(spawnArgs)};
57
+ const LOG_PATH = ${JSON.stringify(logPath)};
58
+
59
+ function log(msg) {
60
+ try {
61
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
62
+ fs.appendFileSync(LOG_PATH, "[" + new Date().toISOString() + "] " + msg + "\\n");
63
+ } catch (_) { /* ignore */ }
64
+ }
65
+
66
+ function portFree(port) {
67
+ return new Promise(resolve => {
68
+ const sock = net.createConnection({ port, host: "127.0.0.1" });
69
+ let done = false;
70
+ const finish = (free) => { if (done) return; done = true; try { sock.destroy(); } catch(_){} resolve(free); };
71
+ sock.setTimeout(500);
72
+ sock.once("connect", () => finish(false));
73
+ sock.once("error", () => finish(true));
74
+ sock.once("timeout", () => finish(true));
75
+ });
76
+ }
77
+
78
+ function healthOk() {
79
+ return new Promise(resolve => {
80
+ const req = http.get({ host: "127.0.0.1", port: PORT, path: "/api/health", timeout: 1000 }, res => {
81
+ resolve(res.statusCode === 200);
82
+ res.resume();
83
+ });
84
+ req.once("error", () => resolve(false));
85
+ req.once("timeout", () => { req.destroy(); resolve(false); });
86
+ });
87
+ }
88
+
89
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
90
+
91
+ (async () => {
92
+ // 1. Wait for port to be free (up to 10s)
93
+ for (let i = 0; i < 20; i++) {
94
+ if (await portFree(PORT)) break;
95
+ await sleep(500);
96
+ }
97
+
98
+ // 2. Spawn new server
99
+ const child = spawn(EXEC, ARGS, { detached: true, stdio: "ignore", env: process.env });
100
+ child.unref();
101
+
102
+ // 3. Poll health (up to 10s)
103
+ for (let i = 0; i < 20; i++) {
104
+ await sleep(500);
105
+ if (await healthOk()) {
106
+ process.exit(0);
107
+ }
108
+ }
109
+
110
+ log("restart failed: new server did not respond to /api/health within 10s");
111
+ process.exit(1);
112
+ })();
113
+ `;
114
+ }
115
+
116
+ /**
117
+ * Spawn a detached orchestrator child that restarts the server.
118
+ * Returns immediately (the caller is expected to exit shortly after).
119
+ */
120
+ export function spawnRestart(params: RestartParams): void {
121
+ const script = buildOrchestratorScript(params);
122
+ const execPath = params.execPath ?? process.execPath;
123
+ const child = spawn(execPath, ["-e", script], {
124
+ detached: true,
125
+ stdio: "ignore",
126
+ env: { ...process.env },
127
+ windowsHide: true,
128
+ });
129
+ child.unref();
130
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Bootstrap REST API routes: `/api/bootstrap/status`, `/api/bootstrap/upgrade-pi`,
3
+ * `/api/bootstrap/retry`.
4
+ *
5
+ * The routes are thin — they read/write the injected `BootstrapStateStore`
6
+ * and delegate actual install work to the supplied `trigger` callbacks.
7
+ * Keeping triggers as callbacks lets the CLI wire them to `bootstrapInstall`
8
+ * while tests wire them to mocks.
9
+ *
10
+ * See change: unified-bootstrap-install.
11
+ */
12
+ import type { FastifyInstance } from "fastify";
13
+ import { randomUUID } from "node:crypto";
14
+ import type { BootstrapStateStore } from "../bootstrap-state.js";
15
+ import type { NetworkGuard } from "./route-deps.js";
16
+
17
+ export interface BootstrapRouteDeps {
18
+ bootstrapState: BootstrapStateStore;
19
+ networkGuard: NetworkGuard;
20
+ /**
21
+ * Trigger a pi upgrade. Called when `POST /api/bootstrap/upgrade-pi`
22
+ * succeeds the 409-gate. Implementation is responsible for setting
23
+ * state to "installing" before returning, and to "ready"/"failed"
24
+ * when complete. Must NOT throw synchronously.
25
+ */
26
+ triggerUpgradePi: (ticketId: string) => Promise<void>;
27
+ /**
28
+ * Trigger a retry of the last bootstrap install. Called when
29
+ * `POST /api/bootstrap/retry` succeeds the 409-gate. Implementation
30
+ * should re-run the same install that failed and flip status back to
31
+ * "installing" before returning.
32
+ */
33
+ triggerRetry: (ticketId: string) => Promise<void>;
34
+ }
35
+
36
+ export function registerBootstrapRoutes(
37
+ fastify: FastifyInstance,
38
+ deps: BootstrapRouteDeps,
39
+ ): void {
40
+ const { bootstrapState, networkGuard, triggerUpgradePi, triggerRetry } = deps;
41
+
42
+ fastify.get(
43
+ "/api/bootstrap/status",
44
+ { preHandler: networkGuard },
45
+ async () => {
46
+ return bootstrapState.get();
47
+ },
48
+ );
49
+
50
+ fastify.post(
51
+ "/api/bootstrap/upgrade-pi",
52
+ { preHandler: networkGuard },
53
+ async (_request, reply) => {
54
+ const current = bootstrapState.get();
55
+ if (current.status === "installing") {
56
+ return reply.code(409).send({
57
+ error: "bootstrap is currently installing; try again when status becomes ready or failed",
58
+ status: current.status,
59
+ });
60
+ }
61
+ const ticketId = randomUUID();
62
+ // Fire-and-forget. Errors flow through state.
63
+ void triggerUpgradePi(ticketId).catch((err) => {
64
+ console.error("[bootstrap-routes] upgrade-pi trigger failed:", err);
65
+ });
66
+ return reply.code(202).send({ ticketId, status: "accepted" });
67
+ },
68
+ );
69
+
70
+ fastify.post(
71
+ "/api/bootstrap/retry",
72
+ { preHandler: networkGuard },
73
+ async (_request, reply) => {
74
+ const current = bootstrapState.get();
75
+ if (current.status !== "failed") {
76
+ return reply.code(409).send({
77
+ error: "retry is only valid when status is failed",
78
+ status: current.status,
79
+ });
80
+ }
81
+ const ticketId = randomUUID();
82
+ void triggerRetry(ticketId).catch((err) => {
83
+ console.error("[bootstrap-routes] retry trigger failed:", err);
84
+ });
85
+ return reply.code(202).send({ ticketId, status: "accepted" });
86
+ },
87
+ );
88
+ }
@@ -30,9 +30,16 @@ export function registerOpenSpecRoutes(
30
30
  networkGuard: NetworkGuard;
31
31
  /** Optional — called after a successful toggle to trigger openspec_update. */
32
32
  onOpenSpecChanged?: OpenSpecBroadcaster;
33
+ /**
34
+ * Optional bootstrap state. When provided AND status !== "ready", the
35
+ * `/api/pi-resources` endpoint returns an empty result set with a
36
+ * `bootstrap` passthrough so the UI can render "pi not yet installed".
37
+ * See change: unified-bootstrap-install §5.4.
38
+ */
39
+ bootstrapState?: import("../bootstrap-state.js").BootstrapStateStore;
33
40
  },
34
41
  ) {
35
- const { sessionManager, preferencesStore, directoryService, networkGuard, onOpenSpecChanged } = deps;
42
+ const { sessionManager, preferencesStore, directoryService, networkGuard, onOpenSpecChanged, bootstrapState } = deps;
36
43
 
37
44
  // OpenSpec archive listing endpoint
38
45
  fastify.get<{ Querystring: { cwd?: string } }>(
@@ -59,6 +66,23 @@ export function registerOpenSpecRoutes(
59
66
  reply.code(400);
60
67
  return { success: false, error: "cwd parameter required" } satisfies ApiResponse;
61
68
  }
69
+ // Bootstrap gate: during degraded-mode install, return empty result
70
+ // with a `bootstrap` field so the UI can render the "pi not yet
71
+ // installed" state. See change: unified-bootstrap-install §5.4.
72
+ if (bootstrapState) {
73
+ const bs = bootstrapState.get();
74
+ if (bs.status !== "ready") {
75
+ return {
76
+ success: true,
77
+ data: {
78
+ local: { extensions: [], skills: [], prompts: [] },
79
+ global: { extensions: [], skills: [], prompts: [] },
80
+ packages: [],
81
+ bootstrap: bs,
82
+ },
83
+ } satisfies ApiResponse;
84
+ }
85
+ }
62
86
  const forceRefresh = request.query.refresh === "true" || request.query.refresh === "1";
63
87
  let data = forceRefresh ? undefined : directoryService.getPiResources(cwd);
64
88
  if (!data) {
@@ -18,10 +18,16 @@ import type {
18
18
  import type { PiCoreChecker } from "../pi-core-checker.js";
19
19
  import type { PiCoreUpdater } from "../pi-core-updater.js";
20
20
  import { PackageOperationBusyError } from "../package-manager-wrapper.js";
21
+ import type { BootstrapStateStore } from "../bootstrap-state.js";
21
22
 
22
23
  export interface PiCoreRouteDeps {
23
24
  piCoreChecker: PiCoreChecker;
24
25
  piCoreUpdater: PiCoreUpdater;
26
+ /**
27
+ * When provided, pi-core endpoints return 503 unless bootstrap
28
+ * status is "ready". See change: unified-bootstrap-install §5.5.
29
+ */
30
+ bootstrapState?: BootstrapStateStore;
25
31
  /**
26
32
  * Called after the updater finishes a batch (success or per-package failure).
27
33
  * The server wires this to broadcast a `pi_core_update_complete` WS message
@@ -38,12 +44,28 @@ export function registerPiCoreRoutes(
38
44
  fastify: FastifyInstance,
39
45
  deps: PiCoreRouteDeps,
40
46
  ): void {
41
- const { piCoreChecker, piCoreUpdater } = deps;
47
+ const { piCoreChecker, piCoreUpdater, bootstrapState } = deps;
48
+
49
+ /** Gate: 503 unless bootstrap is ready. Returns undefined when OK to proceed. */
50
+ const bootstrapGate = async (
51
+ _req: unknown,
52
+ reply: { code: (n: number) => { send: (body: unknown) => unknown } },
53
+ ): Promise<unknown> => {
54
+ if (!bootstrapState) return undefined;
55
+ const status = bootstrapState.get().status;
56
+ if (status === "ready") return undefined;
57
+ return reply.code(503).send({
58
+ success: false,
59
+ error: `pi not yet installed (bootstrap status: ${status})`,
60
+ bootstrap: status,
61
+ });
62
+ };
42
63
 
43
64
  // ── GET /api/pi-core/versions ──────────────────────────────────
44
65
 
45
66
  fastify.get<{ Querystring: { refresh?: string } }>(
46
67
  "/api/pi-core/versions",
68
+ { preHandler: bootstrapGate as any },
47
69
  async (request) => {
48
70
  const refresh = request.query.refresh === "true";
49
71
  try {
@@ -59,6 +81,7 @@ export function registerPiCoreRoutes(
59
81
 
60
82
  fastify.post<{ Body: PiCoreUpdateRequest }>(
61
83
  "/api/pi-core/update",
84
+ { preHandler: bootstrapGate as any },
62
85
  async (request, reply) => {
63
86
  const requested = request.body?.packages ?? [];
64
87
 
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * REST routes for browser-based pi provider authentication.
3
3
  */
4
- import { exec } from "node:child_process";
5
4
  import type { FastifyInstance } from "fastify";
6
5
  import {
7
6
  getProviderHandler,
@@ -66,15 +65,16 @@ function makeFlowId(): string {
66
65
 
67
66
  // ── Helpers ──────────────────────────────────────────────────────────────────
68
67
 
68
+ // Delegate to the shared platform primitive. The cross-OS dispatch
69
+ // (open/start/xdg-open) and URL escaping live in
70
+ // `packages/shared/src/platform/commands.ts`.
71
+ // See change: consolidate-platform-handlers.
72
+ import { openBrowser as platformOpenBrowser } from "@blackbelt-technology/pi-dashboard-shared/platform/commands.js";
73
+
69
74
  /** Open a URL in the system's default browser */
70
75
  function openInBrowser(url: string): void {
71
- const cmd = process.platform === "darwin"
72
- ? `open ${JSON.stringify(url)}`
73
- : process.platform === "win32"
74
- ? `start "" ${JSON.stringify(url)}`
75
- : `xdg-open ${JSON.stringify(url)}`;
76
- exec(cmd, (err) => {
77
- if (err) console.error("[provider-auth] Failed to open browser:", err.message);
76
+ platformOpenBrowser(url, {
77
+ onError: (err) => console.error("[provider-auth] Failed to open browser:", err.message),
78
78
  });
79
79
  }
80
80
 
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
8
8
  import type { NetworkGuard } from "./route-deps.js";
9
9
  import type { PiGateway } from "../pi-gateway.js";
10
10
  import type { BrowserGateway } from "../browser-gateway.js";
11
+ import { probeProvider, resolveProbeApiKey, type ProbeApi } from "../provider-probe.js";
11
12
 
12
13
  const REDACTED = "***";
13
14
  const CONFIG_PATH = join(homedir(), ".pi", "agent", "providers.json");
@@ -108,4 +109,46 @@ export function registerProviderRoutes(fastify: FastifyInstance, deps: { network
108
109
  return { success: true };
109
110
  },
110
111
  );
112
+
113
+ // Test a provider configuration without saving it. Accepts literal api keys,
114
+ // $ENV_VAR references, or the REDACTED sentinel (***) for already-saved entries.
115
+ fastify.post(
116
+ "/api/providers/test",
117
+ { preHandler: networkGuard },
118
+ async (request, reply) => {
119
+ const body = request.body as Record<string, any> | null;
120
+ if (!body || typeof body !== "object") {
121
+ return reply.code(400).send({ ok: false, error: "Invalid body" });
122
+ }
123
+ const name = typeof body.name === "string" ? body.name : undefined;
124
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
125
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey : "";
126
+ const api = typeof body.api === "string" ? (body.api as ProbeApi) : undefined;
127
+ if (!baseUrl) {
128
+ return reply.code(400).send({ ok: false, error: "baseUrl is required" });
129
+ }
130
+ if (!apiKey) {
131
+ return reply.code(400).send({ ok: false, error: "apiKey is required" });
132
+ }
133
+ if (!api) {
134
+ return reply.code(400).send({ ok: false, error: "api type is required" });
135
+ }
136
+
137
+ const resolved = resolveProbeApiKey({
138
+ apiKey,
139
+ name,
140
+ readProviders: readProvidersRaw,
141
+ });
142
+ if (!resolved.ok) {
143
+ return { ok: false, error: resolved.error };
144
+ }
145
+
146
+ const result = await probeProvider({
147
+ baseUrl,
148
+ apiKey: resolved.key,
149
+ api,
150
+ });
151
+ return result;
152
+ },
153
+ );
111
154
  }
@@ -195,18 +195,16 @@ export function registerRecommendedRoutes(
195
195
  }>;
196
196
  }
197
197
 
198
- let installedGlobal: Array<{ source: string; installedPath?: string }> = [];
199
- let installedLocal: Array<{ source: string; installedPath?: string }> = [];
200
- try {
201
- installedGlobal = (await deps.packageManagerWrapper.listInstalled("global")) as any[];
202
- } catch {
203
- /* proceed with empty */
204
- }
205
- try {
206
- installedLocal = (await deps.packageManagerWrapper.listInstalled("local")) as any[];
207
- } catch {
208
- /* proceed with empty */
209
- }
198
+ // Run global + local listInstalled in parallel to halve cold-start
199
+ // latency. On Windows where each call instantiates pi's
200
+ // DefaultPackageManager (1-3s cold), sequential awaits were making
201
+ // the "Loading recommended extensions" spinner stick for ~15s.
202
+ const [installedGlobalRes, installedLocalRes] = await Promise.allSettled([
203
+ deps.packageManagerWrapper.listInstalled("global"),
204
+ deps.packageManagerWrapper.listInstalled("local"),
205
+ ]);
206
+ const installedGlobal = (installedGlobalRes.status === "fulfilled" ? installedGlobalRes.value : []) as Array<{ source: string; installedPath?: string }>;
207
+ const installedLocal = (installedLocalRes.status === "fulfilled" ? installedLocalRes.value : []) as Array<{ source: string; installedPath?: string }>;
210
208
 
211
209
  // Include both global + project-local settings.json `packages[]`.
212
210
  // The server's CWD is a reasonable proxy for the active project.