@blackbelt-technology/pi-agent-dashboard 0.2.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 (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { WebSocket } from "ws";
3
+ import { createServer, type DashboardServer } from "../server.js";
4
+
5
+ /**
6
+ * Helper: connect a pi session via WebSocket and register it.
7
+ */
8
+ async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
9
+ const ws = new WebSocket(`ws://localhost:${piPort}`);
10
+ await new Promise<void>((resolve) => {
11
+ ws.on("open", () => {
12
+ ws.send(JSON.stringify({
13
+ type: "session_register",
14
+ sessionId,
15
+ cwd: "/tmp",
16
+ source: "cli",
17
+ }));
18
+ setTimeout(resolve, 50);
19
+ });
20
+ });
21
+ return ws;
22
+ }
23
+
24
+ /**
25
+ * Helper: send a tool_execution_start event that triggers OpenSpec detection.
26
+ * Uses a Read tool on a SKILL.md path to trigger phase detection,
27
+ * or a Read/Write on an openspec/changes/ path for changeName detection.
28
+ */
29
+ function sendToolEvent(ws: WebSocket, sessionId: string, opts: { phase?: string; changeName?: string }) {
30
+ if (opts.phase) {
31
+ // Map phase back to skill name suffix for detection
32
+ const phaseToSuffix: Record<string, string> = {
33
+ apply: "apply-change",
34
+ archive: "archive-change",
35
+ continue: "continue-change",
36
+ explore: "explore",
37
+ ff: "ff-change",
38
+ new: "new-change",
39
+ verify: "verify-change",
40
+ };
41
+ const suffix = phaseToSuffix[opts.phase] ?? opts.phase;
42
+ ws.send(JSON.stringify({
43
+ type: "event_forward",
44
+ sessionId,
45
+ event: {
46
+ eventType: "tool_execution_start",
47
+ timestamp: Date.now(),
48
+ data: {
49
+ toolName: "Read",
50
+ args: { path: `.pi/skills/openspec-${suffix}/SKILL.md` },
51
+ },
52
+ },
53
+ }));
54
+ }
55
+ if (opts.changeName) {
56
+ ws.send(JSON.stringify({
57
+ type: "event_forward",
58
+ sessionId,
59
+ event: {
60
+ eventType: "tool_execution_start",
61
+ timestamp: Date.now(),
62
+ data: {
63
+ toolName: "Read",
64
+ args: { path: `openspec/changes/${opts.changeName}/proposal.md` },
65
+ },
66
+ },
67
+ }));
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Helper: send a detach_proposal via browser gateway.
73
+ */
74
+ async function sendDetach(browserPort: number, sessionId: string): Promise<void> {
75
+ const ws = new WebSocket(`ws://localhost:${browserPort}/ws`);
76
+ await new Promise<void>((resolve) => {
77
+ ws.on("open", () => {
78
+ ws.send(JSON.stringify({
79
+ type: "detach_proposal",
80
+ sessionId,
81
+ }));
82
+ setTimeout(resolve, 50);
83
+ });
84
+ });
85
+ ws.close();
86
+ }
87
+
88
+ describe("Auto-attach from openspec activity", () => {
89
+ let server: DashboardServer;
90
+ let piPort: number;
91
+ let browserPort: number;
92
+ let ws: WebSocket;
93
+
94
+ let testPort = 18800;
95
+
96
+ beforeEach(async () => {
97
+ testPort += 2;
98
+ browserPort = testPort;
99
+ piPort = testPort + 1;
100
+ server = await createServer({
101
+ port: browserPort,
102
+ piPort,
103
+ dev: true,
104
+ autoShutdown: false,
105
+ shutdownIdleSeconds: 999,
106
+ tunnel: false,
107
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
108
+ });
109
+ await server.start();
110
+ ws = await connectSession(piPort, "s1");
111
+ });
112
+
113
+ afterEach(async () => {
114
+ ws.close();
115
+ await server.stop();
116
+ });
117
+
118
+ it("auto-attaches when phase and changeName arrive in separate events", async () => {
119
+ // Send phase only (via skill file read)
120
+ sendToolEvent(ws, "s1", { phase: "apply" });
121
+ await new Promise((r) => setTimeout(r, 80));
122
+
123
+ let session = server.sessionManager.get("s1");
124
+ expect(session?.attachedProposal).toBeFalsy();
125
+
126
+ // Send changeName only (via change file read)
127
+ sendToolEvent(ws, "s1", { changeName: "add-auth" });
128
+ await new Promise((r) => setTimeout(r, 80));
129
+
130
+ session = server.sessionManager.get("s1");
131
+ expect(session?.attachedProposal).toBe("add-auth");
132
+ });
133
+
134
+ it("auto-attaches when only changeName is detected (no phase)", async () => {
135
+ // Only send changeName — no phase event at all
136
+ // This happens when a skill is loaded via prompt template (no SKILL.md read tool event)
137
+ sendToolEvent(ws, "s1", { changeName: "my-feature" });
138
+ await new Promise((r) => setTimeout(r, 80));
139
+
140
+ const session = server.sessionManager.get("s1");
141
+ expect(session?.attachedProposal).toBe("my-feature");
142
+ });
143
+
144
+ it("auto-attaches when both arrive from a single tool event", async () => {
145
+ // A single tool event can only detect one thing at a time (phase OR changeName),
146
+ // so we send two events in quick succession
147
+ sendToolEvent(ws, "s1", { phase: "apply" });
148
+ sendToolEvent(ws, "s1", { changeName: "add-auth" });
149
+ await new Promise((r) => setTimeout(r, 80));
150
+
151
+ const session = server.sessionManager.get("s1");
152
+ expect(session?.attachedProposal).toBe("add-auth");
153
+ });
154
+
155
+ it("auto-names session from changeName when name is blank", async () => {
156
+ sendToolEvent(ws, "s1", { changeName: "cool-feature" });
157
+ await new Promise((r) => setTimeout(r, 80));
158
+
159
+ const session = server.sessionManager.get("s1");
160
+ expect(session?.attachedProposal).toBe("cool-feature");
161
+ expect(session?.name).toBe("cool-feature");
162
+ });
163
+
164
+ it("does not auto-attach when proposal is already attached", async () => {
165
+ // First, attach
166
+ sendToolEvent(ws, "s1", { phase: "apply" });
167
+ sendToolEvent(ws, "s1", { changeName: "add-auth" });
168
+ await new Promise((r) => setTimeout(r, 80));
169
+
170
+ // Try to attach a different change
171
+ sendToolEvent(ws, "s1", { changeName: "other-change" });
172
+ await new Promise((r) => setTimeout(r, 80));
173
+
174
+ const session = server.sessionManager.get("s1");
175
+ expect(session?.attachedProposal).toBe("add-auth");
176
+ });
177
+ });
178
+
179
+ describe("Detach clears openspec state", () => {
180
+ let server: DashboardServer;
181
+ let piPort: number;
182
+ let browserPort: number;
183
+ let ws: WebSocket;
184
+
185
+ let testPort = 18900;
186
+
187
+ beforeEach(async () => {
188
+ testPort += 2;
189
+ browserPort = testPort;
190
+ piPort = testPort + 1;
191
+ server = await createServer({
192
+ port: browserPort,
193
+ piPort,
194
+ dev: true,
195
+ autoShutdown: false,
196
+ shutdownIdleSeconds: 999,
197
+ tunnel: false,
198
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
199
+ });
200
+ await server.start();
201
+ ws = await connectSession(piPort, "s1");
202
+ });
203
+
204
+ afterEach(async () => {
205
+ ws.close();
206
+ await server.stop();
207
+ });
208
+
209
+ it("clears openspecPhase and openspecChange on detach", async () => {
210
+ // Attach first
211
+ sendToolEvent(ws, "s1", { phase: "apply" });
212
+ sendToolEvent(ws, "s1", { changeName: "add-auth" });
213
+ await new Promise((r) => setTimeout(r, 80));
214
+
215
+ let session = server.sessionManager.get("s1");
216
+ expect(session?.attachedProposal).toBe("add-auth");
217
+
218
+ // Detach via browser
219
+ await sendDetach(browserPort, "s1");
220
+ await new Promise((r) => setTimeout(r, 80));
221
+
222
+ session = server.sessionManager.get("s1");
223
+ expect(session?.attachedProposal).toBeNull();
224
+ expect(session?.openspecPhase).toBeNull();
225
+ expect(session?.openspecChange).toBeNull();
226
+ });
227
+
228
+ it("allows re-attach after detach with new activity", async () => {
229
+ // Attach
230
+ sendToolEvent(ws, "s1", { phase: "apply" });
231
+ sendToolEvent(ws, "s1", { changeName: "add-auth" });
232
+ await new Promise((r) => setTimeout(r, 80));
233
+
234
+ // Detach
235
+ await sendDetach(browserPort, "s1");
236
+ await new Promise((r) => setTimeout(r, 80));
237
+
238
+ // New activity
239
+ sendToolEvent(ws, "s1", { phase: "ff" });
240
+ sendToolEvent(ws, "s1", { changeName: "new-change" });
241
+ await new Promise((r) => setTimeout(r, 80));
242
+
243
+ const session = server.sessionManager.get("s1");
244
+ expect(session?.attachedProposal).toBe("new-change");
245
+ });
246
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createPendingResumeRegistry } from "../pending-resume-registry.js";
3
+ import type { SessionManager } from "../memory-session-manager.js";
4
+ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
5
+
6
+ /**
7
+ * Tests for the auto-resume-on-prompt flow.
8
+ * These test the registry + orchestration logic without full server setup.
9
+ */
10
+
11
+ function createMockSession(overrides: Partial<DashboardSession> = {}): DashboardSession {
12
+ return {
13
+ id: "session-1",
14
+ cwd: "/project",
15
+ source: "tui",
16
+ status: "ended",
17
+ startedAt: Date.now(),
18
+ sessionFile: "/path/to/session.jsonl",
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("Auto-resume on prompt", () => {
24
+ beforeEach(() => {
25
+ vi.useFakeTimers();
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.useRealTimers();
30
+ });
31
+
32
+ describe("send_prompt to ended session triggers resume flow", () => {
33
+ it("records pending resume with prompt data", () => {
34
+ const registry = createPendingResumeRegistry();
35
+ const session = createMockSession();
36
+
37
+ registry.record(session.cwd, {
38
+ text: "fix the bug",
39
+ oldSessionId: session.id,
40
+ sessionFile: session.sessionFile!,
41
+ });
42
+
43
+ const entry = registry.consume(session.cwd);
44
+ expect(entry).toBeDefined();
45
+ expect(entry!.text).toBe("fix the bug");
46
+ expect(entry!.oldSessionId).toBe("session-1");
47
+ expect(entry!.sessionFile).toBe("/path/to/session.jsonl");
48
+ });
49
+
50
+ it("records pending resume with images", () => {
51
+ const registry = createPendingResumeRegistry();
52
+ const session = createMockSession();
53
+ const images = [{ type: "image" as const, data: "base64", mimeType: "image/png" }];
54
+
55
+ registry.record(session.cwd, {
56
+ text: "look at this",
57
+ images,
58
+ oldSessionId: session.id,
59
+ sessionFile: session.sessionFile!,
60
+ });
61
+
62
+ const entry = registry.consume(session.cwd);
63
+ expect(entry!.images).toEqual(images);
64
+ });
65
+
66
+ it("does not record when session has no sessionFile", () => {
67
+ const registry = createPendingResumeRegistry();
68
+ const session = createMockSession({ sessionFile: undefined });
69
+
70
+ // The browser-gateway checks sessionFile before calling record,
71
+ // so this tests the precondition
72
+ expect(session.sessionFile).toBeUndefined();
73
+ });
74
+ });
75
+
76
+ describe("session_register with pending resume flushes prompt", () => {
77
+ it("consumes entry for matching cwd and returns prompt data", () => {
78
+ const registry = createPendingResumeRegistry();
79
+
80
+ registry.record("/project", {
81
+ text: "fix the bug",
82
+ oldSessionId: "old-session",
83
+ sessionFile: "session.jsonl",
84
+ });
85
+
86
+ // Simulate new session registering with same cwd
87
+ const entry = registry.consume("/project");
88
+ expect(entry).toBeDefined();
89
+ expect(entry!.text).toBe("fix the bug");
90
+ expect(entry!.oldSessionId).toBe("old-session");
91
+
92
+ // Entry should be consumed (not available again)
93
+ expect(registry.consume("/project")).toBeUndefined();
94
+ });
95
+
96
+ it("returns undefined when no pending resume for cwd", () => {
97
+ const registry = createPendingResumeRegistry();
98
+ expect(registry.consume("/project")).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ describe("spawn failure clears resuming state", () => {
103
+ it("consume clears entry so timeout does not fire", () => {
104
+ const onTimeout = vi.fn();
105
+ const registry = createPendingResumeRegistry({ onTimeout });
106
+
107
+ registry.record("/project", {
108
+ text: "fix it",
109
+ oldSessionId: "old-1",
110
+ sessionFile: "session.jsonl",
111
+ });
112
+
113
+ // Simulate spawn failure: consume immediately to clear
114
+ registry.consume("/project");
115
+
116
+ // Advance past expiry — onTimeout should NOT fire since entry was consumed
117
+ vi.advanceTimersByTime(31_000);
118
+ expect(onTimeout).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("timeout fires onTimeout with oldSessionId when not consumed", () => {
122
+ const onTimeout = vi.fn();
123
+ const registry = createPendingResumeRegistry({ onTimeout });
124
+
125
+ registry.record("/project", {
126
+ text: "fix it",
127
+ oldSessionId: "old-1",
128
+ sessionFile: "session.jsonl",
129
+ });
130
+
131
+ vi.advanceTimersByTime(30_001);
132
+ expect(onTimeout).toHaveBeenCalledWith("old-1");
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { createServer, type ServerConfig, type DashboardServer } from "../server.js";
3
+
4
+ describe("Server auto-shutdown", () => {
5
+ let server: DashboardServer;
6
+ const baseConfig: ServerConfig = {
7
+ port: 0,
8
+ piPort: 0,
9
+ dev: true,
10
+ autoShutdown: true,
11
+ shutdownIdleSeconds: 2,
12
+ tunnel: false,
13
+ pingInterval: 0, // Disable WS ping to avoid fake/real timer conflicts
14
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
15
+ };
16
+
17
+ let testPort = 18700;
18
+
19
+ beforeEach(async () => {
20
+ vi.useFakeTimers();
21
+ testPort += 2;
22
+ server = await createServer({
23
+ ...baseConfig,
24
+ port: testPort,
25
+ piPort: testPort + 1,
26
+ });
27
+ });
28
+
29
+ afterEach(async () => {
30
+ vi.useRealTimers();
31
+ try {
32
+ await server.stop();
33
+ } catch {
34
+ // may already be stopped
35
+ }
36
+ });
37
+
38
+ it("should shut down after idle timeout when no sessions connect", async () => {
39
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
40
+
41
+ await server.start();
42
+
43
+ await vi.advanceTimersByTimeAsync(2000);
44
+
45
+ expect(exitSpy).toHaveBeenCalledWith(0);
46
+ exitSpy.mockRestore();
47
+ });
48
+
49
+ it("should not shut down when autoShutdown is false", async () => {
50
+ await server.stop();
51
+ testPort += 2;
52
+ server = await createServer({
53
+ ...baseConfig,
54
+ port: testPort,
55
+ piPort: testPort + 1,
56
+ autoShutdown: false,
57
+ });
58
+
59
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
60
+
61
+ await server.start();
62
+
63
+ await vi.advanceTimersByTimeAsync(5000);
64
+
65
+ expect(exitSpy).not.toHaveBeenCalled();
66
+ exitSpy.mockRestore();
67
+ });
68
+
69
+ it("should not shut down if session reconnects before idle timer fires", async () => {
70
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
71
+
72
+ await server.start();
73
+
74
+ // Advance to just before idle timeout
75
+ await vi.advanceTimersByTimeAsync(1500);
76
+
77
+ // Connect a session — this cancels the idle timer and sets lastConnectionTimestamp
78
+ vi.useRealTimers();
79
+ const { WebSocket } = await import("ws");
80
+ const ws = new WebSocket(`ws://localhost:${testPort + 1}`);
81
+ await new Promise<void>((resolve) => {
82
+ ws.on("open", () => {
83
+ ws.send(JSON.stringify({
84
+ type: "session_register",
85
+ sessionId: "wake-sess",
86
+ cwd: "/tmp",
87
+ source: "cli",
88
+ }));
89
+ setTimeout(resolve, 50);
90
+ });
91
+ });
92
+
93
+ vi.useFakeTimers();
94
+
95
+ // Even if we advance way past the idle timeout, should NOT exit because session is connected
96
+ await vi.advanceTimersByTimeAsync(10000);
97
+ expect(exitSpy).not.toHaveBeenCalled();
98
+
99
+ vi.useRealTimers();
100
+ ws.close();
101
+ exitSpy.mockRestore();
102
+ }, 10000);
103
+
104
+ it("should cancel idle timer when a session connects", async () => {
105
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
106
+
107
+ await server.start();
108
+
109
+ await vi.advanceTimersByTimeAsync(1000);
110
+
111
+ vi.useRealTimers();
112
+ const { WebSocket } = await import("ws");
113
+ const ws = new WebSocket(`ws://localhost:${testPort + 1}`);
114
+ await new Promise<void>((resolve) => {
115
+ ws.on("open", () => {
116
+ ws.send(JSON.stringify({
117
+ type: "session_register",
118
+ sessionId: "test-sess",
119
+ cwd: "/tmp",
120
+ source: "cli",
121
+ }));
122
+ setTimeout(resolve, 50);
123
+ });
124
+ });
125
+
126
+ vi.useFakeTimers();
127
+
128
+ await vi.advanceTimersByTimeAsync(5000);
129
+
130
+ expect(exitSpy).not.toHaveBeenCalled();
131
+
132
+ vi.useRealTimers();
133
+ ws.close();
134
+ exitSpy.mockRestore();
135
+ });
136
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tests for the browse directory endpoint logic.
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import { listDirectories } from "../browse.js";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ import fs from "node:fs";
9
+
10
+ describe("listDirectories", () => {
11
+ it("should return directory entries for a valid path", async () => {
12
+ // Use the project root — known to have subdirectories
13
+ const projectRoot = path.resolve(import.meta.dirname, "../../..");
14
+ const result = await listDirectories(projectRoot);
15
+
16
+ expect(result.current).toBe(projectRoot);
17
+ expect(result.parent).toBe(path.dirname(projectRoot));
18
+ expect(result.entries.length).toBeGreaterThan(0);
19
+
20
+ // Should contain known subdirectories
21
+ const names = result.entries.map((e) => e.name);
22
+ expect(names).toContain("src");
23
+ expect(names).toContain("node_modules");
24
+ });
25
+
26
+ it("should default to home directory when no path given", async () => {
27
+ const result = await listDirectories();
28
+ expect(result.current).toBe(os.homedir());
29
+ });
30
+
31
+ it("should return entries sorted alphabetically", async () => {
32
+ const projectRoot = path.resolve(import.meta.dirname, "../../..");
33
+ const result = await listDirectories(projectRoot);
34
+ const names = result.entries.map((e) => e.name);
35
+ const sorted = [...names].sort((a, b) => a.localeCompare(b));
36
+ expect(names).toEqual(sorted);
37
+ });
38
+
39
+ it("should exclude hidden directories", async () => {
40
+ // Home dir typically has hidden dirs like .config, .cache
41
+ const result = await listDirectories(os.homedir());
42
+ const names = result.entries.map((e) => e.name);
43
+ const hidden = names.filter((n) => n.startsWith("."));
44
+ expect(hidden).toEqual([]);
45
+ });
46
+
47
+ it("should detect isGit flag for git repos", async () => {
48
+ const projectRoot = path.resolve(import.meta.dirname, "../../..");
49
+ const parentDir = path.dirname(projectRoot);
50
+ const result = await listDirectories(parentDir);
51
+
52
+ const projectEntry = result.entries.find(
53
+ (e) => e.name === path.basename(projectRoot)
54
+ );
55
+ expect(projectEntry).toBeDefined();
56
+ expect(projectEntry!.isGit).toBe(true);
57
+ });
58
+
59
+ it("should detect isPi flag for pi projects", async () => {
60
+ const projectRoot = path.resolve(import.meta.dirname, "../../..");
61
+ const parentDir = path.dirname(projectRoot);
62
+ const result = await listDirectories(parentDir);
63
+
64
+ const projectEntry = result.entries.find(
65
+ (e) => e.name === path.basename(projectRoot)
66
+ );
67
+ expect(projectEntry).toBeDefined();
68
+ expect(projectEntry!.isPi).toBe(true);
69
+ });
70
+
71
+ it("should return null parent for root directory", async () => {
72
+ const result = await listDirectories("/");
73
+ expect(result.parent).toBeNull();
74
+ });
75
+
76
+ it("should throw for non-existent directory", async () => {
77
+ await expect(
78
+ listDirectories("/nonexistent/path/that/does/not/exist")
79
+ ).rejects.toThrow();
80
+ });
81
+
82
+ it("should cap entries at 200", async () => {
83
+ // Can't easily create 200+ dirs, but test the logic path exists
84
+ const result = await listDirectories(os.homedir());
85
+ expect(result.entries.length).toBeLessThanOrEqual(200);
86
+ });
87
+
88
+ it("should only return directories, not files", async () => {
89
+ const projectRoot = path.resolve(import.meta.dirname, "../../..");
90
+ const result = await listDirectories(projectRoot);
91
+ const names = result.entries.map((e) => e.name);
92
+ // package.json is a file, should not appear
93
+ expect(names).not.toContain("package.json");
94
+ expect(names).not.toContain("tsconfig.json");
95
+ });
96
+
97
+ it("should include full path in each entry", async () => {
98
+ const projectRoot = path.resolve(import.meta.dirname, "../../..");
99
+ const result = await listDirectories(projectRoot);
100
+ for (const entry of result.entries) {
101
+ expect(entry.path).toBe(path.join(projectRoot, entry.name));
102
+ }
103
+ });
104
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { OpenSpecBulkArchiveBrowserMessage, BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
3
+
4
+ describe("openspec_bulk_archive message type", () => {
5
+ it("is a valid BrowserToServerMessage", () => {
6
+ const msg: OpenSpecBulkArchiveBrowserMessage = {
7
+ type: "openspec_bulk_archive",
8
+ cwd: "/project/foo",
9
+ };
10
+ // Type-check: ensure it's assignable to the union
11
+ const _: BrowserToServerMessage = msg;
12
+ expect(msg.type).toBe("openspec_bulk_archive");
13
+ expect(msg.cwd).toBe("/project/foo");
14
+ });
15
+ });