@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -10,6 +10,9 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import os from "node:os";
13
+ import { createRequire } from "node:module";
14
+ const _require = createRequire(import.meta.url);
15
+ const _lockfile = _require("proper-lockfile") as typeof import("proper-lockfile");
13
16
  import type { ProviderAuthStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
14
17
  import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
15
18
  import { getAllHandlers, type ProviderHandler } from "./provider-auth-handlers.js";
@@ -19,10 +22,6 @@ import { getLatestCatalogue } from "./provider-catalogue-cache.js";
19
22
 
20
23
  const AUTH_DIR = path.join(os.homedir(), ".pi", "agent");
21
24
  const AUTH_PATH = path.join(AUTH_DIR, "auth.json");
22
- const LOCK_PATH = AUTH_PATH + ".lock";
23
- const LOCK_STALE_MS = 10_000;
24
- const LOCK_RETRY_MS = 50;
25
- const LOCK_MAX_RETRIES = 40; // 40 × 50ms = 2s max wait
26
25
 
27
26
  export type ApiKeyCredential = { type: "api_key"; key: string };
28
27
  export type OAuthCredential = { type: "oauth"; refresh: string; access: string; expires: number; [k: string]: unknown };
@@ -35,46 +34,35 @@ interface OAuthProviderMeta {
35
34
  flowType: "auth_code" | "device_code";
36
35
  }
37
36
 
38
- // ── Lock helpers ─────────────────────────────────────────────────────────────
37
+ // ── Lock helpers (proper-lockfile) ───────────────────────────────────────────
38
+ //
39
+ // Upgraded from mkdir-based lock to proper-lockfile to match pi-coding-agent's
40
+ // AuthStorage lock convention. See change: add-dashboard-model-proxy task 2.5.
39
41
 
40
- function acquireLock(): void {
41
- // Ensure parent directory exists (fresh install may not have ~/.pi/agent/)
42
+ /**
43
+ * Run `fn` while holding a proper-lockfile lock on auth.json.
44
+ * Ensures the file exists (lockfile requires the target to exist).
45
+ */
46
+ function withLock<T>(fn: () => T): T {
42
47
  fs.mkdirSync(AUTH_DIR, { recursive: true });
43
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
44
- try {
45
- fs.mkdirSync(LOCK_PATH, { recursive: false });
46
- return;
47
- } catch (err: any) {
48
- if (err.code === "EEXIST") {
49
- // Check for stale lock
50
- try {
51
- const stat = fs.statSync(LOCK_PATH);
52
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
53
- fs.rmdirSync(LOCK_PATH);
54
- continue;
55
- }
56
- } catch { /* stat failed, retry */ }
57
- // Wait and retry
58
- const waitMs = LOCK_RETRY_MS + Math.random() * LOCK_RETRY_MS;
59
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
60
- continue;
61
- }
62
- throw err;
63
- }
48
+ if (!fs.existsSync(AUTH_PATH)) {
49
+ // Create empty auth file so lockfile can lock it
50
+ try { fs.writeFileSync(AUTH_PATH, "{}\n", { flag: "wx" }); } catch { /* race-safe */ }
64
51
  }
65
- throw new Error("Failed to acquire auth.json lock after retries");
66
- }
67
52
 
68
- function releaseLock(): void {
69
- try { fs.rmdirSync(LOCK_PATH); } catch { /* ignore */ }
53
+ const release = _lockfile.lockSync(AUTH_PATH, {
54
+ stale: 10_000,
55
+ realpath: false,
56
+ });
57
+ try {
58
+ return fn();
59
+ } finally {
60
+ try { release(); } catch { /* ignore cleanup errors */ }
61
+ }
70
62
  }
71
63
 
72
64
  // ── File operations ──────────────────────────────────────────────────────────
73
65
 
74
- function ensureDir(): void {
75
- fs.mkdirSync(AUTH_DIR, { recursive: true });
76
- }
77
-
78
66
  export function readAuthJson(): AuthData {
79
67
  try {
80
68
  const raw = fs.readFileSync(AUTH_PATH, "utf-8");
@@ -86,7 +74,7 @@ export function readAuthJson(): AuthData {
86
74
  }
87
75
 
88
76
  function writeAuthJson(data: AuthData): void {
89
- ensureDir();
77
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
90
78
  const tmp = AUTH_PATH + ".tmp";
91
79
  const content = JSON.stringify(data, null, 2) + "\n";
92
80
 
@@ -104,25 +92,19 @@ function writeAuthJson(data: AuthData): void {
104
92
  // ── Public API: write/remove ─────────────────────────────────────────────────
105
93
 
106
94
  export function writeCredential(provider: string, credential: AuthCredential): void {
107
- acquireLock();
108
- try {
95
+ withLock(() => {
109
96
  const data = readAuthJson();
110
97
  data[provider] = credential;
111
98
  writeAuthJson(data);
112
- } finally {
113
- releaseLock();
114
- }
99
+ });
115
100
  }
116
101
 
117
102
  export function removeCredential(provider: string): void {
118
- acquireLock();
119
- try {
103
+ withLock(() => {
120
104
  const data = readAuthJson();
121
105
  delete data[provider];
122
106
  writeAuthJson(data);
123
- } finally {
124
- releaseLock();
125
- }
107
+ });
126
108
  }
127
109
 
128
110
  // ── Pure status builder (testable) ───────────────────────────────────────────
@@ -1,41 +1,47 @@
1
1
  /**
2
- * In-memory cache of provider catalogues pushed by bridges.
2
+ * In-memory cache of the most-recently-pushed provider catalogue.
3
3
  *
4
4
  * Each pi process pushes a `providers_list` over WS, derived from its
5
- * `ModelRegistry`. The server caches per-session and tracks the most-recent
6
- * snapshot. `GET /api/provider-auth/status` reads `getLatestCatalogue()`.
5
+ * `ModelRegistry`. The server caches the latest snapshot. `GET
6
+ * /api/provider-auth/status` reads `getLatestCatalogue()`.
7
7
  *
8
- * See change: replace-hardcoded-provider-lists.
8
+ * The catalogue is a property of the machine's auth + provider config,
9
+ * not of individual sessions: every bridge in the same process tree
10
+ * derives an identical catalogue from `~/.pi/agent/auth.json` +
11
+ * `~/.pi/agent/providers.json` + pi-ai's MODELS table. We therefore
12
+ * keep ONE global snapshot — the last push wins. A previous version
13
+ * kept a per-session Map plus a `changed` deep-equality gate to avoid
14
+ * spurious `models_refreshed` broadcasts; that broadcast is gone
15
+ * (see change: simplify-model-selection-channels), so the gate is
16
+ * unnecessary and the per-session split was redundant.
17
+ *
18
+ * See changes: replace-hardcoded-provider-lists,
19
+ * fix-providers-list-spurious-models-refreshed,
20
+ * simplify-model-selection-channels.
9
21
  */
10
22
  import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
23
 
12
- const bySession = new Map<string, ProviderInfo[]>();
13
24
  let latest: ProviderInfo[] | null = null;
14
25
 
15
- export function setCatalogueForSession(sessionId: string, providers: ProviderInfo[]): void {
16
- bySession.set(sessionId, providers);
26
+ /**
27
+ * Replace the cached catalogue. Called from event-wiring.ts on every
28
+ * `providers_list` arrival. No-op semantically beyond the assignment;
29
+ * no signal to callers because no caller needs one.
30
+ */
31
+ export function setCatalogueForSession(_sessionId: string, providers: ProviderInfo[]): void {
17
32
  latest = providers;
18
33
  }
19
34
 
20
- export function getCatalogueForSession(sessionId: string): ProviderInfo[] | undefined {
21
- return bySession.get(sessionId);
22
- }
23
-
24
35
  /**
25
36
  * Most recent catalogue across any session. Returns [] when no bridge
26
- * has pushed yet — callers should treat that as "waiting for pi".
37
+ * has pushed yet — callers should treat that as "waiting for pi" and
38
+ * may issue a `request_providers` nudge to fetch one synchronously.
27
39
  */
28
40
  export function getLatestCatalogue(): ProviderInfo[] {
29
41
  return latest ?? [];
30
42
  }
31
43
 
32
- export function clearForSession(sessionId: string): void {
33
- bySession.delete(sessionId);
34
- if (bySession.size === 0) latest = null;
35
- }
36
-
37
44
  /** Test-only: reset all cached state. */
38
45
  export function _resetForTests(): void {
39
- bySession.clear();
40
46
  latest = null;
41
47
  }
@@ -12,7 +12,7 @@
12
12
  * See change: fix-windows-server-parity.
13
13
  */
14
14
  import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
15
- import { toFileUrl, shouldUrlWrapEntry } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
15
+ import { buildNodeImportArgvParts, toFileUrl, shouldUrlWrapEntry } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
18
 
@@ -40,21 +40,22 @@ export function buildOrchestratorScript(params: RestartParams): string {
40
40
  // so quoting/path-separator handling is correct on Windows.
41
41
  // See change: fix-restart-bridge-auto-start-race.
42
42
  const pidPath = path.join(os.homedir(), ".pi", "dashboard", "dashboard.pid");
43
- // Loader is always URL-wrapped (required on Windows for non-C: drives).
44
- // Entry is URL-wrapped only on Windows + non-tsx loader. POSIX + jiti MUST
45
- // pass raw path because jiti's resolver treats file:// URL entries as
46
- // relative specifiers (normalises to file:/... then prepends cwd).
47
- // See openspec/changes/archive/2026-04-24-fix-windows-entry-script-url.
48
- const wrapEntry = shouldUrlWrapEntry(params.loader);
49
- const spawnArgs: string[] = [];
50
- if (params.loader) {
51
- spawnArgs.push("--import", toFileUrl(params.loader));
52
- }
53
- spawnArgs.push(
54
- wrapEntry ? toFileUrl(params.cliPath) : params.cliPath,
55
- "start",
56
- ...params.extraArgs,
57
- );
43
+ // Argv shape (loader URL-wrapping + entry URL-wrapping rule) is
44
+ // owned by `buildNodeImportArgvParts` in `node-spawn.ts` the same
45
+ // helper `spawnNodeScript` calls. Keeps the `--import` argv shape
46
+ // in exactly one place.
47
+ // See change: unify-server-launch-ts-loader.
48
+ const spawnArgs: string[] = params.loader
49
+ ? buildNodeImportArgvParts({
50
+ loader: params.loader,
51
+ entry: params.cliPath,
52
+ args: ["start", ...params.extraArgs],
53
+ })
54
+ : [
55
+ shouldUrlWrapEntry(params.loader) ? toFileUrl(params.cliPath) : params.cliPath,
56
+ "start",
57
+ ...params.extraArgs,
58
+ ];
58
59
 
59
60
  // The script runs in a fresh Node process. Keep it self-contained and use
60
61
  // only built-ins (net, http, fs, child_process). JSON.stringify is used to
@@ -13,6 +13,10 @@ import type { FastifyInstance } from "fastify";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import type { BootstrapStateStore } from "../bootstrap-state.js";
15
15
  import type { NetworkGuard } from "./route-deps.js";
16
+ import {
17
+ detectLegacyPiInstalls,
18
+ uninstallLegacyPi,
19
+ } from "../legacy-pi-cleanup.js";
16
20
 
17
21
  export interface BootstrapRouteDeps {
18
22
  bootstrapState: BootstrapStateStore;
@@ -85,4 +89,37 @@ export function registerBootstrapRoutes(
85
89
  return reply.code(202).send({ ticketId, status: "accepted" });
86
90
  },
87
91
  );
92
+
93
+ // ── Legacy pi cleanup ──────────────────────────────────────────
94
+
95
+ // Refresh detection on demand (also runs at server startup).
96
+ fastify.get(
97
+ "/api/bootstrap/legacy-pi",
98
+ { preHandler: networkGuard },
99
+ async () => {
100
+ const installs = detectLegacyPiInstalls();
101
+ bootstrapState.set({ legacyPiInstalls: installs });
102
+ return { installs };
103
+ },
104
+ );
105
+
106
+ // Remove all currently-detected legacy installs. Returns per-install
107
+ // result; partial failures are reported but do not abort the others.
108
+ fastify.post(
109
+ "/api/bootstrap/legacy-pi/cleanup",
110
+ { preHandler: networkGuard },
111
+ async () => {
112
+ const before = detectLegacyPiInstalls();
113
+ if (before.length === 0) {
114
+ bootstrapState.set({ legacyPiInstalls: [] });
115
+ return { results: [], remaining: [] };
116
+ }
117
+ const results = uninstallLegacyPi(before);
118
+ // Re-scan so the UI shows any installs that survived (e.g. permission
119
+ // error). The store mirrors the post-cleanup state.
120
+ const remaining = detectLegacyPiInstalls();
121
+ bootstrapState.set({ legacyPiInstalls: remaining });
122
+ return { results, remaining };
123
+ },
124
+ );
88
125
  }
@@ -22,6 +22,7 @@ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/type
22
22
  import type { BrowserGateway } from "../browser-gateway.js";
23
23
  import type { PendingAttachRegistry } from "../pending-attach-registry.js";
24
24
  import { spawnPiSession } from "../process-manager.js";
25
+ import { keeperOptsFromSpawnResult } from "../headless-pid-registry.js";
25
26
  import type { NetworkGuard } from "./route-deps.js";
26
27
  import { safeRealpathSync } from "../resolve-path.js";
27
28
 
@@ -218,6 +219,8 @@ export function registerJjRoutes(fastify: FastifyInstance, deps: JjRoutesDeps) {
218
219
  spawnResult.pid,
219
220
  realDestPath,
220
221
  spawnResult.process,
222
+ spawnResult.spawnToken,
223
+ keeperOptsFromSpawnResult(spawnResult),
221
224
  );
222
225
  }
223
226
  if (!spawnResult.success) {
@@ -0,0 +1,168 @@
1
+ /**
2
+ * REST routes for proxy API key management.
3
+ *
4
+ * GET /api/model-proxy/api-keys → list keys (redacted)
5
+ * POST /api/model-proxy/api-keys → create key (cleartext returned ONCE)
6
+ * POST /api/model-proxy/api-keys/:id/revoke → soft-delete (set revokedAt)
7
+ * DELETE /api/model-proxy/api-keys/:id → hard-delete (purge)
8
+ *
9
+ * All routes are JWT-gated (NOT proxy-key-gated — this is the management surface).
10
+ *
11
+ * See change: add-dashboard-model-proxy.
12
+ */
13
+ import crypto from "node:crypto";
14
+ import type { FastifyInstance } from "fastify";
15
+ import type { ProxyApiKey } from "@blackbelt-technology/pi-dashboard-shared/config.js";
16
+ import type { NetworkGuard } from "./route-deps.js";
17
+ import { generateKey, hashKey } from "../model-proxy/api-key-store.js";
18
+
19
+ export interface ApiKeyRoutesDeps {
20
+ networkGuard: NetworkGuard;
21
+ getModelProxyConfig: () => import("@blackbelt-technology/pi-dashboard-shared/config.js").ModelProxyConfig;
22
+ writeModelProxyApiKeys: (apiKeys: ProxyApiKey[]) => Promise<void>;
23
+ getAdminEmail?: () => string | undefined;
24
+ }
25
+
26
+ function getUserEmail(request: any): string | undefined {
27
+ return request.user?.email;
28
+ }
29
+
30
+ export function registerModelProxyApiKeyRoutes(
31
+ fastify: FastifyInstance,
32
+ deps: ApiKeyRoutesDeps,
33
+ ): void {
34
+ const { networkGuard, getModelProxyConfig, writeModelProxyApiKeys, getAdminEmail } = deps;
35
+
36
+ // ── GET /api/model-proxy/api-keys ─────────────────────────────────
37
+ fastify.get(
38
+ "/api/model-proxy/api-keys",
39
+ { preHandler: networkGuard },
40
+ async (request) => {
41
+ const config = getModelProxyConfig();
42
+ const userEmail = getUserEmail(request);
43
+ const adminEmail = getAdminEmail?.();
44
+ const isAdmin = adminEmail != null && userEmail === adminEmail;
45
+
46
+ const filtered = config.apiKeys.filter(
47
+ (k) => isAdmin || !k.createdBy || k.createdBy === userEmail,
48
+ );
49
+
50
+ const active = filtered
51
+ .filter((k) => k.revokedAt == null)
52
+ .map((k) => ({ ...k, hash: "***" }));
53
+ const revoked = filtered
54
+ .filter((k) => k.revokedAt != null)
55
+ .map((k) => ({ ...k, hash: "***" }));
56
+
57
+ return { success: true, data: { keys: active, revoked } };
58
+ },
59
+ );
60
+
61
+ // ── POST /api/model-proxy/api-keys ────────────────────────────────
62
+ fastify.post(
63
+ "/api/model-proxy/api-keys",
64
+ { preHandler: networkGuard },
65
+ async (request, reply) => {
66
+ const body = request.body as any;
67
+ if (!body || typeof body.label !== "string" || !body.label.trim()) {
68
+ return reply.code(400).send({ success: false, error: "label is required" });
69
+ }
70
+
71
+ const scopes: string[] = Array.isArray(body.scopes) ? body.scopes : ["all"];
72
+ const expiresAt: number | undefined =
73
+ typeof body.expiresAt === "number" ? body.expiresAt : undefined;
74
+
75
+ if (expiresAt != null && expiresAt <= Date.now()) {
76
+ return reply.code(400).send({ success: false, error: "expiresAt must be in the future" });
77
+ }
78
+
79
+ const cleartext = generateKey();
80
+ const hash = hashKey(cleartext);
81
+ const userEmail = getUserEmail(request);
82
+
83
+ const entry: ProxyApiKey = {
84
+ id: crypto.randomUUID(),
85
+ label: body.label.trim(),
86
+ hash,
87
+ createdAt: Date.now(),
88
+ ...(userEmail ? { createdBy: userEmail } : {}),
89
+ scopes,
90
+ ...(expiresAt != null ? { expiresAt } : {}),
91
+ };
92
+
93
+ const config = getModelProxyConfig();
94
+ await writeModelProxyApiKeys([...config.apiKeys, entry]);
95
+
96
+ return reply.code(201).send({
97
+ success: true,
98
+ data: {
99
+ id: entry.id,
100
+ label: entry.label,
101
+ createdBy: entry.createdBy,
102
+ scopes: entry.scopes ?? ["all"],
103
+ createdAt: entry.createdAt,
104
+ ...(entry.expiresAt != null ? { expiresAt: entry.expiresAt } : {}),
105
+ key: cleartext, // revealed ONCE
106
+ },
107
+ });
108
+ },
109
+ );
110
+
111
+ // ── POST /api/model-proxy/api-keys/:id/revoke ─────────────────────
112
+ fastify.post(
113
+ "/api/model-proxy/api-keys/:id/revoke",
114
+ { preHandler: networkGuard },
115
+ async (request, reply) => {
116
+ const { id } = request.params as { id: string };
117
+ const config = getModelProxyConfig();
118
+ const entry = config.apiKeys.find((k) => k.id === id);
119
+
120
+ if (!entry) {
121
+ return reply.code(404).send({ success: false, error: "Key not found" });
122
+ }
123
+
124
+ const userEmail = getUserEmail(request);
125
+ const adminEmail = getAdminEmail?.();
126
+ const isAdmin = adminEmail != null && userEmail === adminEmail;
127
+
128
+ if (!isAdmin && entry.createdBy && entry.createdBy !== userEmail) {
129
+ return reply.code(403).send({ success: false, error: "Not authorized to revoke this key" });
130
+ }
131
+
132
+ const updated = config.apiKeys.map((k) =>
133
+ k.id === id ? { ...k, revokedAt: Date.now() } : k,
134
+ );
135
+ await writeModelProxyApiKeys(updated);
136
+
137
+ return reply.code(204).send();
138
+ },
139
+ );
140
+
141
+ // ── DELETE /api/model-proxy/api-keys/:id ──────────────────────────
142
+ fastify.delete(
143
+ "/api/model-proxy/api-keys/:id",
144
+ { preHandler: networkGuard },
145
+ async (request, reply) => {
146
+ const { id } = request.params as { id: string };
147
+ const config = getModelProxyConfig();
148
+ const entry = config.apiKeys.find((k) => k.id === id);
149
+
150
+ if (!entry) {
151
+ return reply.code(404).send({ success: false, error: "Key not found" });
152
+ }
153
+
154
+ const userEmail = getUserEmail(request);
155
+ const adminEmail = getAdminEmail?.();
156
+ const isAdmin = adminEmail != null && userEmail === adminEmail;
157
+
158
+ if (!isAdmin && entry.createdBy && entry.createdBy !== userEmail) {
159
+ return reply.code(403).send({ success: false, error: "Not authorized to delete this key" });
160
+ }
161
+
162
+ const filtered = config.apiKeys.filter((k) => k.id !== id);
163
+ await writeModelProxyApiKeys(filtered);
164
+
165
+ return reply.code(204).send();
166
+ },
167
+ );
168
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * POST /api/model-proxy/refresh — force-refresh the model proxy registry.
3
+ *
4
+ * JWT-gated. Returns 200 on success, 503 if registry cannot be resolved.
5
+ * See change: add-dashboard-model-proxy, task 2.9.
6
+ */
7
+ import type { FastifyInstance } from "fastify";
8
+ import { refreshModelRegistry, getModelRegistry } from "../model-proxy/registry-singleton.js";
9
+
10
+ export function registerModelProxyRefreshRoutes(fastify: FastifyInstance): void {
11
+ fastify.post("/api/model-proxy/refresh", async (_request, reply) => {
12
+ try {
13
+ // Ensure registry is initialized first, then refresh
14
+ await getModelRegistry();
15
+ await refreshModelRegistry();
16
+ return { ok: true };
17
+ } catch (err: any) {
18
+ return reply.code(503).send({
19
+ code: "MODEL_PROXY_RUNTIME_MISSING",
20
+ message: err.message || "Failed to refresh model proxy registry",
21
+ });
22
+ }
23
+ });
24
+ }