@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,202 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { parseEtime, scanChildProcesses, captureChildPgids, scanTrackedProcesses, killProcessByPgid, scanWindowsProcesses, type SpawnSyncFn } from "../process-scanner.js";
3
+ import type { SpawnSyncReturns } from "node:child_process";
4
+
5
+ function mockResult(stdout: string, status = 0): SpawnSyncReturns<string> {
6
+ return { status, stdout, stderr: "", pid: 0, output: [], signal: null };
7
+ }
8
+
9
+ function fail(): SpawnSyncReturns<string> {
10
+ return mockResult("", 1);
11
+ }
12
+
13
+ /** Helper: ps -eo pid=,ppid= output for a set of pid→ppid pairs */
14
+ function psChildOutput(pairs: [number, number][]): string {
15
+ return pairs.map(([pid, ppid]) => ` ${pid} ${ppid}`).join("\n") + "\n";
16
+ }
17
+
18
+ describe("parseEtime", () => {
19
+ it("parses mm:ss format", () => expect(parseEtime("02:15")).toBe(135000));
20
+ it("parses hh:mm:ss format", () => expect(parseEtime("01:30:00")).toBe(5400000));
21
+ it("parses dd-hh:mm:ss format", () => expect(parseEtime("2-03:00:00")).toBe(183600000));
22
+ it("parses 00:05 as 5 seconds", () => expect(parseEtime("00:05")).toBe(5000));
23
+ it("parses 1-00:00:00 as 1 day", () => expect(parseEtime("1-00:00:00")).toBe(86400000));
24
+ it("returns 0 for empty string", () => expect(parseEtime("")).toBe(0));
25
+ it("returns 0 for invalid format", () => expect(parseEtime("garbage")).toBe(0));
26
+ });
27
+
28
+ describe("captureChildPgids", () => {
29
+ it("captures PGIDs of leaf children", () => {
30
+ const mock: SpawnSyncFn = (cmd, args) => {
31
+ // getChildPids for parent 100: finds child 200
32
+ if (cmd === "ps" && args[0] === "-eo" && args[1] === "pid=,ppid=") {
33
+ return mockResult(psChildOutput([[200, 100], [300, 999]]));
34
+ }
35
+ // getChildPids for child 200: no grandchildren (same ps call, no match)
36
+ // ps -p 200 -o pgid=
37
+ if (cmd === "ps" && args[0] === "-p" && args[1] === "200") {
38
+ return mockResult(" 200\n");
39
+ }
40
+ return fail();
41
+ };
42
+ const tracked = new Set<number>();
43
+ captureChildPgids(100, tracked, { _spawnSync: mock });
44
+ expect(tracked.has(200)).toBe(true);
45
+ });
46
+
47
+ it("does nothing on Windows", () => {
48
+ const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
49
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
50
+ try {
51
+ const tracked = new Set<number>();
52
+ captureChildPgids(100, tracked);
53
+ expect(tracked.size).toBe(0);
54
+ } finally {
55
+ if (origPlatform) Object.defineProperty(process, "platform", origPlatform);
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("scanTrackedProcesses", () => {
61
+ it("returns alive processes matching tracked PGIDs", () => {
62
+ const mock: SpawnSyncFn = (cmd) => {
63
+ if (cmd === "ps") {
64
+ return mockResult(
65
+ " 300 200 02:00 node vitest\n 200 200 02:00 /bin/bash -c npm test\n 400 400 01:00 node vite\n"
66
+ );
67
+ }
68
+ return fail();
69
+ };
70
+ const tracked = new Set([200]);
71
+ const result = scanTrackedProcesses(tracked, 0, { _spawnSync: mock });
72
+ expect(result.some((p) => p.command === "node vitest")).toBe(true);
73
+ expect(result.some((p) => p.command.includes("bash"))).toBe(false);
74
+ expect(result.some((p) => p.pgid === 400)).toBe(false);
75
+ });
76
+
77
+ it("removes dead PGIDs from tracked set", () => {
78
+ const mock: SpawnSyncFn = (cmd) => {
79
+ if (cmd === "ps") return mockResult(" 500 500 01:00 node other\n");
80
+ return fail();
81
+ };
82
+ const tracked = new Set([300]);
83
+ scanTrackedProcesses(tracked, 0, { _spawnSync: mock });
84
+ expect(tracked.has(300)).toBe(false);
85
+ });
86
+
87
+ it("filters by minElapsedMs", () => {
88
+ const mock: SpawnSyncFn = (cmd) => {
89
+ if (cmd === "ps") return mockResult(" 300 200 00:05 node vitest\n");
90
+ return fail();
91
+ };
92
+ const tracked = new Set([200]);
93
+ const result = scanTrackedProcesses(tracked, 30000, { _spawnSync: mock });
94
+ expect(result).toHaveLength(0);
95
+ });
96
+
97
+ it("returns empty on Windows", () => {
98
+ const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
99
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
100
+ try {
101
+ const tracked = new Set([200]);
102
+ expect(scanTrackedProcesses(tracked, 0)).toEqual([]);
103
+ } finally {
104
+ if (origPlatform) Object.defineProperty(process, "platform", origPlatform);
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("scanChildProcesses (combined)", () => {
110
+ it("captures and returns processes in one call", () => {
111
+ let callCount = 0;
112
+ const mock: SpawnSyncFn = (cmd, args) => {
113
+ // Phase 1 (capture): getChildPids finds child 200 of parent 100
114
+ if (cmd === "ps" && args[0] === "-eo" && args[1] === "pid=,ppid=") {
115
+ callCount++;
116
+ // First call: find children of 100 → 200
117
+ // Second call: find children of 200 → none (leaf)
118
+ if (callCount === 1) return mockResult(psChildOutput([[200, 100], [999, 888]]));
119
+ if (callCount === 2) return mockResult(psChildOutput([[999, 888]])); // no children of 200
120
+ // Phase 2: full process list
121
+ return mockResult(" 200 200 02:00 node vitest\n 999 888 01:00 unrelated\n");
122
+ }
123
+ // Phase 1: get PGID for captured PID 200
124
+ if (cmd === "ps" && args[0] === "-p" && args[1] === "200" && args[2] === "-o" && args[3] === "pgid=") {
125
+ return mockResult(" 200\n");
126
+ }
127
+ // Phase 2: full scan
128
+ if (cmd === "ps" && args[0] === "-eo" && args[1] === "pid=,pgid=,etime=,args=") {
129
+ return mockResult(" 200 200 02:00 node vitest\n 999 888 01:00 unrelated\n");
130
+ }
131
+ return fail();
132
+ };
133
+
134
+ const tracked = new Set<number>();
135
+ const result = scanChildProcesses(100, tracked, 0, { _spawnSync: mock });
136
+ expect(tracked.has(200)).toBe(true);
137
+ expect(result.some(p => p.command === "node vitest")).toBe(true);
138
+ expect(result.some(p => p.pgid === 888)).toBe(false); // unrelated PGID not tracked
139
+ });
140
+
141
+ it("returns empty on Windows", () => {
142
+ const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
143
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
144
+ try {
145
+ const tracked = new Set<number>();
146
+ expect(scanChildProcesses(100, tracked, 0)).toEqual([]);
147
+ } finally {
148
+ if (origPlatform) Object.defineProperty(process, "platform", origPlatform);
149
+ }
150
+ });
151
+ });
152
+
153
+ describe("killProcessByPgid", () => {
154
+ it("returns false for non-existent process group", () => {
155
+ expect(killProcessByPgid(99999)).toBe(false);
156
+ });
157
+
158
+ it("uses taskkill on Windows", () => {
159
+ const mockSpawn = vi.fn().mockReturnValue({ status: 0, stdout: "" });
160
+ expect(killProcessByPgid(1234, { _spawnSync: mockSpawn, _platform: "win32" } as any)).toBe(true);
161
+ expect(mockSpawn).toHaveBeenCalledWith(
162
+ "taskkill",
163
+ ["/PID", "1234", "/T", "/F"],
164
+ expect.any(Object),
165
+ );
166
+ });
167
+ });
168
+
169
+ describe("Windows process scanning", () => {
170
+ it("scanChildProcesses delegates to scanWindowsProcesses on win32", () => {
171
+ const wmicOutput = [
172
+ "CommandLine=node server.js",
173
+ "CreationDate=20260410220000.000000+000",
174
+ "ParentProcessId=100",
175
+ "ProcessId=200",
176
+ "",
177
+ ].join("\n");
178
+ const mockSpawn = vi.fn().mockReturnValue({ status: 0, stdout: wmicOutput });
179
+ const tracked = new Set<number>();
180
+ const result = scanChildProcesses(100, tracked, 0, { _spawnSync: mockSpawn, _platform: "win32" } as any);
181
+ expect(result.length).toBeGreaterThan(0);
182
+ expect(result[0].pid).toBe(200);
183
+ });
184
+
185
+ it("falls back to PowerShell when wmic fails", () => {
186
+ const psOutput = JSON.stringify([{ ProcessId: 300, CommandLine: "npm test", CreationDate: new Date(Date.now() - 60000).toISOString() }]);
187
+ const mockSpawn = vi.fn()
188
+ .mockReturnValueOnce({ status: 1, stdout: "" }) // wmic fails
189
+ .mockReturnValueOnce({ status: 0, stdout: psOutput }); // powershell succeeds
190
+ const tracked = new Set<number>();
191
+ const result = scanChildProcesses(100, tracked, 0, { _spawnSync: mockSpawn, _platform: "win32" } as any);
192
+ expect(result.length).toBeGreaterThan(0);
193
+ expect(result[0].pid).toBe(300);
194
+ });
195
+
196
+ it("returns empty when both wmic and PowerShell fail", () => {
197
+ const mockSpawn = vi.fn().mockReturnValue({ status: 1, stdout: "" });
198
+ const tracked = new Set<number>();
199
+ const result = scanChildProcesses(100, tracked, 0, { _spawnSync: mockSpawn, _platform: "win32" } as any);
200
+ expect(result).toEqual([]);
201
+ });
202
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { expandPromptTemplateFromDisk } from "../prompt-expander.js";
5
+
6
+ const tmpDir = join(import.meta.dirname ?? __dirname, "__tmp_prompt_test__");
7
+ const promptsDir = join(tmpDir, ".pi", "prompts");
8
+
9
+ beforeEach(() => {
10
+ mkdirSync(promptsDir, { recursive: true });
11
+ writeFileSync(join(promptsDir, "opsx-continue.md"), "---\ndescription: continue\n---\nContinue the change");
12
+ writeFileSync(join(promptsDir, "opsx-apply.md"), "Apply the change");
13
+ writeFileSync(join(promptsDir, "hello.md"), "Hello world");
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe("expandPromptTemplateFromDisk", () => {
21
+ it("expands hyphen form /opsx-continue", () => {
22
+ const result = expandPromptTemplateFromDisk("/opsx-continue my-change", tmpDir);
23
+ expect(result).toContain("Continue the change");
24
+ expect(result).toContain("my-change");
25
+ });
26
+
27
+ it("expands colon form /opsx:continue as alias for /opsx-continue", () => {
28
+ const result = expandPromptTemplateFromDisk("/opsx:continue my-change", tmpDir);
29
+ expect(result).toContain("Continue the change");
30
+ expect(result).toContain("my-change");
31
+ });
32
+
33
+ it("expands colon form /opsx:apply without args", () => {
34
+ const result = expandPromptTemplateFromDisk("/opsx:apply", tmpDir);
35
+ expect(result).toBe("Apply the change");
36
+ });
37
+
38
+ it("does not affect non-opsx colon commands", () => {
39
+ // /hello has no colon, should work as before
40
+ const result = expandPromptTemplateFromDisk("/hello", tmpDir);
41
+ expect(result).toBe("Hello world");
42
+ });
43
+
44
+ it("returns original text when no template found", () => {
45
+ const result = expandPromptTemplateFromDisk("/nonexistent", tmpDir);
46
+ expect(result).toBe("/nonexistent");
47
+ });
48
+
49
+ it("strips YAML frontmatter from colon form too", () => {
50
+ const result = expandPromptTemplateFromDisk("/opsx:continue", tmpDir);
51
+ expect(result).toBe("Continue the change");
52
+ expect(result).not.toContain("---");
53
+ });
54
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { autoStartServer, type AutoStartDeps, type DiscoveredServer } from "../server-auto-start.js";
3
+
4
+ function makeDeps(overrides: Partial<AutoStartDeps> = {}): AutoStartDeps {
5
+ return {
6
+ discoverDashboard: vi.fn().mockResolvedValue([]),
7
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
8
+ launchServer: vi.fn().mockResolvedValue({ success: true, message: "Server started" }),
9
+ notify: vi.fn(),
10
+ ...overrides,
11
+ };
12
+ }
13
+
14
+ const baseConfig = { piPort: 9999, port: 8000, autoStart: true };
15
+
16
+ describe("autoStartServer", () => {
17
+ it("returns server from mDNS when local server is discovered", async () => {
18
+ const localServer: DiscoveredServer = {
19
+ host: "myhost.local", port: 8000, piPort: 9999,
20
+ isLocal: true, source: "mdns",
21
+ };
22
+ const deps = makeDeps({
23
+ discoverDashboard: vi.fn().mockResolvedValue([localServer]),
24
+ });
25
+
26
+ const result = await autoStartServer(baseConfig, deps);
27
+
28
+ expect(result.server).toEqual({ host: "myhost.local", port: 8000, piPort: 9999 });
29
+ expect(deps.isDashboardRunning).not.toHaveBeenCalled();
30
+ expect(deps.launchServer).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("falls back to health check when mDNS finds no local server", async () => {
34
+ const deps = makeDeps({
35
+ discoverDashboard: vi.fn().mockResolvedValue([]),
36
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: true }),
37
+ });
38
+
39
+ const result = await autoStartServer(baseConfig, deps);
40
+
41
+ expect(result.server).toEqual({ host: "localhost", port: 8000, piPort: 9999 });
42
+ expect(deps.launchServer).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it("falls back to health check when mDNS throws", async () => {
46
+ const deps = makeDeps({
47
+ discoverDashboard: vi.fn().mockRejectedValue(new Error("mDNS failed")),
48
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: true }),
49
+ });
50
+
51
+ const result = await autoStartServer(baseConfig, deps);
52
+
53
+ expect(result.server).toEqual({ host: "localhost", port: 8000, piPort: 9999 });
54
+ });
55
+
56
+ it("auto-starts server and returns config defaults when mDNS fails after launch", async () => {
57
+ const deps = makeDeps({
58
+ discoverDashboard: vi.fn().mockResolvedValue([]),
59
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
60
+ launchServer: vi.fn().mockResolvedValue({ success: true, message: "ok" }),
61
+ });
62
+
63
+ const result = await autoStartServer(baseConfig, deps);
64
+
65
+ expect(deps.launchServer).toHaveBeenCalled();
66
+ expect(deps.notify).toHaveBeenCalledWith(
67
+ "🌐 Dashboard started at http://localhost:8000",
68
+ "info",
69
+ );
70
+ expect(result.server).toEqual({ host: "localhost", port: 8000, piPort: 9999 });
71
+ });
72
+
73
+ it("uses mDNS-discovered piPort after auto-start", async () => {
74
+ const localServer: DiscoveredServer = {
75
+ host: "myhost.local", port: 8000, piPort: 9998,
76
+ isLocal: true, source: "mdns",
77
+ };
78
+ const deps = makeDeps({
79
+ discoverDashboard: vi.fn()
80
+ .mockResolvedValueOnce([]) // First call: nothing found
81
+ .mockResolvedValueOnce([localServer]), // After launch: found
82
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
83
+ launchServer: vi.fn().mockResolvedValue({ success: true, message: "ok" }),
84
+ });
85
+
86
+ const result = await autoStartServer(baseConfig, deps);
87
+
88
+ expect(result.server).toEqual({ host: "myhost.local", port: 8000, piPort: 9998 });
89
+ });
90
+
91
+ it("suppresses warning when launch fails but health check succeeds on recheck", async () => {
92
+ const deps = makeDeps({
93
+ discoverDashboard: vi.fn().mockResolvedValue([]),
94
+ isDashboardRunning: vi.fn()
95
+ .mockResolvedValueOnce({ running: false }) // initial check
96
+ .mockResolvedValueOnce({ running: true }), // recheck after failure
97
+ launchServer: vi.fn().mockResolvedValue({ success: false, message: "exited" }),
98
+ });
99
+
100
+ const result = await autoStartServer(baseConfig, deps);
101
+
102
+ expect(deps.notify).not.toHaveBeenCalled();
103
+ expect(result.server).toEqual({ host: "localhost", port: 8000, piPort: 9999 });
104
+ });
105
+
106
+ it("shows warning when launch fails and recheck also fails", async () => {
107
+ const deps = makeDeps({
108
+ discoverDashboard: vi.fn().mockResolvedValue([]),
109
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
110
+ launchServer: vi.fn().mockResolvedValue({ success: false, message: "exited" }),
111
+ });
112
+
113
+ const result = await autoStartServer(baseConfig, deps);
114
+
115
+ expect(deps.notify).toHaveBeenCalledWith(
116
+ "Dashboard server failed to start: exited",
117
+ "warning",
118
+ );
119
+ expect(result.server).toBeUndefined();
120
+ });
121
+
122
+ it("does nothing when autoStart is disabled and no server found", async () => {
123
+ const deps = makeDeps({
124
+ discoverDashboard: vi.fn().mockResolvedValue([]),
125
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
126
+ });
127
+
128
+ const result = await autoStartServer({ ...baseConfig, autoStart: false }, deps);
129
+
130
+ expect(deps.launchServer).not.toHaveBeenCalled();
131
+ expect(result.server).toBeUndefined();
132
+ });
133
+
134
+ it("shows port conflict warning when port is occupied by another service", async () => {
135
+ const deps = makeDeps({
136
+ discoverDashboard: vi.fn().mockResolvedValue([]),
137
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false, portConflict: true }),
138
+ });
139
+
140
+ const result = await autoStartServer(baseConfig, deps);
141
+
142
+ expect(deps.launchServer).not.toHaveBeenCalled();
143
+ expect(deps.notify).toHaveBeenCalledWith(
144
+ "Port 8000 is occupied by another service",
145
+ "warning",
146
+ );
147
+ expect(result.server).toBeUndefined();
148
+ });
149
+
150
+ it("prefers local server over remote when both discovered via mDNS", async () => {
151
+ const remote: DiscoveredServer = {
152
+ host: "remote.local", port: 8000, piPort: 9999,
153
+ isLocal: false, source: "mdns",
154
+ };
155
+ const local: DiscoveredServer = {
156
+ host: "myhost.local", port: 8000, piPort: 9999,
157
+ isLocal: true, source: "mdns",
158
+ };
159
+ const deps = makeDeps({
160
+ discoverDashboard: vi.fn().mockResolvedValue([remote, local]),
161
+ });
162
+
163
+ const result = await autoStartServer(baseConfig, deps);
164
+
165
+ expect(result.server).toEqual({ host: "myhost.local", port: 8000, piPort: 9999 });
166
+ });
167
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { resolveServerCliPath } from "../server-launcher.js";
3
+ import path from "node:path";
4
+
5
+ describe("server-launcher", () => {
6
+ it("should resolve server CLI path relative to extension", () => {
7
+ const cliPath = resolveServerCliPath();
8
+ // From src/extension/server-launcher.ts → ../../server/cli.ts
9
+ expect(cliPath).toContain("server");
10
+ expect(cliPath).toContain("cli.ts");
11
+ expect(path.isAbsolute(cliPath)).toBe(true);
12
+ });
13
+
14
+ it("should build correct spawn args from config", async () => {
15
+ // We test the arg building logic, not the actual spawn
16
+ const { buildSpawnArgs } = await import("../server-launcher.js");
17
+ const args = buildSpawnArgs({
18
+ port: 3000,
19
+ piPort: 4000,
20
+ autoStart: true,
21
+ autoShutdown: true,
22
+ shutdownIdleSeconds: 300,
23
+ spawnStrategy: "tmux",
24
+ tunnel: { enabled: true },
25
+ devBuildOnReload: false,
26
+ memoryLimits: { maxEventsPerSession: 5000, maxStringFieldSize: 0, maxWsBufferBytes: 4194304 },
27
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
28
+ });
29
+
30
+ expect(args).toContain("--port");
31
+ expect(args).toContain("3000");
32
+ expect(args).toContain("--pi-port");
33
+ expect(args).toContain("4000");
34
+ });
35
+
36
+ it("should use resolveJitiImport in launchServer spawn args", async () => {
37
+ // Verify server-launcher imports resolveJitiImport (compile-time check).
38
+ // At runtime inside pi, resolveJitiImport returns the jiti path.
39
+ // In test context (no pi), it throws — which confirms the tsx fallback is gone.
40
+ const mod = await import("../server-launcher.js");
41
+ expect(mod.resolveServerCliPath).toBeDefined();
42
+ expect(mod.buildSpawnArgs).toBeDefined();
43
+ });
44
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isPortOpen } from "../server-probe.js";
3
+ import net from "node:net";
4
+
5
+ describe("isPortOpen", () => {
6
+ it("should return true when a server is listening", async () => {
7
+ // Start a temporary server
8
+ const server = net.createServer();
9
+ await new Promise<void>((resolve) => server.listen(0, resolve));
10
+ const port = (server.address() as net.AddressInfo).port;
11
+
12
+ try {
13
+ const result = await isPortOpen(port);
14
+ expect(result).toBe(true);
15
+ } finally {
16
+ server.close();
17
+ }
18
+ });
19
+
20
+ it("should return false when no server is listening", async () => {
21
+ // Use a port that's almost certainly not in use
22
+ const result = await isPortOpen(59321);
23
+ expect(result).toBe(false);
24
+ });
25
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for session switch/fork behavior in the bridge extension.
3
+ * These test the helper functions and logic used during session changes.
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+
7
+ // We test the extractFirstMessage logic inline since it's a local function in bridge.ts.
8
+ // Replicate the logic here to verify behavior.
9
+
10
+ function extractFirstMessage(ctx: any): string | undefined {
11
+ try {
12
+ const entries = ctx.sessionManager?.getEntries?.();
13
+ if (!entries || !Array.isArray(entries)) return undefined;
14
+ for (const entry of entries) {
15
+ if (entry.role === "user" && typeof entry.content === "string") {
16
+ return entry.content.slice(0, 200);
17
+ }
18
+ if (entry.role === "user" && Array.isArray(entry.content)) {
19
+ for (const part of entry.content) {
20
+ if (part.type === "text" && typeof part.text === "string") {
21
+ return part.text.slice(0, 200);
22
+ }
23
+ }
24
+ }
25
+ }
26
+ } catch { /* ignore */ }
27
+ return undefined;
28
+ }
29
+
30
+ describe("extractFirstMessage", () => {
31
+ it("should extract first user message as string content", () => {
32
+ const ctx = {
33
+ sessionManager: {
34
+ getEntries: () => [
35
+ { role: "system", content: "You are a helpful assistant" },
36
+ { role: "user", content: "Help me fix the auth module" },
37
+ { role: "assistant", content: "Sure, I'll help" },
38
+ ],
39
+ },
40
+ };
41
+ expect(extractFirstMessage(ctx)).toBe("Help me fix the auth module");
42
+ });
43
+
44
+ it("should extract first user message from array content", () => {
45
+ const ctx = {
46
+ sessionManager: {
47
+ getEntries: () => [
48
+ {
49
+ role: "user",
50
+ content: [
51
+ { type: "text", text: "Look at this image and fix the bug" },
52
+ { type: "image", data: "base64..." },
53
+ ],
54
+ },
55
+ ],
56
+ },
57
+ };
58
+ expect(extractFirstMessage(ctx)).toBe("Look at this image and fix the bug");
59
+ });
60
+
61
+ it("should truncate long messages to 200 chars", () => {
62
+ const longMessage = "a".repeat(300);
63
+ const ctx = {
64
+ sessionManager: {
65
+ getEntries: () => [{ role: "user", content: longMessage }],
66
+ },
67
+ };
68
+ expect(extractFirstMessage(ctx)).toBe("a".repeat(200));
69
+ });
70
+
71
+ it("should return undefined when no entries exist", () => {
72
+ const ctx = {
73
+ sessionManager: {
74
+ getEntries: () => [],
75
+ },
76
+ };
77
+ expect(extractFirstMessage(ctx)).toBeUndefined();
78
+ });
79
+
80
+ it("should return undefined when getEntries is not available", () => {
81
+ const ctx = { sessionManager: {} };
82
+ expect(extractFirstMessage(ctx)).toBeUndefined();
83
+ });
84
+
85
+ it("should return undefined when sessionManager is not available", () => {
86
+ expect(extractFirstMessage({})).toBeUndefined();
87
+ });
88
+
89
+ it("should handle errors gracefully", () => {
90
+ const ctx = {
91
+ sessionManager: {
92
+ getEntries: () => { throw new Error("fail"); },
93
+ },
94
+ };
95
+ expect(extractFirstMessage(ctx)).toBeUndefined();
96
+ });
97
+ });
98
+
99
+ describe("session switch flow", () => {
100
+ it("should produce unregister for old ID and register for new ID", () => {
101
+ // This tests the expected message sequence during a session switch.
102
+ // The actual bridge sends these messages; here we verify the logic.
103
+ const messages: any[] = [];
104
+ const send = (msg: any) => messages.push(msg);
105
+
106
+ const oldSessionId = "old-uuid";
107
+ let sessionId = oldSessionId;
108
+
109
+ // Simulate handleSessionChange
110
+ send({ type: "session_unregister", sessionId });
111
+
112
+ sessionId = "new-uuid"; // ctx.sessionManager.getSessionId() returns new ID
113
+
114
+ send({
115
+ type: "session_register",
116
+ sessionId,
117
+ cwd: "/project",
118
+ source: "tui",
119
+ });
120
+
121
+ expect(messages).toHaveLength(2);
122
+ expect(messages[0]).toEqual({ type: "session_unregister", sessionId: "old-uuid" });
123
+ expect(messages[1]).toMatchObject({ type: "session_register", sessionId: "new-uuid" });
124
+ });
125
+
126
+ it("should handle fork identically to switch", () => {
127
+ // Fork produces same message pattern: unregister old, register new
128
+ const messages: any[] = [];
129
+ const send = (msg: any) => messages.push(msg);
130
+
131
+ let sessionId = "original-uuid";
132
+ send({ type: "session_unregister", sessionId });
133
+ sessionId = "forked-uuid";
134
+ send({ type: "session_register", sessionId, cwd: "/project", source: "tui" });
135
+
136
+ expect(messages[0].sessionId).toBe("original-uuid");
137
+ expect(messages[1].sessionId).toBe("forked-uuid");
138
+ });
139
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for session-sync: sendStateSync and handleSessionSwitch.
3
+ */
4
+ import { describe, it, expect, vi } from "vitest";
5
+ import { sendStateSync } from "../session-sync.js";
6
+ import type { BridgeContext } from "../bridge-context.js";
7
+
8
+ function createMockBridgeContext(overrides?: Partial<BridgeContext>): BridgeContext {
9
+ const sent: any[] = [];
10
+ return {
11
+ pi: {
12
+ getSessionName: () => "test-session",
13
+ getCommands: () => [],
14
+ } as any,
15
+ connection: {
16
+ send: (msg: any) => sent.push(msg),
17
+ } as any,
18
+ sessionId: "sess-123",
19
+ cachedCtx: {
20
+ sessionManager: {
21
+ getSessionFile: () => "/path/to/session.json",
22
+ getSessionDir: () => "/path/to/session",
23
+ getBranch: () => [{ role: "user", content: "hello" }],
24
+ getEntries: () => [{ role: "user", content: "hello" }],
25
+ },
26
+ },
27
+ cachedModelRegistry: null,
28
+ cachedHasUI: true,
29
+ lastModel: undefined,
30
+ lastThinkingLevel: undefined,
31
+ lastSessionFile: undefined,
32
+ lastSessionDir: undefined,
33
+ lastFirstMessage: undefined,
34
+ lastGitBranch: undefined,
35
+ lastGitPrNumber: undefined,
36
+ lastSessionName: undefined,
37
+ ...overrides,
38
+ // Expose sent messages for assertions
39
+ _sent: sent,
40
+ } as any;
41
+ }
42
+
43
+ describe("sendStateSync", () => {
44
+ it("should include pid in session_register message", () => {
45
+ const bc = createMockBridgeContext();
46
+ sendStateSync(bc, () => []);
47
+
48
+ const sent = (bc as any)._sent;
49
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
50
+ expect(registerMsg).toBeDefined();
51
+ expect(registerMsg.pid).toBe(process.pid);
52
+ expect(typeof registerMsg.pid).toBe("number");
53
+ expect(registerMsg.pid).toBeGreaterThan(0);
54
+ });
55
+ });