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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -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
+ });
@@ -25,7 +25,7 @@ function makeNoopDeps() {
25
25
  function makeFakeGateway(): { gateway: PiGateway; broadcasts: ServerToExtensionMessage[] } {
26
26
  const broadcasts: ServerToExtensionMessage[] = [];
27
27
  const gateway: PiGateway = {
28
- broadcast(msg) { broadcasts.push(msg); },
28
+ broadcast(msg: ServerToExtensionMessage) { broadcasts.push(msg); },
29
29
  sendToSession() { return false; },
30
30
  isSessionConnected() { return false; },
31
31
  connectionCount() { return 0; },
@@ -46,7 +46,7 @@ describe("POST /api/restart broadcasts server_restarting", () => {
46
46
  const fake = makeFakeGateway();
47
47
  broadcasts = fake.broadcasts;
48
48
  // process.exit is deferred via setTimeout(...,200); silence it for the test
49
- exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
49
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
50
50
  registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
51
51
  });
52
52
 
@@ -82,7 +82,7 @@ describe("POST /api/shutdown broadcasts server_restarting", () => {
82
82
  fastify = Fastify();
83
83
  const fake = makeFakeGateway();
84
84
  broadcasts = fake.broadcasts;
85
- exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
85
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
86
86
  registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
87
87
  });
88
88
 
@@ -111,7 +111,7 @@ describe("/api/restart works without piGateway (no-op broadcast)", () => {
111
111
 
112
112
  beforeEach(() => {
113
113
  fastify = Fastify();
114
- exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
114
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
115
115
  registerSystemRoutes(fastify, makeNoopDeps()); // no piGateway
116
116
  });
117
117
 
@@ -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;
@@ -15,7 +15,44 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
15
15
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
16
16
  import type { SessionOrderManager } from "./session-order-manager.js";
17
17
  import type { PreferencesStore } from "./preferences-store.js";
18
- import type { DirectoryService } from "./directory-service.js";
18
+ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
19
+
20
+ /**
21
+ * Pure helper: build the per-cwd `openspec_update` messages a freshly
22
+ * connecting browser should receive. One message per known cwd.
23
+ * Disambiguates three states:
24
+ * - cache populated → cached payload
25
+ * - openspec dir but cold → { initialized: false, pending: true }
26
+ * - no openspec dir → { initialized: false, pending: false }
27
+ *
28
+ * Exported so cold-boot snapshot semantics can be unit-tested without
29
+ * spinning up a WS server. See change: fix-cold-boot-openspec-protocol.
30
+ */
31
+ export function buildOpenSpecConnectSnapshot(
32
+ directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
33
+ hasDir: (cwd: string) => boolean,
34
+ ): Array<ServerToBrowserMessage> {
35
+ const out: Array<ServerToBrowserMessage> = [];
36
+ for (const cwd of directoryService.knownDirectories()) {
37
+ const cached = directoryService.getOpenSpecData(cwd);
38
+ if (cached && cached.initialized) {
39
+ out.push({ type: "openspec_update", cwd, data: cached });
40
+ } else if (hasDir(cwd)) {
41
+ out.push({
42
+ type: "openspec_update",
43
+ cwd,
44
+ data: { initialized: false, pending: true, changes: [] },
45
+ });
46
+ } else {
47
+ out.push({
48
+ type: "openspec_update",
49
+ cwd,
50
+ data: { initialized: false, pending: false, changes: [] },
51
+ });
52
+ }
53
+ }
54
+ return out;
55
+ }
19
56
  import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
20
57
  import { createViewedSessionTracker, type ViewedSessionTracker } from "./viewed-session-tracker.js";
21
58
  import type { TerminalManager } from "./terminal-manager.js";
@@ -208,10 +245,21 @@ export function createBrowserGateway(
208
245
  const subs = new Set<string>();
209
246
  subscriptions.set(ws, subs);
210
247
 
211
- // Send all sessions on connect (client filters by hidden flag)
212
- const allSessions = sessionManager.listAll();
213
- for (const session of allSessions) {
214
- sendTo(ws, { type: "session_added", session });
248
+ // Atomic snapshot of the full session registry + per-cwd orders.
249
+ // Replaces the legacy per-session `session_added` loop and per-cwd
250
+ // `sessions_reordered` loop. Client REPLACES (not merges) its
251
+ // `sessions` Map and `sessionOrderMap` on receipt so stale ids from a
252
+ // previous server lifetime are dropped atomically.
253
+ // See change: fix-stale-sessions-on-reconnect.
254
+ {
255
+ const sessionsSnapshot = sessionManager.listAll();
256
+ const orders: Record<string, string[]> = {};
257
+ if (sessionOrderManager) {
258
+ for (const [cwd, sessionIds] of Object.entries(sessionOrderManager.getAllOrders())) {
259
+ if (sessionIds.length > 0) orders[cwd] = sessionIds;
260
+ }
261
+ }
262
+ sendTo(ws, { type: "sessions_snapshot", sessions: sessionsSnapshot, orders });
215
263
  }
216
264
 
217
265
  // Send pinned directories on connect
@@ -219,23 +267,12 @@ export function createBrowserGateway(
219
267
  sendTo(ws, { type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
220
268
  }
221
269
 
222
- // Send session orders for all cwds
223
- if (sessionOrderManager) {
224
- const allOrders = sessionOrderManager.getAllOrders();
225
- for (const [cwd, sessionIds] of Object.entries(allOrders)) {
226
- if (sessionIds.length > 0) {
227
- sendTo(ws, { type: "sessions_reordered", cwd, sessionIds });
228
- }
229
- }
230
- }
231
-
232
- // Send cached OpenSpec data for all known directories
270
+ // Send OpenSpec data for every known directory — exactly one
271
+ // `openspec_update` per cwd, never silently omit.
272
+ // See change: fix-cold-boot-openspec-protocol.
233
273
  if (directoryService) {
234
- for (const cwd of directoryService.knownDirectories()) {
235
- const data = directoryService.getOpenSpecData(cwd);
236
- if (data && data.initialized) {
237
- sendTo(ws, { type: "openspec_update", cwd, data });
238
- }
274
+ for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
275
+ sendTo(ws, msg);
239
276
  }
240
277
  }
241
278
 
@@ -148,6 +148,10 @@ export function handlePiGatewayForward(
148
148
  case "request_models":
149
149
  piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
150
150
  break;
151
+ case "request_providers":
152
+ // See change: replace-hardcoded-provider-lists.
153
+ piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
154
+ break;
151
155
  case "set_thinking_level":
152
156
  piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level });
153
157
  break;