@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1

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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  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__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { handleSubscribe } from "../browser-handlers/subscription-handler.js";
2
+ import { handleSubscribe, replaySessionAssets } from "../browser-handlers/subscription-handler.js";
3
3
  import { createMemoryEventStore } from "../memory-event-store.js";
4
4
  import { createMemorySessionManager } from "../memory-session-manager.js";
5
5
  import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
@@ -30,16 +30,18 @@ function createMockContext(overrides: Partial<BrowserHandlerContext> = {}): Brow
30
30
  }
31
31
 
32
32
  describe("handleSubscribe — metadata requests on subscribe", () => {
33
- it("sends request_commands, request_models, and request_roles to piGateway", () => {
33
+ it("sends request_commands, request_models, request_providers, and request_roles to piGateway", () => {
34
+ // request_providers added by change: replace-hardcoded-provider-lists.
34
35
  const ctx = createMockContext();
35
36
  const subs = new Set<string>();
36
37
  handleSubscribe({ type: "subscribe", sessionId: "s1" }, subs, ctx);
37
38
 
38
39
  const calls = (ctx.piGateway.sendToSession as any).mock.calls;
39
- expect(calls).toHaveLength(3);
40
+ expect(calls).toHaveLength(4);
40
41
  expect(calls[0]).toEqual(["s1", { type: "request_commands", sessionId: "s1" }]);
41
42
  expect(calls[1]).toEqual(["s1", { type: "request_models", sessionId: "s1" }]);
42
- expect(calls[2]).toEqual(["s1", { type: "request_roles", sessionId: "s1" }]);
43
+ expect(calls[2]).toEqual(["s1", { type: "request_providers", sessionId: "s1" }]);
44
+ expect(calls[3]).toEqual(["s1", { type: "request_roles", sessionId: "s1" }]);
43
45
  });
44
46
  });
45
47
 
@@ -107,9 +109,17 @@ describe("handleSubscribe — stale lastSeq detection", () => {
107
109
  expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 5); // lastSent = 5
108
110
  });
109
111
 
110
- it("does not mark replaying for fresh subscribe (lastSeq: 0)", async () => {
112
+ it("marks replaying for fresh subscribe (lastSeq: 0) when events exist", async () => {
113
+ // Regression: cold subscribe must suppress live events during paginated
114
+ // replay. Without suppression, a live `event` arriving between batches
115
+ // bumps the client's maxSeq past the next batch's firstSeq, triggering
116
+ // the `firstSeq <= maxSeq` reset rule on the client which wipes state
117
+ // and rebuilds from only the last batch — leaving the chat showing
118
+ // only the tail messages.
119
+ // See change: fix-cold-subscribe-replay-interleave.
111
120
  const markReplaying = vi.fn();
112
- const ctx = createMockContext({ markReplaying });
121
+ const clearReplaying = vi.fn();
122
+ const ctx = createMockContext({ markReplaying, clearReplaying });
113
123
  for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent());
114
124
 
115
125
  const subs = new Set<string>();
@@ -117,6 +127,20 @@ describe("handleSubscribe — stale lastSeq detection", () => {
117
127
 
118
128
  await new Promise((r) => setTimeout(r, 50));
119
129
 
130
+ expect(markReplaying).toHaveBeenCalledWith(ctx.ws, "s1");
131
+ expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 3); // lastSent = 3
132
+ });
133
+
134
+ it("does not mark replaying for fresh subscribe when there are no events", async () => {
135
+ const markReplaying = vi.fn();
136
+ const ctx = createMockContext({ markReplaying });
137
+ // No events inserted — hasEvents() returns false; falls through to the
138
+ // empty-session branch.
139
+ const subs = new Set<string>();
140
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, subs, ctx);
141
+
142
+ await new Promise((r) => setTimeout(r, 50));
143
+
120
144
  expect(markReplaying).not.toHaveBeenCalled();
121
145
  });
122
146
 
@@ -193,3 +217,71 @@ describe("handleSubscribe — stale lastSeq detection", () => {
193
217
  expect(allEvents).toHaveLength(3);
194
218
  });
195
219
  });
