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

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 (212) hide show
  1. package/AGENTS.md +19 -30
  2. package/README.md +69 -1
  3. package/docs/architecture.md +89 -165
  4. package/package.json +11 -7
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-default-model-gate.test.ts +47 -0
  7. package/packages/extension/src/__tests__/bridge-followup-chat-order.test.ts +215 -0
  8. package/packages/extension/src/__tests__/bridge-followup-multi-entry.test.ts +202 -0
  9. package/packages/extension/src/__tests__/bridge-queue-update-forward.test.ts +77 -0
  10. package/packages/extension/src/__tests__/bridge-retry-ordering.test.ts +148 -0
  11. package/packages/extension/src/__tests__/bridge-shadow-queue-drain.test.ts +221 -0
  12. package/packages/extension/src/__tests__/bridge-shadow-queue-gate.test.ts +299 -0
  13. package/packages/extension/src/__tests__/bridge-shutdown-reset.test.ts +238 -0
  14. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +127 -31
  15. package/packages/extension/src/__tests__/command-handler.test.ts +105 -3
  16. package/packages/extension/src/__tests__/fixtures/usage-limit-error-strings.ts +127 -0
  17. package/packages/extension/src/__tests__/source-detector.test.ts +15 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +12 -0
  19. package/packages/extension/src/bridge-default-model-gate.ts +32 -0
  20. package/packages/extension/src/bridge.ts +299 -20
  21. package/packages/extension/src/command-handler.ts +53 -7
  22. package/packages/extension/src/dashboard-default-adapter.ts +5 -0
  23. package/packages/extension/src/prompt-bus.ts +15 -0
  24. package/packages/extension/src/slash-dispatch.ts +30 -15
  25. package/packages/extension/src/source-detector.ts +13 -5
  26. package/packages/extension/src/usage-limit-orderer.ts +18 -1
  27. package/packages/server/bin/pi-dashboard.mjs +62 -14
  28. package/packages/server/package.json +9 -5
  29. package/packages/server/src/__tests__/browser-gateway-register-handler.test.ts +69 -0
  30. package/packages/server/src/__tests__/cli-env-no-clobber.test.ts +46 -0
  31. package/packages/server/src/__tests__/cli-no-bootstrap-references.test.ts +69 -0
  32. package/packages/server/src/__tests__/cli-parse.test.ts +9 -10
  33. package/packages/server/src/__tests__/cli-version.test.ts +151 -0
  34. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +9 -0
  35. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +9 -0
  36. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +9 -0
  37. package/packages/server/src/__tests__/directory-service-toctou.test.ts +9 -0
  38. package/packages/server/src/__tests__/directory-service.test.ts +9 -0
  39. package/packages/server/src/__tests__/doctor-route.test.ts +53 -0
  40. package/packages/server/src/__tests__/event-wiring-queue-state.test.ts +156 -0
  41. package/packages/server/src/__tests__/event-wiring-resume-clear.test.ts +105 -0
  42. package/packages/server/src/__tests__/health-shape.test.ts +35 -12
  43. package/packages/server/src/__tests__/installed-package-enricher.test.ts +12 -12
  44. package/packages/server/src/__tests__/is-activity-event.test.ts +4 -7
  45. package/packages/server/src/__tests__/package-routes.test.ts +6 -2
  46. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +10 -13
  47. package/packages/server/src/__tests__/pi-core-checker.test.ts +2 -2
  48. package/packages/server/src/__tests__/pi-version-skew.test.ts +3 -2
  49. package/packages/server/src/__tests__/plugin-activation-routes.test.ts +267 -0
  50. package/packages/server/src/__tests__/plugin-intent-cache.test.ts +75 -0
  51. package/packages/server/src/__tests__/preferences-store.test.ts +196 -0
  52. package/packages/server/src/__tests__/reattach-placement.test.ts +9 -0
  53. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  54. package/packages/server/src/__tests__/recovery-server.test.ts +203 -0
  55. package/packages/server/src/__tests__/session-action-handler-clear-queue.test.ts +153 -0
  56. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +43 -0
  57. package/packages/server/src/__tests__/session-order-manager.test.ts +9 -0
  58. package/packages/server/src/__tests__/session-order-reboot.test.ts +9 -0
  59. package/packages/server/src/__tests__/session-ordering-integration.test.ts +9 -0
  60. package/packages/server/src/browser-gateway.ts +83 -5
  61. package/packages/server/src/browser-handlers/directory-handler.ts +69 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +89 -0
  63. package/packages/server/src/browser-handlers/subscription-handler.ts +23 -0
  64. package/packages/server/src/changelog-parser.ts +1 -1
  65. package/packages/server/src/cli.ts +68 -250
  66. package/packages/server/src/event-status-extraction.ts +14 -62
  67. package/packages/server/src/event-wiring.ts +23 -10
  68. package/packages/server/src/memory-session-manager.ts +4 -0
  69. package/packages/server/src/pi-core-checker.ts +1 -1
  70. package/packages/server/src/pi-dev-version-check.ts +1 -1
  71. package/packages/server/src/pi-version-skew.ts +24 -46
  72. package/packages/server/src/plugin-intent-cache.ts +67 -0
  73. package/packages/server/src/preferences-store.ts +199 -13
  74. package/packages/server/src/recovery-server.ts +366 -0
  75. package/packages/server/src/routes/__tests__/manifest-route.test.ts +138 -0
  76. package/packages/server/src/routes/doctor-routes.ts +26 -21
  77. package/packages/server/src/routes/manifest-route.ts +162 -0
  78. package/packages/server/src/routes/openspec-routes.ts +4 -25
  79. package/packages/server/src/routes/pi-changelog-routes.ts +5 -24
  80. package/packages/server/src/routes/pi-core-routes.ts +3 -23
  81. package/packages/server/src/routes/plugin-activation-routes.ts +193 -0
  82. package/packages/server/src/routes/recommended-routes.ts +21 -0
  83. package/packages/server/src/routes/system-routes.ts +73 -11
  84. package/packages/server/src/server.ts +105 -307
  85. package/packages/server/src/session-api.ts +5 -63
  86. package/packages/shared/package.json +1 -1
  87. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +28 -0
  88. package/packages/shared/src/__tests__/binary-lookup-spawn-env.test.ts +61 -0
  89. package/packages/shared/src/__tests__/binary-lookup.test.ts +16 -0
  90. package/packages/shared/src/__tests__/bridge-register.test.ts +67 -0
  91. package/packages/shared/src/__tests__/ci-electron-no-side-effects.test.ts +129 -0
  92. package/packages/shared/src/__tests__/config.test.ts +40 -0
  93. package/packages/shared/src/__tests__/dashboard-paths.test.ts +81 -0
  94. package/packages/shared/src/__tests__/ensure-windows-path.test.ts +112 -0
  95. package/packages/shared/src/__tests__/intent-types.test.ts +120 -0
  96. package/packages/shared/src/__tests__/jiti-packages-parity.test.ts +85 -0
  97. package/packages/shared/src/__tests__/legacy-managed-dir.test.ts +59 -0
  98. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +12 -0
  99. package/packages/shared/src/__tests__/no-electron-execpath-spawn.test.ts +149 -0
  100. package/packages/shared/src/__tests__/no-flow-command-route-claims.test.ts +71 -0
  101. package/packages/shared/src/__tests__/no-flow-references-in-shell.test.ts +221 -0
  102. package/packages/shared/src/__tests__/no-managed-dir-reference.test.ts +134 -0
  103. package/packages/shared/src/__tests__/no-pi-dashboard-version-jiti-gate.test.ts +41 -0
  104. package/packages/shared/src/__tests__/no-primitive-direct-import.test.ts +235 -0
  105. package/packages/shared/src/__tests__/no-server-imports-in-resolver.test.ts +53 -0
  106. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +54 -101
  107. package/packages/shared/src/__tests__/node-spawn.test.ts +29 -13
  108. package/packages/shared/src/__tests__/pi-package-resolver.test.ts +300 -0
  109. package/packages/shared/src/__tests__/plugin-activation-contracts.test.ts +74 -0
  110. package/packages/shared/src/__tests__/plugin-bridge-classify-source.test.ts +73 -0
  111. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +17 -5
  112. package/packages/shared/src/__tests__/plugin-bridge-register-packages.test.ts +233 -0
  113. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +19 -9
  114. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +154 -15
  115. package/packages/shared/src/__tests__/recommended-extensions.test.ts +28 -10
  116. package/packages/shared/src/__tests__/resolver-parity-with-scanner.test.ts +76 -0
  117. package/packages/shared/src/__tests__/server-identity.test.ts +127 -0
  118. package/packages/shared/src/__tests__/server-launcher.test.ts +35 -0
  119. package/packages/shared/src/__tests__/source-matching.test.ts +5 -5
  120. package/packages/shared/src/__tests__/sync-versions-spec.test.ts +76 -0
  121. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +50 -2
  122. package/packages/shared/src/bridge-register.ts +35 -2
  123. package/packages/shared/src/browser-protocol.ts +176 -2
  124. package/packages/shared/src/config.ts +12 -0
  125. package/packages/shared/src/dashboard-paths.ts +69 -0
  126. package/packages/shared/src/dashboard-plugin/index.ts +2 -0
  127. package/packages/shared/src/dashboard-plugin/intent-types.ts +93 -0
  128. package/packages/shared/src/dashboard-plugin/manifest-types.ts +55 -1
  129. package/packages/shared/src/dashboard-plugin/plugin-status.ts +82 -0
  130. package/packages/shared/src/dashboard-plugin/slot-props.ts +11 -0
  131. package/packages/shared/src/dashboard-plugin/slot-types.ts +16 -2
  132. package/packages/shared/src/dashboard-plugin/ui-primitives.ts +287 -0
  133. package/packages/shared/src/dashboard-starter.ts +22 -0
  134. package/packages/shared/src/doctor-core.ts +49 -27
  135. package/packages/shared/src/launch-source-types.ts +9 -9
  136. package/packages/shared/src/legacy-managed-dir.ts +97 -0
  137. package/packages/shared/src/mdns-discovery.ts +4 -1
  138. package/packages/shared/src/pi-package-resolver.ts +388 -0
  139. package/packages/shared/src/platform/binary-lookup.ts +27 -3
  140. package/packages/shared/src/platform/ensure-windows-path.ts +95 -0
  141. package/packages/shared/src/platform/exec.ts +22 -0
  142. package/packages/shared/src/platform/node-spawn.ts +42 -41
  143. package/packages/shared/src/plugin-bridge-register.ts +275 -18
  144. package/packages/shared/src/protocol.ts +94 -2
  145. package/packages/shared/src/recommended-extensions.ts +34 -10
  146. package/packages/shared/src/server-identity.ts +74 -5
  147. package/packages/shared/src/server-launcher.ts +20 -0
  148. package/packages/shared/src/source-matching.ts +1 -1
  149. package/packages/shared/src/tool-registry/__tests__/node-script-toargv-fallback.test.ts +84 -0
  150. package/packages/shared/src/tool-registry/definitions.ts +91 -7
  151. package/packages/shared/src/types.ts +12 -8
  152. package/scripts/maybe-patch-package.cjs +44 -0
  153. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +0 -263
  154. package/packages/server/src/__tests__/bootstrap-queue.test.ts +0 -120
  155. package/packages/server/src/__tests__/bootstrap-routes.test.ts +0 -125
  156. package/packages/server/src/__tests__/bootstrap-state.test.ts +0 -119
  157. package/packages/server/src/__tests__/cli-bootstrap.test.ts +0 -36
  158. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +0 -55
  159. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +0 -149
  160. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +0 -180
  161. package/packages/server/src/__tests__/post-install-rescan.test.ts +0 -134
  162. package/packages/server/src/__tests__/system-routes-reextract.test.ts +0 -91
  163. package/packages/server/src/bootstrap-install-from-list.ts +0 -232
  164. package/packages/server/src/bootstrap-queue.ts +0 -130
  165. package/packages/server/src/bootstrap-state.ts +0 -159
  166. package/packages/server/src/legacy-pi-cleanup.ts +0 -151
  167. package/packages/server/src/routes/bootstrap-routes.ts +0 -125
  168. package/packages/shared/src/__tests__/bootstrap/README.md +0 -133
  169. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +0 -378
  170. package/packages/shared/src/__tests__/bootstrap/assertions.ts +0 -136
  171. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +0 -47
  172. package/packages/shared/src/__tests__/bootstrap/cube.ts +0 -66
  173. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +0 -84
  174. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +0 -90
  175. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +0 -34
  176. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +0 -20
  177. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +0 -62
  178. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +0 -34
  179. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +0 -49
  180. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +0 -12
  181. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +0 -156
  182. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +0 -157
  183. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +0 -102
  184. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +0 -76
  185. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +0 -94
  186. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +0 -87
  187. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +0 -143
  188. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +0 -64
  189. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +0 -77
  190. package/packages/shared/src/__tests__/bootstrap/families/index.ts +0 -19
  191. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +0 -61
  192. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +0 -50
  193. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +0 -272
  194. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +0 -58
  195. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +0 -84
  196. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +0 -9
  197. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +0 -85
  198. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +0 -122
  199. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +0 -36
  200. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +0 -39
  201. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +0 -220
  202. package/packages/shared/src/__tests__/bootstrap/harness.ts +0 -413
  203. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +0 -125
  204. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +0 -132
  205. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +0 -72
  206. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +0 -68
  207. package/packages/shared/src/__tests__/install-managed-node.test.ts +0 -192
  208. package/packages/shared/src/__tests__/installable-list.test.ts +0 -130
  209. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +0 -52
  210. package/packages/shared/src/bootstrap-install.ts +0 -406
  211. package/packages/shared/src/installable-list.ts +0 -152
  212. package/packages/shared/src/launch-source-flag.ts +0 -14
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Dynamic /manifest.json route.
3
+ *
4
+ * Serves a PWA web-app manifest whose `name` and `short_name` vary by
5
+ * server identity, so the same dashboard installed as a PWA from multiple
6
+ * origins (LAN host, tunnel, loopback) shows distinct labels on the
7
+ * launcher. See change: add-dynamic-pwa-manifest-naming.
8
+ *
9
+ * Resolution order for the name `<source>`:
10
+ * 1. `config.dashboardName` (user override; trimmed)
11
+ * 2. Request `Host` header with port stripped (IPv6-safe)
12
+ * 3. `os.hostname()`
13
+ * 4. Literal `"Pi-Dash"`
14
+ *
15
+ * Final manifest fields:
16
+ * name = `Pi-Dash \u00b7 ${source}`
17
+ * short_name = source.slice(0, 12)
18
+ * id = "/"
19
+ *
20
+ * All other fields (icons, theme/background color, display, start_url)
21
+ * are spread from the static `manifest.json` shipped in the client bundle.
22
+ *
23
+ * This route is registered BEFORE `@fastify/static` so explicit Fastify
24
+ * route matching wins over the on-disk static asset.
25
+ */
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import type { FastifyInstance } from "fastify";
30
+
31
+ /**
32
+ * Strip the trailing port from a Host header.
33
+ *
34
+ * Handles:
35
+ * - bare hostnames (`mybox.local`)
36
+ * - host:port (`mybox.local:8000`)
37
+ * - bracketed IPv6 with port (`[::1]:8000`)
38
+ * - bracketed IPv6 without port (`[::1]`)
39
+ * - empty / undefined input → empty string
40
+ *
41
+ * Lower-cases the result so casing differences across requests don't
42
+ * produce ostensibly distinct labels.
43
+ */
44
+ export function stripPort(host: string | undefined | null): string {
45
+ if (!host) return "";
46
+ const trimmed = host.trim();
47
+ if (!trimmed) return "";
48
+
49
+ // Bracketed IPv6: "[::1]" or "[::1]:8000"
50
+ if (trimmed.startsWith("[")) {
51
+ const close = trimmed.indexOf("]");
52
+ if (close > 0) return trimmed.slice(1, close).toLowerCase();
53
+ // Malformed — drop bracket, return as-is
54
+ return trimmed.slice(1).toLowerCase();
55
+ }
56
+
57
+ // host:port — last colon, but only if there's exactly one colon
58
+ // (bare IPv6 like "::1" has multiple colons; we leave it untouched
59
+ // since unbracketed IPv6 in a Host header is non-conformant anyway).
60
+ const firstColon = trimmed.indexOf(":");
61
+ const lastColon = trimmed.lastIndexOf(":");
62
+ if (firstColon === lastColon && firstColon > 0) {
63
+ return trimmed.slice(0, firstColon).toLowerCase();
64
+ }
65
+ return trimmed.toLowerCase();
66
+ }
67
+
68
+ /**
69
+ * Resolve the `<source>` string used to build manifest `name` / `short_name`.
70
+ *
71
+ * Pure — no fs or process access. Pass `hostname` explicitly so tests can
72
+ * control it.
73
+ */
74
+ export function resolveManifestSource(
75
+ hostHeader: string | undefined | null,
76
+ configDashboardName: string | undefined | null,
77
+ hostname: string,
78
+ ): string {
79
+ const override = (configDashboardName ?? "").trim();
80
+ if (override) return override;
81
+
82
+ const fromHost = stripPort(hostHeader);
83
+ if (fromHost) return fromHost;
84
+
85
+ const fromHostname = (hostname ?? "").trim();
86
+ if (fromHostname) return fromHostname;
87
+
88
+ return "Pi-Dash";
89
+ }
90
+
91
+ /**
92
+ * Build the dynamic manifest body. Spreads the static base, overrides
93
+ * `name`/`short_name`/`id`. Pure given a `staticBase`.
94
+ */
95
+ export function buildManifestBody(
96
+ staticBase: Record<string, unknown>,
97
+ source: string,
98
+ ): Record<string, unknown> {
99
+ return {
100
+ ...staticBase,
101
+ id: "/",
102
+ name: `Pi-Dash \u00b7 ${source}`,
103
+ short_name: source.slice(0, 12) || "Pi-Dash",
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Load the static manifest JSON shipped in the client bundle. Returns an
109
+ * empty object if missing or unparseable (route still serves a valid
110
+ * minimal manifest in that case).
111
+ *
112
+ * Synchronous + cached — manifest content is immutable per server build.
113
+ */
114
+ export function loadStaticManifest(clientDir: string): Record<string, unknown> {
115
+ if (!clientDir) return {};
116
+ try {
117
+ const manifestPath = path.join(clientDir, "manifest.json");
118
+ if (!fs.existsSync(manifestPath)) return {};
119
+ const raw = fs.readFileSync(manifestPath, "utf-8");
120
+ const parsed = JSON.parse(raw);
121
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
122
+ return parsed as Record<string, unknown>;
123
+ }
124
+ return {};
125
+ } catch {
126
+ return {};
127
+ }
128
+ }
129
+
130
+ export interface ManifestRouteDeps {
131
+ /** Resolved client-dist directory (where the static manifest.json lives). */
132
+ clientDir: string;
133
+ /** Lazy accessor for the *latest* dashboard config — re-read per request
134
+ * so Settings panel changes propagate without a server restart. */
135
+ getDashboardName: () => string | undefined;
136
+ }
137
+
138
+ /**
139
+ * Register `GET /manifest.json` on the given Fastify instance.
140
+ *
141
+ * MUST be called BEFORE `fastify.register(fastifyStatic, ...)`. Explicit
142
+ * routes win over the static plugin's fallback handler.
143
+ */
144
+ export function registerManifestRoute(
145
+ fastify: FastifyInstance,
146
+ deps: ManifestRouteDeps,
147
+ ): void {
148
+ const staticBase = loadStaticManifest(deps.clientDir);
149
+ const hostname = os.hostname();
150
+
151
+ fastify.get("/manifest.json", async (request, reply) => {
152
+ const source = resolveManifestSource(
153
+ typeof request.headers.host === "string" ? request.headers.host : "",
154
+ deps.getDashboardName(),
155
+ hostname,
156
+ );
157
+ const body = buildManifestBody(staticBase, source);
158
+ reply.header("Content-Type", "application/manifest+json; charset=utf-8");
159
+ reply.header("Cache-Control", "no-cache, must-revalidate");
160
+ return body;
161
+ });
162
+ }
@@ -30,16 +30,9 @@ 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;
40
33
  },
