@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,102 @@
1
+ /**
2
+ * Integration tests for session ordering flows.
3
+ */
4
+ import { describe, it, expect, beforeEach, vi } from "vitest";
5
+ import { createSessionOrderManager } from "../session-order-manager.js";
6
+ import { createPendingForkRegistry } from "../pending-fork-registry.js";
7
+ import type { PreferencesStore } from "../preferences-store.js";
8
+
9
+ function createMockPreferencesStore(): PreferencesStore {
10
+ let order: Record<string, string[]> = {};
11
+ return {
12
+ getSessionOrder: vi.fn(() => order),
13
+ setSessionOrder: vi.fn((o: Record<string, string[]>) => { order = o; }),
14
+ getPinnedDirectories: vi.fn(() => []),
15
+ setPinnedDirectories: vi.fn(),
16
+ pinDirectory: vi.fn(),
17
+ unpinDirectory: vi.fn(),
18
+ reorderPinnedDirs: vi.fn(),
19
+ flush: vi.fn(),
20
+ dispose: vi.fn(),
21
+ };
22
+ }
23
+
24
+ describe("Session ordering integration", () => {
25
+ let stateStore: PreferencesStore;
26
+
27
+ beforeEach(() => {
28
+ stateStore = createMockPreferencesStore();
29
+ });
30
+
31
+ it("new session prepends to order", () => {
32
+ const orderMgr = createSessionOrderManager(stateStore);
33
+ orderMgr.insert("/project", "s1");
34
+ orderMgr.insert("/project", "s2");
35
+ orderMgr.insert("/project", "s3");
36
+ expect(orderMgr.getOrder("/project")).toEqual(["s3", "s2", "s1"]);
37
+ });
38
+
39
+ it("fork inserts after parent", () => {
40
+ const orderMgr = createSessionOrderManager(stateStore);
41
+ const forkRegistry = createPendingForkRegistry();
42
+
43
+ // Setup: two sessions exist
44
+ orderMgr.insert("/project", "s1");
45
+ orderMgr.insert("/project", "s2"); // s2 is at front: ["s2", "s1"]
46
+
47
+ // User forks s1
48
+ forkRegistry.recordFork("/project", "s1");
49
+
50
+ // New session registers — simulate server checking fork registry
51
+ const forkParent = forkRegistry.consumeFork("/project");
52
+ orderMgr.insert("/project", "s3", forkParent ?? undefined);
53
+
54
+ // s3 should be after s1: ["s2", "s1", "s3"]
55
+ expect(orderMgr.getOrder("/project")).toEqual(["s2", "s1", "s3"]);
56
+ });
57
+
58
+ it("reorder replaces order", () => {
59
+ const orderMgr = createSessionOrderManager(stateStore);
60
+ orderMgr.insert("/project", "s1");
61
+ orderMgr.insert("/project", "s2");
62
+ orderMgr.insert("/project", "s3"); // ["s3", "s2", "s1"]
63
+
64
+ orderMgr.reorder("/project", ["s1", "s3", "s2"]);
65
+ expect(orderMgr.getOrder("/project")).toEqual(["s1", "s3", "s2"]);
66
+ });
67
+
68
+ it("continue preserves position (no re-insert for existing ID)", () => {
69
+ const orderMgr = createSessionOrderManager(stateStore);
70
+ orderMgr.insert("/project", "s1");
71
+ orderMgr.insert("/project", "s2"); // ["s2", "s1"]
72
+
73
+ // s1 re-registers (continue) — insert is a no-op because ID already exists
74
+ orderMgr.insert("/project", "s1");
75
+ expect(orderMgr.getOrder("/project")).toEqual(["s2", "s1"]);
76
+ });
77
+
78
+ it("getOrder prunes stale IDs", () => {
79
+ const orderMgr = createSessionOrderManager(stateStore);
80
+ orderMgr.insert("/project", "s1");
81
+ orderMgr.insert("/project", "s2");
82
+ orderMgr.insert("/project", "s3"); // ["s3", "s2", "s1"]
83
+
84
+ // s2 no longer exists
85
+ const validIds = new Set(["s1", "s3"]);
86
+ expect(orderMgr.getOrder("/project", validIds)).toEqual(["s3", "s1"]);
87
+ });
88
+
89
+ it("concurrent registrations in same cwd maintain correct order", () => {
90
+ const orderMgr = createSessionOrderManager(stateStore);
91
+
92
+ // Simulate rapid concurrent registrations (all synchronous in Node.js)
93
+ orderMgr.insert("/project", "s1");
94
+ orderMgr.insert("/project", "s2");
95
+ orderMgr.insert("/project", "s3");
96
+ orderMgr.insert("/project", "s4");
97
+ orderMgr.insert("/project", "s5");
98
+
99
+ // All should be in reverse order (most recent first)
100
+ expect(orderMgr.getOrder("/project")).toEqual(["s5", "s4", "s3", "s2", "s1"]);
101
+ });
102
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { scanAllSessions } from "../session-scanner.js";
6
+ import { metaPath, writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
7
+
8
+ // Mock extractSessionStats to avoid needing real JSONL content with usage data
9
+ vi.mock("../session-stats-reader.js", () => ({
10
+ extractSessionStats: vi.fn(() => ({
11
+ tokensIn: 10,
12
+ tokensOut: 20,
13
+ cacheRead: 30,
14
+ cacheWrite: 40,
15
+ cost: 0.5,
16
+ lastTotalTokens: 1000,
17
+ contextWindow: 200000,
18
+ model: "anthropic/claude-sonnet-4-20250514",
19
+ thinkingLevel: "medium",
20
+ })),
21
+ }));
22
+
23
+ describe("session-scanner", () => {
24
+ let tmpDir: string;
25
+
26
+ beforeEach(() => {
27
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "scanner-test-"));
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(tmpDir, { recursive: true, force: true });
32
+ });
33
+
34
+ function createSessionDir(cwdEncoded: string): string {
35
+ const dir = path.join(tmpDir, cwdEncoded);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ return dir;
38
+ }
39
+
40
+ function createJsonl(dir: string, filename: string, header?: { id: string; cwd: string }): string {
41
+ const filePath = path.join(dir, filename);
42
+ const h = header ?? { id: "test-id", cwd: "/test/cwd" };
43
+ const lines = [
44
+ JSON.stringify({ type: "session", id: h.id, cwd: h.cwd, timestamp: "2026-03-30T21:39:43.034Z" }),
45
+ JSON.stringify({ type: "message", message: { role: "user", content: "Hello world" } }),
46
+ ];
47
+ fs.writeFileSync(filePath, lines.join("\n") + "\n");
48
+ return filePath;
49
+ }
50
+
51
+ it("should return empty for non-existent directory", () => {
52
+ const result = scanAllSessions("/non/existent/path");
53
+ expect(result.sessions).toEqual([]);
54
+ expect(result.cacheUpdates).toBe(0);
55
+ });
56
+
57
+ it("should return empty for empty sessions directory", () => {
58
+ const result = scanAllSessions(tmpDir);
59
+ expect(result.sessions).toEqual([]);
60
+ });
61
+
62
+ it("should discover session from .meta.json with cached data", () => {
63
+ const dir = createSessionDir("--test-cwd--");
64
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_abc-123.jsonl", { id: "abc-123", cwd: "/test/cwd" });
65
+ writeSessionMeta(sf, {
66
+ cwd: "/test/cwd",
67
+ name: "My Session",
68
+ source: "dashboard",
69
+ status: "ended",
70
+ startedAt: 1000,
71
+ cost: 5.0,
72
+ tokensIn: 100,
73
+ tokensOut: 200,
74
+ cachedAt: Date.now() + 10000, // far future = fresh cache
75
+ });
76
+
77
+ const result = scanAllSessions(tmpDir);
78
+ expect(result.sessions).toHaveLength(1);
79
+ expect(result.sessions[0].id).toBe("abc-123");
80
+ expect(result.sessions[0].cwd).toBe("/test/cwd");
81
+ expect(result.sessions[0].name).toBe("My Session");
82
+ expect(result.sessions[0].cost).toBe(5.0);
83
+ expect(result.cacheUpdates).toBe(0); // no re-extraction needed
84
+ });
85
+
86
+ it("should fall back to .jsonl parsing when no .meta.json exists", () => {
87
+ const dir = createSessionDir("--test-cwd--");
88
+ createJsonl(dir, "2026-03-30T21-39-43-034Z_def-456.jsonl", { id: "def-456", cwd: "/fallback/cwd" });
89
+
90
+ const result = scanAllSessions(tmpDir);
91
+ expect(result.sessions).toHaveLength(1);
92
+ expect(result.sessions[0].id).toBe("def-456");
93
+ expect(result.sessions[0].cwd).toBe("/fallback/cwd");
94
+ expect(result.sessions[0].firstMessage).toBe("Hello world");
95
+ expect(result.cacheUpdates).toBe(1); // wrote new .meta.json
96
+ });
97
+
98
+ it("should write .meta.json for uncached sessions", () => {
99
+ const dir = createSessionDir("--test-cwd--");
100
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_ghi-789.jsonl", { id: "ghi-789", cwd: "/new/cwd" });
101
+
102
+ scanAllSessions(tmpDir);
103
+
104
+ // .meta.json should now exist
105
+ expect(fs.existsSync(metaPath(sf))).toBe(true);
106
+ const meta = JSON.parse(fs.readFileSync(metaPath(sf), "utf-8"));
107
+ expect(meta.cwd).toBe("/new/cwd");
108
+ expect(meta.cachedAt).toBeGreaterThan(0);
109
+ });
110
+
111
+ it("should ignore orphaned .meta.json without .jsonl", () => {
112
+ const dir = createSessionDir("--test-cwd--");
113
+ // Write .meta.json without a corresponding .jsonl
114
+ const orphanedMeta = path.join(dir, "2026-03-30T21-39-43-034Z_orphan-id.meta.json");
115
+ fs.writeFileSync(orphanedMeta, JSON.stringify({ cwd: "/ghost", source: "dashboard" }));
116
+
117
+ const result = scanAllSessions(tmpDir);
118
+ expect(result.sessions).toHaveLength(0);
119
+ });
120
+
121
+ it("should extract session ID from filename", () => {
122
+ const dir = createSessionDir("--test-cwd--");
123
+ createJsonl(dir, "2026-03-30T21-39-43-034Z_c7ab4be9-78d1-4764-8197-dbf74fea8bf4.jsonl", {
124
+ id: "c7ab4be9-78d1-4764-8197-dbf74fea8bf4",
125
+ cwd: "/test",
126
+ });
127
+ writeSessionMeta(
128
+ path.join(dir, "2026-03-30T21-39-43-034Z_c7ab4be9-78d1-4764-8197-dbf74fea8bf4.jsonl"),
129
+ { cwd: "/test", cachedAt: Date.now() + 10000 },
130
+ );
131
+
132
+ const result = scanAllSessions(tmpDir);
133
+ expect(result.sessions[0].id).toBe("c7ab4be9-78d1-4764-8197-dbf74fea8bf4");
134
+ });
135
+
136
+ it("should re-extract stats when .jsonl is newer than cachedAt", () => {
137
+ const dir = createSessionDir("--test-cwd--");
138
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_stale-id.jsonl", { id: "stale-id", cwd: "/stale" });
139
+
140
+ // Write meta with old cachedAt
141
+ writeSessionMeta(sf, {
142
+ cwd: "/stale",
143
+ cost: 1.0,
144
+ cachedAt: 1000, // very old
145
+ });
146
+
147
+ // Touch the .jsonl to make it newer
148
+ const now = new Date();
149
+ fs.utimesSync(sf, now, now);
150
+
151
+ const result = scanAllSessions(tmpDir);
152
+ expect(result.sessions).toHaveLength(1);
153
+ // Stats should come from mock extractSessionStats (cost=0.5), not cached (cost=1.0)
154
+ expect(result.sessions[0].cost).toBe(0.5);
155
+ expect(result.cacheUpdates).toBe(1);
156
+ });
157
+
158
+ it("should scan multiple cwd directories", () => {
159
+ const dir1 = createSessionDir("--project-a--");
160
+ const dir2 = createSessionDir("--project-b--");
161
+ createJsonl(dir1, "2026-03-30T21-39-43-034Z_id-a.jsonl", { id: "id-a", cwd: "/project/a" });
162
+ createJsonl(dir2, "2026-03-30T21-39-43-034Z_id-b.jsonl", { id: "id-b", cwd: "/project/b" });
163
+
164
+ const result = scanAllSessions(tmpDir);
165
+ expect(result.sessions).toHaveLength(2);
166
+ const ids = result.sessions.map((s) => s.id).sort();
167
+ expect(ids).toEqual(["id-a", "id-b"]);
168
+ });
169
+
170
+ it("should preserve existing meta fields when falling back to .jsonl", () => {
171
+ const dir = createSessionDir("--test-cwd--");
172
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_preserve-id.jsonl", { id: "preserve-id", cwd: "/test" });
173
+
174
+ // Write partial meta (source only, no cwd — triggers fallback)
175
+ writeSessionMeta(sf, { source: "dashboard" });
176
+
177
+ const result = scanAllSessions(tmpDir);
178
+ expect(result.sessions).toHaveLength(1);
179
+ expect(result.sessions[0].source).toBe("dashboard");
180
+
181
+ // Check the written meta preserved source
182
+ const meta = JSON.parse(fs.readFileSync(metaPath(sf), "utf-8"));
183
+ expect(meta.source).toBe("dashboard");
184
+ expect(meta.cwd).toBe("/test");
185
+ });
186
+
187
+ it("should set hidden from meta", () => {
188
+ const dir = createSessionDir("--test-cwd--");
189
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_hidden-id.jsonl", { id: "hidden-id", cwd: "/test" });
190
+ writeSessionMeta(sf, {
191
+ cwd: "/test",
192
+ hidden: true,
193
+ cachedAt: Date.now() + 10000,
194
+ });
195
+
196
+ const result = scanAllSessions(tmpDir);
197
+ expect(result.sessions[0].hidden).toBe(true);
198
+ });
199
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tests for POST /api/shutdown endpoint.
3
+ */
4
+ import { describe, it, expect, afterEach, vi } from "vitest";
5
+ import { createServer, type DashboardServer } from "../server.js";
6
+
7
+ const httpPort = 19080;
8
+ const piPort = 19081;
9
+ let server: DashboardServer;
10
+
11
+ // Mock process.exit to prevent actually exiting
12
+ const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
13
+
14
+ describe("POST /api/shutdown", () => {
15
+ afterEach(async () => {
16
+ mockExit.mockClear();
17
+ if (server) {
18
+ try { await server.stop(); } catch { /* already stopped */ }
19
+ }
20
+ });
21
+
22
+ it("should respond with { ok: true }", async () => {
23
+ server = await createServer({
24
+ port: httpPort, piPort, dev: true,
25
+ autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
26
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
27
+ });
28
+ await server.start();
29
+
30
+ const res = await fetch(`http://localhost:${httpPort}/api/shutdown`, {
31
+ method: "POST",
32
+ });
33
+
34
+ expect(res.ok).toBe(true);
35
+ const body = await res.json();
36
+ expect(body).toEqual({ ok: true });
37
+
38
+ // Give the setTimeout a chance to fire
39
+ await new Promise((r) => setTimeout(r, 200));
40
+ expect(mockExit).toHaveBeenCalledWith(0);
41
+ });
42
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Tests for bridge reconnect skip-wipe logic in event-wiring.
3
+ * When bridge sends eventCount matching server's stored count, skip the event wipe.
4
+ */
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { createMemoryEventStore } from "../memory-event-store.js";
7
+ import { createMemorySessionManager } from "../memory-session-manager.js";
8
+ import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
9
+
10
+ function makeEvent(type: string = "test"): DashboardEvent {
11
+ return { eventType: type, timestamp: Date.now(), data: {} };
12
+ }
13
+
14
+ /**
15
+ * Minimal simulation of the session_register path in event-wiring.ts.
16
+ * We test the skip-wipe decision logic in isolation.
17
+ */
18
+ function simulateSessionRegister(opts: {
19
+ eventStore: ReturnType<typeof createMemoryEventStore>;
20
+ sessionId: string;
21
+ previousSessionId?: string;
22
+ eventCount?: number;
23
+ }) {
24
+ const { eventStore, sessionId, previousSessionId, eventCount } = opts;
25
+ const wiped = { value: false };
26
+ const resetSent = { value: false };
27
+
28
+ // Decision logic matching event-wiring.ts
29
+ const sameSession = !previousSessionId || previousSessionId === sessionId;
30
+ const serverEventCount = eventStore.getEvents(sessionId, 1).length;
31
+ const canSkipWipe = sameSession && eventCount !== undefined && eventCount === serverEventCount;
32
+
33
+ if (!canSkipWipe) {
34
+ eventStore.deleteEventsForSession(sessionId);
35
+ wiped.value = true;
36
+ resetSent.value = true;
37
+ }
38
+
39
+ return { wiped: wiped.value, resetSent: resetSent.value };
40
+ }
41
+
42
+ describe("skip-wipe on bridge reconnect", () => {
43
+ it("skips wipe when eventCount matches server event count", () => {
44
+ const store = createMemoryEventStore(() => false);
45
+ store.insertEvent("s1", makeEvent("a"));
46
+ store.insertEvent("s1", makeEvent("b"));
47
+ store.insertEvent("s1", makeEvent("c"));
48
+
49
+ const result = simulateSessionRegister({
50
+ eventStore: store,
51
+ sessionId: "s1",
52
+ previousSessionId: "s1",
53
+ eventCount: 3,
54
+ });
55
+
56
+ expect(result.wiped).toBe(false);
57
+ expect(result.resetSent).toBe(false);
58
+ // Events preserved
59
+ expect(store.getEvents("s1", 1)).toHaveLength(3);
60
+ });
61
+
62
+ it("wipes when eventCount mismatches", () => {
63
+ const store = createMemoryEventStore(() => false);
64
+ store.insertEvent("s1", makeEvent("a"));
65
+ store.insertEvent("s1", makeEvent("b"));
66
+
67
+ const result = simulateSessionRegister({
68
+ eventStore: store,
69
+ sessionId: "s1",
70
+ previousSessionId: "s1",
71
+ eventCount: 5, // mismatch
72
+ });
73
+
74
+ expect(result.wiped).toBe(true);
75
+ expect(result.resetSent).toBe(true);
76
+ expect(store.getEvents("s1", 1)).toHaveLength(0);
77
+ });
78
+
79
+ it("wipes when eventCount is not provided (backward compat)", () => {
80
+ const store = createMemoryEventStore(() => false);
81
+ store.insertEvent("s1", makeEvent("a"));
82
+
83
+ const result = simulateSessionRegister({
84
+ eventStore: store,
85
+ sessionId: "s1",
86
+ previousSessionId: "s1",
87
+ eventCount: undefined,
88
+ });
89
+
90
+ expect(result.wiped).toBe(true);
91
+ expect(result.resetSent).toBe(true);
92
+ });
93
+
94
+ it("wipes when session ID changed", () => {
95
+ const store = createMemoryEventStore(() => false);
96
+ store.insertEvent("s1", makeEvent("a"));
97
+ store.insertEvent("s1", makeEvent("b"));
98
+
99
+ const result = simulateSessionRegister({
100
+ eventStore: store,
101
+ sessionId: "s2", // different session
102
+ previousSessionId: "s1",
103
+ eventCount: 2,
104
+ });
105
+
106
+ expect(result.wiped).toBe(true);
107
+ expect(result.resetSent).toBe(true);
108
+ });
109
+
110
+ it("wipes when no previous session (first connect)", () => {
111
+ const store = createMemoryEventStore(() => false);
112
+
113
+ const result = simulateSessionRegister({
114
+ eventStore: store,
115
+ sessionId: "s1",
116
+ previousSessionId: undefined,
117
+ eventCount: 5,
118
+ });
119
+
120
+ // No previous session = can't verify, so wipe
121
+ expect(result.wiped).toBe(true);
122
+ });
123
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for sleep-aware heartbeat in pi-gateway.
3
+ */
4
+ import { describe, it, expect, afterEach } from "vitest";
5
+ import { createPiGateway } from "../pi-gateway.js";
6
+ import { createMemorySessionManager } from "../memory-session-manager.js";
7
+ import { WebSocket } from "ws";
8
+
9
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
10
+
11
+ function waitForOpen(ws: WebSocket): Promise<void> {
12
+ return new Promise((resolve, reject) => {
13
+ if (ws.readyState === WebSocket.OPEN) return resolve();
14
+ ws.on("open", resolve);
15
+ ws.on("error", reject);
16
+ setTimeout(() => reject(new Error("open timeout")), 3000);
17
+ });
18
+ }
19
+
20
+ function makeTempSessionManager() {
21
+ return createMemorySessionManager();
22
+ }
23
+
24
+ // Use a short heartbeat for fast tests
25
+ const SHORT_HB = 300; // 300ms
26
+ let portCounter = 19390;
27
+
28
+ describe("Sleep-aware heartbeat", () => {
29
+ let gateway: ReturnType<typeof createPiGateway>;
30
+
31
+ afterEach(() => {
32
+ gateway?.stop();
33
+ });
34
+
35
+ it("should unregister session after normal heartbeat timeout", async () => {
36
+ const sessionManager = makeTempSessionManager();
37
+ gateway = createPiGateway(sessionManager, { heartbeatTimeout: SHORT_HB });
38
+ const port = portCounter++;
39
+ gateway.start(port);
40
+
41
+ const ws = new WebSocket(`ws://localhost:${port}`);
42
+ await waitForOpen(ws);
43
+ ws.send(JSON.stringify({
44
+ type: "session_register", sessionId: "s1", cwd: "/tmp", source: "tui",
45
+ }));
46
+ await delay(100);
47
+ expect(sessionManager.get("s1")!.status).toBe("active");
48
+
49
+ // Close without unregister
50
+ ws.close();
51
+ await delay(100);
52
+
53
+ // Wait for heartbeat timeout
54
+ await delay(SHORT_HB + 200);
55
+
56
+ expect(sessionManager.get("s1")!.status).toBe("ended");
57
+ }, 10000);
58
+
59
+ it("should call onEmpty after heartbeat timeout", async () => {
60
+ const sessionManager = makeTempSessionManager();
61
+ gateway = createPiGateway(sessionManager, { heartbeatTimeout: SHORT_HB });
62
+ const port = portCounter++;
63
+ gateway.start(port);
64
+
65
+ let emptyCalled = false;
66
+ gateway.onEmpty = () => { emptyCalled = true; };
67
+
68
+ const ws = new WebSocket(`ws://localhost:${port}`);
69
+ await waitForOpen(ws);
70
+ ws.send(JSON.stringify({
71
+ type: "session_register", sessionId: "s2", cwd: "/tmp", source: "tui",
72
+ }));
73
+ await delay(100);
74
+
75
+ ws.close();
76
+ await delay(SHORT_HB + 200);
77
+
78
+ expect(emptyCalled).toBe(true);
79
+ }, 10000);
80
+
81
+ it("should retry once when sleep is detected (simulated via Date.now override)", async () => {
82
+ const sessionManager = makeTempSessionManager();
83
+ // Use a longer timeout so we can manipulate Date.now between calls
84
+ const HB = 500;
85
+ gateway = createPiGateway(sessionManager, { heartbeatTimeout: HB });
86
+ const port = portCounter++;
87
+ gateway.start(port);
88
+
89
+ const ws = new WebSocket(`ws://localhost:${port}`);
90
+ await waitForOpen(ws);
91
+ ws.send(JSON.stringify({
92
+ type: "session_register", sessionId: "s3", cwd: "/tmp", source: "tui",
93
+ }));
94
+ await delay(100);
95
+ expect(sessionManager.get("s3")!.status).toBe("active");
96
+
97
+ // Close connection
98
+ ws.close();
99
+ await delay(50);
100
+
101
+ // Simulate sleep: make Date.now() jump forward far beyond 2× timeout
102
+ const realNow = Date.now.bind(Date);
103
+ let offset = 0;
104
+ Date.now = () => realNow() + offset;
105
+
106
+ // Jump time forward to simulate sleep (10× timeout = clearly > 2×)
107
+ offset = HB * 10;
108
+
109
+ // Wait for the heartbeat timer to fire (it uses real setTimeout)
110
+ await delay(HB + 200);
111
+
112
+ // Session should still be active (sleep detected, one retry granted)
113
+ expect(sessionManager.get("s3")!.status).toBe("active");
114
+
115
+ // Reset time back to normal
116
+ offset = 0;
117
+
118
+ // Wait for the retry heartbeat to fire
119
+ await delay(HB + 200);
120
+
121
+ // Now it should be ended (second timeout, no sleep detected)
122
+ expect(sessionManager.get("s3")!.status).toBe("ended");
123
+
124
+ Date.now = realNow;
125
+ }, 10000);
126
+ });