220
+
221
+ // chat-markdown-local-images-and-math
222
+ describe("replaySessionAssets — emits one asset_register per Session.assets entry", () => {
223
+ it("sends nothing when session has no assets", () => {
224
+ const ctx = createMockContext();
225
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
226
+ replaySessionAssets({} as any, "s1", ctx);
227
+ expect((ctx.sendTo as any).mock.calls).toHaveLength(0);
228
+ });
229
+
230
+ it("sends one asset_register per asset on the session", () => {
231
+ const ctx = createMockContext();
232
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
233
+ ctx.sessionManager.update("s1", {
234
+ assets: {
235
+ abc: { data: "AAAA", mimeType: "image/png" },
236
+ def: { data: "BBBB", mimeType: "image/svg+xml" },
237
+ },
238
+ } as any);
239
+ const ws = {} as any;
240
+ replaySessionAssets(ws, "s1", ctx);
241
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
242
+ const assetMsgs = calls.filter(([, m]) => m.type === "asset_register");
243
+ expect(assetMsgs).toHaveLength(2);
244
+ const byHash = Object.fromEntries(assetMsgs.map(([, m]: any) => [m.hash, m]));
245
+ expect(byHash.abc).toMatchObject({ data: "AAAA", mimeType: "image/png", sessionId: "s1" });
246
+ expect(byHash.def).toMatchObject({ data: "BBBB", mimeType: "image/svg+xml", sessionId: "s1" });
247
+ });
248
+
249
+ it("skips malformed asset entries defensively", () => {
250
+ const ctx = createMockContext();
251
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
252
+ // Force a malformed entry past the type check.
253
+ ctx.sessionManager.update("s1", {
254
+ assets: {
255
+ good: { data: "AAAA", mimeType: "image/png" },
256
+ bad: { data: 123, mimeType: "image/png" } as any,
257
+ },
258
+ } as any);
259
+ replaySessionAssets({} as any, "s1", ctx);
260
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
261
+ const assetMsgs = calls.filter(([, m]) => m.type === "asset_register");
262
+ expect(assetMsgs).toHaveLength(1);
263
+ expect((assetMsgs[0][1] as any).hash).toBe("good");
264
+ });
265
+ });
266
+
267
+ describe("handleSubscribe — asset replay precedes events", () => {
268
+ it("sends asset_register messages before event_replay batches", async () => {
269
+ const ctx = createMockContext();
270
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
271
+ ctx.sessionManager.update("s1", {
272
+ assets: { h1: { data: "AAAA", mimeType: "image/png" } },
273
+ } as any);
274
+ ctx.eventStore.insertEvent("s1", makeEvent("message_update"));
275
+
276
+ const subs = new Set<string>();
277
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, subs, ctx);
278
+ await new Promise((r) => setTimeout(r, 50));
279
+
280
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
281
+ const firstAssetIdx = calls.findIndex(([, m]) => m.type === "asset_register");
282
+ const firstEventIdx = calls.findIndex(([, m]) => m.type === "event_replay");
283
+ expect(firstAssetIdx).toBeGreaterThanOrEqual(0);
284
+ expect(firstEventIdx).toBeGreaterThanOrEqual(0);
285
+ expect(firstAssetIdx).toBeLessThan(firstEventIdx);
286
+ });
287
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tests for POST /api/electron/reextract
3
+ * See change: simplify-electron-bootstrap-derived-state (task 6.4 / 6.9).
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import Fastify, { type FastifyInstance } from "fastify";
7
+ import { registerSystemRoutes } from "../routes/system-routes.js";
8
+ import type { BootstrapStateStore, BootstrapState } from "../bootstrap-state.js";
9
+
10
+ function noGuard() {
11
+ return async () => { /* allow all */ };
12
+ }
13
+
14
+ function makeBootstrapState(starter: string): BootstrapStateStore {
15
+ return {
16
+ get: () => ({
17
+ status: "ready",
18
+ starter: starter as any,
19
+ installable: { total: 0, installed: 0, failed: [] },
20
+ } as BootstrapState),
21
+ set: () => {},
22
+ subscribe: () => () => {},
23
+ } as unknown as BootstrapStateStore;
24
+ }
25
+
26
+ function makeNoopDeps(bootstrapState?: BootstrapStateStore) {
27
+ return {
28
+ sessionManager: { listActive: () => [], listAll: () => [] } as never,
29
+ preferencesStore: { flush: () => {} } as never,
30
+ metaPersistence: { flushAll: () => {} } as never,
31
+ config: { port: 8000, piPort: 9999, dev: false } as never,
32
+ networkGuard: noGuard(),
33
+ bootstrapState,
34
+ };
35
+ }
36
+
37
+ describe("POST /api/electron/reextract", () => {
38
+ let fastify: FastifyInstance;
39
+
40
+ beforeEach(async () => {
41
+ fastify = Fastify();
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await fastify.close();
46
+ });
47
+
48
+ it("returns 403 when starter is Bridge", async () => {
49
+ const deps = makeNoopDeps(makeBootstrapState("Bridge"));
50
+ registerSystemRoutes(fastify, deps);
51
+ await fastify.ready();
52
+
53
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
54
+ expect(res.statusCode).toBe(403);
55
+ const body = res.json() as Record<string, unknown>;
56
+ expect(body.error).toBe("reextract_not_allowed");
57
+ expect(body.starter).toBe("Bridge");
58
+ });
59
+
60
+ it("returns 403 when starter is Standalone", async () => {
61
+ const deps = makeNoopDeps(makeBootstrapState("Standalone"));
62
+ registerSystemRoutes(fastify, deps);
63
+ await fastify.ready();
64
+
65
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
66
+ expect(res.statusCode).toBe(403);
67
+ const body = res.json() as Record<string, unknown>;
68
+ expect(body.error).toBe("reextract_not_allowed");
69
+ expect(body.starter).toBe("Standalone");
70
+ });
71
+
72
+ it("returns 202 when starter is Electron", async () => {
73
+ const deps = makeNoopDeps(makeBootstrapState("Electron"));
74
+ registerSystemRoutes(fastify, deps);
75
+ await fastify.ready();
76
+
77
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
78
+ expect(res.statusCode).toBe(202);
79
+ const body = res.json() as Record<string, unknown>;
80
+ expect(body.ok).toBe(true);
81
+ });
82
+
83
+ it("returns 403 when no bootstrapState (defaults to Standalone)", async () => {
84
+ const deps = makeNoopDeps(undefined);
85
+ registerSystemRoutes(fastify, deps);
86
+ await fastify.ready();
87
+
88
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
89
+ expect(res.statusCode).toBe(403);
90
+ });
91
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for GET /api/spawn-failures endpoint.
3
+ * See change: spawn-failure-diagnostics.
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import Fastify, { type FastifyInstance } from "fastify";
7
+ import { registerSystemRoutes } from "../routes/system-routes.js";
8
+
9
+ // Mock the spawn-failure-log module.
10
+ vi.mock("../spawn-failure-log.js", () => ({
11
+ readSpawnFailures: vi.fn().mockReturnValue([]),
12
+ appendSpawnFailure: vi.fn(),
13
+ SpawnFailureEntry: undefined,
14
+ }));
15
+
16
+ import { readSpawnFailures } from "../spawn-failure-log.js";
17
+
18
+ const mockReadSpawnFailures = vi.mocked(readSpawnFailures);
19
+
20
+ function makeNoopDeps() {
21
+ return {
22
+ sessionManager: {} as never,
23
+ preferencesStore: { flush: () => {} } as never,
24
+ metaPersistence: { flushAll: () => {} } as never,
25
+ config: { port: 8000, piPort: 9999, dev: false } as never,
26
+ directoryService: {} as never,
27
+ piGateway: {
28
+ broadcast: vi.fn(),
29
+ announceRestart: vi.fn(),
30
+ } as never,
31
+ idleTimer: {} as never,
32
+ serverVersion: "test",
33
+ localhostGuard: () => async () => {},
34
+ tunnelStatus: () => ({ active: false }),
35
+ serverConfig: { dev: false } as never,
36
+ pluginStatusStore: { getAll: () => [] } as never,
37
+ };
38
+ }
39
+
40
+ describe("GET /api/spawn-failures", () => {
41
+ let app: FastifyInstance;
42
+
43
+ beforeEach(async () => {
44
+ vi.clearAllMocks();
45
+ app = Fastify({ logger: false });
46
+ registerSystemRoutes(app, makeNoopDeps() as never);
47
+ await app.ready();
48
+ });
49
+
50
+ afterEach(async () => {
51
+ await app.close();
52
+ });
53
+
54
+ it("returns empty entries when no log exists", async () => {
55
+ mockReadSpawnFailures.mockReturnValue([]);
56
+ const res = await app.inject({ method: "GET", url: "/api/spawn-failures" });
57
+ expect(res.statusCode).toBe(200);
58
+ const body = JSON.parse(res.body);
59
+ expect(body).toHaveProperty("entries");
60
+ expect(body.entries).toEqual([]);
61
+ expect(mockReadSpawnFailures).toHaveBeenCalledWith(50);
62
+ });
63
+
64
+ it("passes custom limit", async () => {
65
+ mockReadSpawnFailures.mockReturnValue([]);
66
+ await app.inject({ method: "GET", url: "/api/spawn-failures?limit=10" });
67
+ expect(mockReadSpawnFailures).toHaveBeenCalledWith(10);
68
+ });
69
+
70
+ it("falls back to default limit on NaN", async () => {
71
+ mockReadSpawnFailures.mockReturnValue([]);
72
+ await app.inject({ method: "GET", url: "/api/spawn-failures?limit=abc" });
73
+ expect(mockReadSpawnFailures).toHaveBeenCalledWith(50);
74
+ });
75
+
76
+ it("returns entries from the log", async () => {
77
+ const entry = { ts: "2026-01-01T00:00:00Z", cwd: "/p/x", strategy: "headless", code: "PI_CRASHED", message: "crashed" };
78
+ mockReadSpawnFailures.mockReturnValue([entry] as never);
79
+ const res = await app.inject({ method: "GET", url: "/api/spawn-failures" });
80
+ const body = JSON.parse(res.body);
81
+ expect(body.entries).toHaveLength(1);
82
+ expect(body.entries[0].code).toBe("PI_CRASHED");
83
+ });
84
+ });
@@ -242,6 +242,51 @@ describe("TerminalManager", () => {
242
242
  handlers.message(resizeMsg, false);
243
243
  expect(mockPtyResize).toHaveBeenCalledWith(120, 40);
244
244
  });
245
+
246
+ // Resize floor — see change: fix-terminal-half-height-dual-mount.
247
+ // PTYs at <2 cols/rows are non-functional for every supported shell
248
+ // and the most common cause is a transient display:none container
249
+ // measured by FitAddon during a route transition.
250
+ describe("resize floor", () => {
251
+ function attachAndSendResize(cols: number, rows: number) {
252
+ const session = manager.spawn("/tmp");
253
+ const handlers: Record<string, Function> = {};
254
+ const mockWs = {
255
+ send: vi.fn(),
256
+ on: vi.fn((event: string, cb: any) => { handlers[event] = cb; }),
257
+ readyState: 1,
258
+ OPEN: 1,
259
+ } as any;
260
+ manager.attach(session.id, mockWs);
261
+ const msg = Buffer.from(JSON.stringify({ type: "resize", cols, rows }));
262
+ handlers.message(msg, false);
263
+ }
264
+
265
+ it("ignores resize with cols below floor (cols=1)", () => {
266
+ attachAndSendResize(1, 24);
267
+ expect(mockPtyResize).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it("ignores resize with rows below floor (rows=0)", () => {
271
+ attachAndSendResize(80, 0);
272
+ expect(mockPtyResize).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("ignores resize with both dimensions below floor", () => {
276
+ attachAndSendResize(1, 1);
277
+ expect(mockPtyResize).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it("accepts resize at the floor (cols=2, rows=2)", () => {
281
+ attachAndSendResize(2, 2);
282
+ expect(mockPtyResize).toHaveBeenCalledWith(2, 2);
283
+ });
284
+
285
+ it("accepts a normal resize", () => {
286
+ attachAndSendResize(80, 24);
287
+ expect(mockPtyResize).toHaveBeenCalledWith(80, 24);
288
+ });
289
+ });
245
290
  });
246
291
 
247
292
  describe("PTY exit", () => {
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Bootstrap install reconciler driven by ~/.pi/dashboard/installable.json.
3
+ * Invoked by cli.ts before app.listen.
4
+ *
5
+ * File-absent path is a deliberate no-op: Bridge and Standalone starters
6
+ * never write installable.json; only Electron seeds it on first run.
7
+ * When the file is absent, this function logs and returns immediately so
8
+ * bootstrap.status transitions to "ready" without delay.
9
+ *
10
+ * See change: simplify-electron-bootstrap-derived-state.
11
+ */
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { createRequire } from "node:module";
15
+ import { getManagedDir } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
16
+ import {
17
+ readInstallableList,
18
+ type InstallablePackage,
19
+ } from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
20
+ import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
21
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
22
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
23
+
24
+ // ── Injectable helpers (overridable in tests) ──────────────────────────────
25
+
26
+ export type InstallProgressCallback = (line: string) => void;
27
+ export type PackageInstaller = (
28
+ pkg: InstallablePackage,
29
+ onOutput: InstallProgressCallback,
30
+ ) => Promise<void>;
31
+
32
+ // ── Installed-check ────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Return true if `pkgName` is resolvable from `managedDir/node_modules`.
36
+ * Version satisfies check is intentionally omitted (no semver dep) —
37
+ * we treat "resolves at all" as satisfied. This is sufficient for the
38
+ * Phase B bootstrap use case.
39
+ */
40
+ export function isNpmPackageInstalled(pkgName: string, managedDir: string): boolean {
41
+ try {
42
+ // createRequire resolves from the given path; look in managedDir/node_modules.
43
+ const req = createRequire(path.join(managedDir, "package.json"));
44
+ req.resolve(pkgName + "/package.json");
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // ── Default installers ─────────────────────────────────────────────────────
52
+
53
+ async function defaultNpmInstall(
54
+ pkg: InstallablePackage,
55
+ managedDir: string,
56
+ onOutput: InstallProgressCallback,
57
+ ): Promise<void> {
58
+ const spec =
59
+ pkg.version && pkg.version !== "*"
60
+ ? `${pkg.name}@${pkg.version}`
61
+ : pkg.name;
62
+ const res = await bootstrapInstall({
63
+ packages: [spec],
64
+ managedDir,
65
+ progress: (p) => {
66
+ if (p.output) onOutput(p.output);
67
+ },
68
+ });
69
+ if (!res.ok) {
70
+ throw new Error(res.error);
71
+ }
72
+ }
73
+
74
+ async function defaultPiExtensionInstall(
75
+ pkg: InstallablePackage,
76
+ onOutput: InstallProgressCallback,
77
+ ): Promise<void> {
78
+ const registry = getDefaultRegistry();
79
+ const { module: piModule } = await registry.resolveModule<{
80
+ DefaultPackageManager: any;
81
+ SettingsManager: any;
82
+ }>("pi-coding-agent");
83
+ const agentDir = path.join(os.homedir(), ".pi", "agent");
84
+ const settingsManager = piModule.SettingsManager.create(process.cwd(), agentDir);
85
+ const pm = new piModule.DefaultPackageManager({
86
+ cwd: process.cwd(),
87
+ agentDir,
88
+ settingsManager,
89
+ });
90
+ pm.setProgressCallback((event: { message?: string }) => {
91
+ if (event.message) onOutput(event.message);
92
+ });
93
+ await pm.installAndPersist(pkg.name, { local: false });
94
+ }
95
+
96
+ // ── Options ────────────────────────────────────────────────────────────────
97
+
98
+ export interface BootstrapInstallFromListOptions {
99
+ /** Override config dir for installable.json (default: ~/.pi/dashboard/). */
100
+ configDir?: string;
101
+ /** Override managed dir for npm installs (default: ~/.pi-dashboard/). */
102
+ managedDir?: string;
103
+ /**
104
+ * Injectable npm installer. Defaults to bootstrapInstall.
105
+ * Receives the InstallablePackage and a streaming output callback.
106
+ */
107
+ npmInstall?: PackageInstaller;
108
+ /**
109
+ * Injectable pi-extension installer. Defaults to pi DefaultPackageManager.
110
+ * Receives the InstallablePackage and a streaming output callback.
111
+ */
112
+ piInstall?: PackageInstaller;
113
+ /**
114
+ * Injectable installed-check for npm packages.
115
+ * Defaults to isNpmPackageInstalled.
116
+ */
117
+ isInstalled?: (pkg: InstallablePackage, managedDir: string) => boolean;
118
+ }
119
+
120
+ // ── Main reconciler ────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Reconcile packages from installable.json against the managed directory.
124
+ *
125
+ * - File absent: log and return immediately (not a failure).
126
+ * - Per package: check installed → skip or install.
127
+ * - Required failure: set bootstrap status=failed, throw (abort server start).
128
+ * - Optional failure: log, record in failed[], continue.
129
+ */
130
+ export async function bootstrapInstallFromList(
131
+ bootstrapState: BootstrapStateStore,
132
+ opts?: BootstrapInstallFromListOptions,
133
+ ): Promise<void> {
134
+ const configDir =
135
+ opts?.configDir ?? path.join(os.homedir(), ".pi", "dashboard");
136
+ const managedDir = opts?.managedDir ?? getManagedDir();
137
+
138
+ // Read installable.json; absent file is a deliberate no-op.
139
+ const list = await readInstallableList(configDir);
140
+ if (list === null) {
141
+ console.log(
142
+ "[bootstrap] bootstrap.installable.skipped reason=file-not-found",
143
+ );
144
+ return;
145
+ }
146
+
147
+ // Only process packages that are active (not deprecated, not defaultOff).
148
+ const packages = list.packages.filter((p) => !p.deprecated && !p.defaultOff);
149
+ const total = packages.length;
150
+ let installedCount = 0;
151
+ const failed: string[] = [];
152
+
153
+ // Stamp initial installable progress into bootstrap state.
154
+ bootstrapState.set({ installable: { total, installed: 0, failed: [] } });
155
+
156
+ const checkInstalled =
157
+ opts?.isInstalled ?? ((p, dir) => isNpmPackageInstalled(p.name, dir));
158
+ const doNpmInstall: PackageInstaller =
159
+ opts?.npmInstall ??
160
+ ((p, cb) => defaultNpmInstall(p, managedDir, cb));
161
+ const doPiInstall: PackageInstaller =
162
+ opts?.piInstall ?? defaultPiExtensionInstall;
163
+
164
+ for (const pkg of packages) {
165
+ // Fast path: already installed (npm packages only; pi-extension always attempts).
166
+ if (pkg.kind === "npm" && checkInstalled(pkg, managedDir)) {
167
+ console.log(
168
+ `[bootstrap] bootstrap.installable.package name=${pkg.name} status=satisfied`,
169
+ );
170
+ installedCount++;
171
+ bootstrapState.set({
172
+ installable: { total, installed: installedCount, failed },
173
+ });
174
+ continue;
175
+ }
176
+
177
+ // Emit installing progress.
178
+ bootstrapState.set({
179
+ progress: { step: pkg.name, output: "installing..." },
180
+ installable: { total, installed: installedCount, failed },
181
+ });
182
+
183
+ try {
184
+ const onOutput = (line: string): void => {
185
+ bootstrapState.set({ progress: { step: pkg.name, output: line } });
186
+ };
187
+
188
+ if (pkg.kind === "npm") {
189
+ await doNpmInstall(pkg, onOutput);
190
+ } else {
191
+ await doPiInstall(pkg, onOutput);
192
+ }
193
+
194
+ installedCount++;
195
+ bootstrapState.set({
196
+ progress: undefined,
197
+ installable: { total, installed: installedCount, failed },
198
+ });
199
+ console.log(
200
+ `[bootstrap] bootstrap.installable.package name=${pkg.name} status=done`,
201
+ );
202
+ } catch (err) {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ console.error(
205
+ `[bootstrap] bootstrap.installable.package name=${pkg.name} status=error error=${message}`,
206
+ );
207
+ failed.push(pkg.name);
208
+ bootstrapState.set({
209
+ progress: undefined,
210
+ installable: { total, installed: installedCount, failed: [...failed] },
211
+ });
212
+
213
+ if (pkg.required) {
214
+ const errorMessage = `Required package "${pkg.name}" failed to install: ${message}`;
215
+ bootstrapState.set({
216
+ status: "failed",
217
+ error: { message: errorMessage },
218
+ });
219
+ throw new Error(errorMessage);
220
+ }
221
+ // Optional package failure: log, continue to next package.
222
+ }
223
+ }
224
+
225
+ // Final state snapshot.
226
+ bootstrapState.set({
227
+ installable: { total, installed: installedCount, failed },
228
+ });
229
+ console.log(
230
+ `[bootstrap] bootstrap.installable.done total=${total} installed=${installedCount} failed=${failed.length}`,
231
+ );
232
+ }
@@ -1,3 +1,5 @@
1
+ import type { DashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
2
+
1
3
  /**
2
4
  * In-memory bootstrap state store for the dashboard server.
3
5
  *
@@ -51,6 +53,22 @@ export interface BootstrapState {
51
53
  compatibility?: BootstrapCompatibility;
52
54
  /** Set when `registerBridgeExtension` fails after a successful install. */
53
55
  bridgeRegistrationError?: string;
56
+ /**
57
+ * Who started this server process. Defaults to "Standalone" (direct CLI).
58
+ * Set at boot time from `parseDashboardStarter(process.env)`.
59
+ */
60
+ starter?: DashboardStarter;
61
+ /**
62
+ * Installable list reconciliation progress.
63
+ * Set by bootstrapInstallFromList during Phase B reconcile.
64
+ * See change: simplify-electron-bootstrap-derived-state.
65
+ */
66
+ installable?: {
67
+ total: number;
68
+ installed: number;
69
+ /** Package names that failed to install. */
70
+ failed: string[];
71
+ };
54
72
  }
55
73
 
56
74
  export type BootstrapListener = (state: BootstrapState) => void;