41
34
  ) {
42
- const { sessionManager, preferencesStore, directoryService, networkGuard, onOpenSpecChanged, bootstrapState } = deps;
35
+ const { sessionManager, preferencesStore, directoryService, networkGuard, onOpenSpecChanged } = deps;
43
36
 
44
37
  // OpenSpec archive listing endpoint
45
38
  fastify.get<{ Querystring: { cwd?: string } }>(
@@ -66,23 +59,9 @@ export function registerOpenSpecRoutes(
66
59
  reply.code(400);
67
60
  return { success: false, error: "cwd parameter required" } satisfies ApiResponse;
68
61
  }
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
+ // Bootstrap gate removed under change: eliminate-electron-runtime-install
63
+ // (task 3.5). pi/openspec/tsx ship as regular npm deps; pi-resources
64
+ // endpoint is unconditionally available.
86
65
  const forceRefresh = request.query.refresh === "true" || request.query.refresh === "1";
87
66
  let data = forceRefresh ? undefined : directoryService.getPiResources(cwd);
88
67
  if (!data) {
@@ -5,10 +5,8 @@
5
5
  * a `(from, to]` half-open version range, plus a derived `hasBreaking`
6
6
  * flag and a public GitHub URL for the full changelog.
7
7
  *
8
- * Gated identically to other `/api/pi-core/*` routes via the shared
9
- * bootstrap-status gate.
10
- *
11
- * See change: pi-update-whats-new-panel.
8
+ * See change: pi-update-whats-new-panel. Bootstrap gate removed under
9
+ * change: eliminate-electron-runtime-install (task 3.5).
12
10
  */
13
11
  import type { FastifyInstance } from "fastify";
14
12
  import type {
@@ -33,11 +31,9 @@ import {
33
31
  fetchRemoteChangelog,
34
32
  } from "../changelog-remote.js";
35
33
  import type { ChangelogRelease } from "@blackbelt-technology/pi-dashboard-shared/changelog-types.js";
36
- import type { BootstrapStateStore } from "../bootstrap-state.js";
37
34
 
38
35
  export interface PiChangelogRouteDeps {
39
- /** When provided, route returns 503 unless bootstrap status is "ready". */
40
- bootstrapState?: BootstrapStateStore;
36
+ // Bootstrap gate field removed; route is unconditionally available.
41
37
  }
42
38
 
43
39
  interface QueryShape {
@@ -48,27 +44,12 @@ interface QueryShape {
48
44
 
49
45
  export function registerPiChangelogRoutes(
50
46
  fastify: FastifyInstance,
51
- deps: PiChangelogRouteDeps,
47
+ _deps: PiChangelogRouteDeps,
52
48
  ): void {
53
- const { bootstrapState } = deps;
54
-
55
- const bootstrapGate = async (
56
- _req: unknown,
57
- reply: { code: (n: number) => { send: (body: unknown) => unknown } },
58
- ): Promise<unknown> => {
59
- if (!bootstrapState) return undefined;
60
- const status = bootstrapState.get().status;
61
- if (status === "ready") return undefined;
62
- return reply.code(503).send({
63
- success: false,
64
- error: `pi not yet installed (bootstrap status: ${status})`,
65
- bootstrap: status,
66
- });
67
- };
49
+
68
50
 
69
51
  fastify.get<{ Querystring: QueryShape }>(
70
52
  "/api/pi-core/changelog",
71
- { preHandler: bootstrapGate as any },
72
53
  async (request, reply) => {
73
54
  const pkg = (request.query.pkg ?? "").trim();
74
55
  const from = (request.query.from ?? "").trim();
@@ -18,16 +18,10 @@ 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";
22
21
 
23
22
  export interface PiCoreRouteDeps {
24
23
  piCoreChecker: PiCoreChecker;
25
24
  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;
31
25
  /**
32
26
  * Called after the updater finishes a batch (success or per-package failure).
33
27
  * The server wires this to broadcast a `pi_core_update_complete` WS message
@@ -44,28 +38,15 @@ export function registerPiCoreRoutes(
44
38
  fastify: FastifyInstance,
45
39
  deps: PiCoreRouteDeps,
46
40
  ): void {
47
- const { piCoreChecker, piCoreUpdater, bootstrapState } = deps;
41
+ const { piCoreChecker, piCoreUpdater } = deps;
48
42
 
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
- };
43
+ // bootstrapGate removed under change: eliminate-electron-runtime-install (task 3.5).
44
+ // pi-core endpoints are unconditionally available; pi ships as regular npm dep.
63
45
 
64
46
  // ── GET /api/pi-core/versions ──────────────────────────────────
65
47
 
66
48
  fastify.get<{ Querystring: { refresh?: string } }>(
67
49
  "/api/pi-core/versions",
68
- { preHandler: bootstrapGate as any },
69
50
  async (request) => {
70
51
  const refresh = request.query.refresh === "true";
71
52
  try {
@@ -81,7 +62,6 @@ export function registerPiCoreRoutes(
81
62
 
82
63
  fastify.post<{ Body: PiCoreUpdateRequest }>(
83
64
  "/api/pi-core/update",
84
- { preHandler: bootstrapGate as any },
85
65
  async (request, reply) => {
86
66
  const requested = request.body?.packages ?? [];
87
67
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Plugin activation REST routes.
3
+ *
4
+ * GET /api/plugins — list every discovered plugin (manifest + status)
5
+ * POST /api/plugins/:id/toggle — body { enabled: boolean }; writes
6
+ * config.plugins.<id>.enabled, broadcasts
7
+ * plugin_config_update, returns
8
+ * { restartRequired: true } or 404.
9
+ *
10
+ * See change: add-plugin-activation-ui.
11
+ */
12
+ import type { FastifyInstance } from "fastify";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+ import {
17
+ discoverPlugins,
18
+ getPluginStatusStore,
19
+ buildGraph,
20
+ computeToggleImpact,
21
+ transitiveDependents,
22
+ } from "@blackbelt-technology/dashboard-plugin-runtime/server";
23
+ import type { NetworkGuard } from "./route-deps.js";
24
+ import type { ServerToBrowserMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
25
+
26
+ // Resolved lazily so tests that override $HOME after import still work.
27
+ function configPaths() {
28
+ const dir = path.join(os.homedir(), ".pi", "dashboard");
29
+ return { dir, file: path.join(dir, "config.json") };
30
+ }
31
+
32
+ function readRawConfig(): Record<string, unknown> {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(configPaths().file, "utf-8"));
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ function writeRawConfig(merged: Record<string, unknown>): void {
41
+ const { dir, file } = configPaths();
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const tmp = file + ".tmp." + process.pid;
44
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n");
45
+ fs.renameSync(tmp, file);
46
+ }
47
+
48
+ export function registerPluginActivationRoutes(
49
+ fastify: FastifyInstance,
50
+ deps: {
51
+ networkGuard: NetworkGuard;
52
+ broadcast: (msg: ServerToBrowserMessage) => void;
53
+ repoRoot?: string;
54
+ },
55
+ ) {
56
+ const { networkGuard, broadcast, repoRoot } = deps;
57
+
58
+ // GET /api/plugins — every discovered plugin's manifest summary + status.
59
+ fastify.get(
60
+ "/api/plugins",
61
+ { preHandler: networkGuard },
62
+ async (_request, reply) => {
63
+ const plugins = discoverPlugins(repoRoot);
64
+ const store = getPluginStatusStore();
65
+ const all = store.listAll();
66
+ const statusById = new Map(all.map((s) => [s.id, s] as const));
67
+
68
+ // Compute dependents per plugin for the cascade-impact preview UX.
69
+ // See change: add-plugin-activation-ui (Layer 2 — dependency graph).
70
+ const graph = buildGraph(
71
+ plugins.map((p) => ({
72
+ id: p.manifest.id,
73
+ dependsOn: p.manifest.dependsOn ?? [],
74
+ })),
75
+ () => true,
76
+ );
77
+
78
+ const rows = plugins.map((p) => {
79
+ const m = p.manifest;
80
+ const status = statusById.get(m.id);
81
+ const dependents = Array.from(transitiveDependents(graph, m.id)).sort();
82
+ return {
83
+ id: m.id,
84
+ displayName: m.displayName,
85
+ priority: m.priority ?? 1000,
86
+ hasServer: Boolean(p.serverEntryPath),
87
+ hasBridge: Boolean(p.bridgeEntryPath),
88
+ hasClient: Boolean(p.clientEntryPath),
89
+ claims: m.claims.map((c) => ({
90
+ slot: c.slot,
91
+ component: c.component,
92
+ tab: c.tab,
93
+ command: c.command,
94
+ toolName: c.toolName,
95
+ })),
96
+ requires: m.requires ?? null,
97
+ dependsOn: m.dependsOn ?? [],
98
+ dependents,
99
+ status: status ?? null,
100
+ };
101
+ });
102
+
103
+ return reply.status(200).send({ success: true, plugins: rows });
104
+ },
105
+ );
106
+
107
+ // POST /api/plugins/:id/toggle — write config.plugins.<id>.enabled.
108
+ //
109
+ // Honors dependency-graph cascade per Robert's add-plugin-activation-ui
110
+ // Layer 2: enabling cascades deps; disabling cascades dependents; enabling
111
+ // with a missing dep returns 409 with the blocker list.
112
+ fastify.post<{ Params: { id: string }; Body: { enabled?: boolean } }>(
113
+ "/api/plugins/:id/toggle",
114
+ { preHandler: networkGuard },
115
+ async (request, reply) => {
116
+ const { id } = request.params;
117
+ const body = request.body ?? {};
118
+
119
+ if (typeof body.enabled !== "boolean") {
120
+ return reply
121
+ .status(400)
122
+ .send({ success: false, error: "body.enabled must be boolean" });
123
+ }
124
+
125
+ const plugins = discoverPlugins(repoRoot);
126
+ const found = plugins.find((p) => p.manifest.id === id);
127
+ if (!found) {
128
+ return reply
129
+ .status(404)
130
+ .send({ success: false, error: `Plugin "${id}" not found` });
131
+ }
132
+
133
+ const existing = readRawConfig();
134
+ const existingPlugins =
135
+ (existing.plugins as Record<string, unknown> | undefined) ?? {};
136
+
137
+ function isEnabled(pid: string): boolean {
138
+ const cfg = existingPlugins[pid] as Record<string, unknown> | undefined;
139
+ return cfg?.enabled !== false;
140
+ }
141
+
142
+ const graph = buildGraph(
143
+ plugins.map((p) => ({
144
+ id: p.manifest.id,
145
+ dependsOn: p.manifest.dependsOn ?? [],
146
+ })),
147
+ isEnabled,
148
+ );
149
+ const impact = computeToggleImpact(graph, id, body.enabled);
150
+
151
+ if (body.enabled && impact.blockers.length > 0) {
152
+ return reply
153
+ .status(409)
154
+ .send({ success: false, reason: "blockers", blockers: impact.blockers });
155
+ }
156
+
157
+ // Atomic cascade write: collect every id whose `enabled` flips, write
158
+ // them all in a single config write, then emit one plugin_config_update
159
+ // per affected id.
160
+ const flips: Array<{ id: string; enabled: boolean }> = [
161
+ { id, enabled: body.enabled },
162
+ ];
163
+ if (body.enabled) {
164
+ for (const dep of impact.cascadeEnable) flips.push({ id: dep, enabled: true });
165
+ } else {
166
+ for (const dep of impact.cascadeDisable) flips.push({ id: dep, enabled: false });
167
+ }
168
+
169
+ const nextPlugins: Record<string, unknown> = { ...existingPlugins };
170
+ const mergedPerId = new Map<string, Record<string, unknown>>();
171
+ for (const flip of flips) {
172
+ const prev = (nextPlugins[flip.id] as Record<string, unknown> | undefined) ?? {};
173
+ const merged = { ...prev, enabled: flip.enabled };
174
+ nextPlugins[flip.id] = merged;
175
+ mergedPerId.set(flip.id, merged);
176
+ }
177
+ writeRawConfig({ ...existing, plugins: nextPlugins });
178
+
179
+ for (const [flipId, merged] of mergedPerId) {
180
+ broadcast({ type: "plugin_config_update", id: flipId, config: merged });
181
+ }
182
+
183
+ return reply.status(200).send({
184
+ success: true,
185
+ restartRequired: true,
186
+ cascade: {
187
+ ...(body.enabled ? { enable: impact.cascadeEnable } : {}),
188
+ ...(!body.enabled ? { disable: impact.cascadeDisable } : {}),
189
+ },
190
+ });
191
+ },
192
+ );
193
+ }
@@ -173,6 +173,24 @@ async function enrichEntry(
173
173
  }
174
174
  }
175
175
 
176
+ // Cross-reference companion dashboard plugin (Layer 1.5 of
177
+ // add-plugin-activation-ui). When the entry names a `dashboardPlugin`,
178
+ // look it up in the plugin status store so the install browser can show
179
+ // a "+plugin" badge that knows whether the plugin is currently present.
180
+ let dashboardPluginInstalled: boolean | undefined;
181
+ if (entry.dashboardPlugin) {
182
+ try {
183
+ const { getPluginStatusStore } = await import(
184
+ "@blackbelt-technology/dashboard-plugin-runtime/server"
185
+ );
186
+ dashboardPluginInstalled = Boolean(
187
+ getPluginStatusStore().getStatus(entry.dashboardPlugin),
188
+ );
189
+ } catch {
190
+ dashboardPluginInstalled = false;
191
+ }
192
+ }
193
+
176
194
  return {
177
195
  ...entry,
178
196
  description,
@@ -180,6 +198,9 @@ async function enrichEntry(
180
198
  installed: { scope: installedScope },
181
199
  activeInPi,
182
200
  updateAvailable,
201
+ ...(entry.dashboardPlugin
202
+ ? { dashboardPluginInstalled: dashboardPluginInstalled ?? false }
203
+ : {}),
183
204
  };
184
205
  }
185
206
 
@@ -22,9 +22,53 @@ import path from "node:path";
22
22
  import os from "node:os";
23
23
  import { localhostGuard, netmaskToCidrBits, networkAddress } from "../localhost-guard.js";
24
24
  import { readSpawnFailures } from "../spawn-failure-log.js";
25
- import { getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
25
+ import {
26
+ getPluginStatusStore,
27
+ discoverPlugins,
28
+ pluginRegistryHash,
29
+ } from "@blackbelt-technology/dashboard-plugin-runtime/server";
30
+ import { classifyBridgeSource } from "@blackbelt-technology/pi-dashboard-shared/plugin-bridge-register.js";
31
+ import fs from "node:fs";
32
+ import type { BridgeLoadSource, PluginStatus } from "@blackbelt-technology/pi-dashboard-shared/dashboard-plugin/plugin-status.js";
26
33
  import type { NetworkInterface } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
27
- import type { BootstrapStateStore } from "../bootstrap-state.js";
34
+ import { parseLaunchSource } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
35
+
36
+ /**
37
+ * Enrich each plugin status with `bridgeLoadedFrom` by classifying the
38
+ * plugin's resolved bridge path against the live pi settings.json.
39
+ *
40
+ * Reads settings.json once per health call; cached `discoverPlugins()`
41
+ * result keeps the bridge path lookup O(1).
42
+ *
43
+ * See change: fix-pi-flows-end-to-end (Group 2, task 2.4).
44
+ */
45
+ function enrichWithBridgeSource(statuses: PluginStatus[]): PluginStatus[] {
46
+ let settings: unknown = null;
47
+ try {
48
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
49
+ const p = path.join(home, ".pi", "agent", "settings.json");
50
+ if (fs.existsSync(p)) {
51
+ const raw = fs.readFileSync(p, "utf-8").trim();
52
+ if (raw) settings = JSON.parse(raw);
53
+ }
54
+ } catch {
55
+ settings = null;
56
+ }
57
+
58
+ const plugins = discoverPlugins();
59
+ const bridgePaths = new Map<string, string>();
60
+ for (const p of plugins) {
61
+ if (p.bridgeEntryPath) bridgePaths.set(p.manifest.id, p.bridgeEntryPath);
62
+ }
63
+
64
+ return statuses.map((s) => {
65
+ const bp = bridgePaths.get(s.id);
66
+ const bridgeLoadedFrom: BridgeLoadSource = bp
67
+ ? classifyBridgeSource(settings, bp)
68
+ : "none";
69
+ return { ...s, bridgeLoadedFrom };
70
+ });
71
+ }
28
72
 
29
73
  export function registerSystemRoutes(
30
74
  fastify: FastifyInstance,
@@ -37,10 +81,9 @@ export function registerSystemRoutes(
37
81
  version?: string;
38
82
  directoryService?: DirectoryService;
39
83
  piGateway?: PiGateway;
40
- bootstrapState?: BootstrapStateStore;
41
84
  },
42
85
  ) {
43
- const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway, bootstrapState } = deps;
86
+ const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway } = deps;
44
87
 
45
88
  // Quiesce windows for the bridge `server_restarting` broadcast. See change
46
89
  // `fix-restart-bridge-auto-start-race`. Bridges that receive this message
@@ -233,10 +276,17 @@ export function registerSystemRoutes(
233
276
  return {
234
277
  ok: true,
235
278
  pid: process.pid,
236
- starter: bootstrapState?.get().starter ?? "Standalone",
237
- installable: bootstrapState?.get().installable,
279
+ // launchSource: single source of truth for arm-aware client gating
280
+ // (e.g. hide pi-core update UI under Electron, since bundled
281
+ // node_modules/ is read-only). See change:
282
+ // eliminate-electron-runtime-install task 3.2.
283
+ launchSource: parseLaunchSource(process.env),
238
284
  version: version ?? "unknown",
239
285
  uptime: Math.floor((Date.now() - serverStartTime) / 1000),
286
+ // ISO timestamp of process start. Used by the Plugins tab to detect
287
+ // server restarts and clear the Restart-required banner.
288
+ // See change: add-plugin-activation-ui.
289
+ startedAt: new Date(serverStartTime).toISOString(),
240
290
  mode: config.dev ? "dev" : "production",
241
291
  server: {
242
292
  rss: mem.rss,
@@ -246,7 +296,19 @@ export function registerSystemRoutes(
246
296
  totalSessions: sessionManager.listAll().length,
247
297
  },
248
298
  agents: agentMetrics,
249
- plugins: getPluginStatusStore().listAll(),
299
+ plugins: enrichWithBridgeSource(getPluginStatusStore().listAll()),
300
+ // Build-time-vs-runtime plugin-bundle hash. Clients compare it to
301
+ // the embedded `PLUGIN_REGISTRY_HASH` to detect stale bundles.
302
+ // See change: fix-pi-flows-end-to-end (Group 6).
303
+ // Must hash over the SAME plugin set the vite-plugin used at build
304
+ // time — production builds exclude `fixture: true` plugins (e.g. demo).
305
+ // Without this filter, the runtime hash would differ from the embedded
306
+ // PLUGIN_REGISTRY_HASH and the staleness banner would always show.
307
+ bundleHash: pluginRegistryHash(
308
+ discoverPlugins().filter((p) =>
309
+ config.dev ? true : p.manifest.fixture !== true,
310
+ ),
311
+ ),
250
312
  proxy: getModelProxyStatus(),
251
313
  };
252
314
  });
@@ -276,13 +338,13 @@ export function registerSystemRoutes(
276
338
  "/api/electron/reextract",
277
339
  { preHandler: networkGuard },
278
340
  async (_request, reply) => {
279
- const starter = bootstrapState?.get().starter ?? "Standalone";
280
- if (starter !== "Electron") {
341
+ const launchSource = parseLaunchSource(process.env);
342
+ if (launchSource !== "electron") {
281
343
  reply.status(403);
282
344
  return {
283
345
  error: "reextract_not_allowed",
284
- message: `Re-extract is only available when the server was started by Electron (current starter: ${starter})`,
285
- starter,
346
+ message: `Re-extract is only available when the server was started by Electron (current launchSource: ${launchSource})`,
347
+ launchSource,
286
348
  };
287
349
  }
288
350
  reply.status(202);