@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Tests for server-side /reload handling on headless pi sessions.
3
+ *
4
+ * See change: headless-reload-via-respawn.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
7
+
8
+ // Mock spawnPiSession BEFORE importing the handler.
9
+ vi.mock("../process-manager.js", () => ({
10
+ spawnPiSession: vi.fn(),
11
+ }));
12
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/config.js", () => ({
13
+ loadConfig: () => ({ spawnStrategy: "headless" as const }),
14
+ }));
15
+
16
+ import {
17
+ handleHeadlessReload,
18
+ handleSendPrompt,
19
+ } from "../browser-handlers/session-action-handler.js";
20
+ import { spawnPiSession } from "../process-manager.js";
21
+
22
+ type SentMessage = { type: string; [k: string]: unknown };
23
+ type InsertedEvent = {
24
+ sessionId: string;
25
+ event: { eventType: string; data: Record<string, unknown>; timestamp: number };
26
+ };
27
+
28
+ function makeCtx(
29
+ options: {
30
+ pidBySession?: Record<string, number | undefined>;
31
+ sessions?: Record<string, any>;
32
+ } = {},
33
+ ) {
34
+ const broadcasts: SentMessage[] = [];
35
+ const insertedEvents: InsertedEvent[] = [];
36
+ const killCalls: string[] = [];
37
+ const registerCalls: Array<{ pid: number; cwd: string; proc: unknown }> = [];
38
+ const sessionUpdates: Array<{ id: string; updates: any }> = [];
39
+
40
+ const pidBySession: Record<string, number | undefined> = {
41
+ ...(options.pidBySession ?? {}),
42
+ };
43
+ const sessions: Record<string, any> = { ...(options.sessions ?? {}) };
44
+
45
+ const ctx = {
46
+ ws: { readyState: 1 } as any,
47
+ sessionManager: {
48
+ get: (sid: string) => sessions[sid],
49
+ update: (sid: string, updates: any) => {
50
+ sessionUpdates.push({ id: sid, updates });
51
+ if (sessions[sid]) Object.assign(sessions[sid], updates);
52
+ },
53
+ unregister: vi.fn(),
54
+ },
55
+ piGateway: {
56
+ sendToSession: vi.fn().mockReturnValue(true),
57
+ },
58
+ headlessPidRegistry: {
59
+ getPid: (sid: string) => pidBySession[sid],
60
+ killBySessionId: (sid: string) => {
61
+ killCalls.push(sid);
62
+ // Simulate immediate removal from registry on kill
63
+ pidBySession[sid] = undefined;
64
+ return true;
65
+ },
66
+ register: (pid: number, cwd: string, proc: unknown) => {
67
+ registerCalls.push({ pid, cwd, proc });
68
+ // Simulate the registry linking the session after re-register
69
+ const existingSessionId = Object.keys(sessions).find(
70
+ (id) => sessions[id]?.cwd === cwd,
71
+ );
72
+ if (existingSessionId) pidBySession[existingSessionId] = pid;
73
+ },
74
+ },
75
+ pendingResumeRegistry: { record: vi.fn(), consume: vi.fn() },
76
+ pendingDashboardSpawns: new Map<string, number>(),
77
+ eventStore: {
78
+ insertEvent: (sid: string, event: any) => {
79
+ insertedEvents.push({ sessionId: sid, event });
80
+ return insertedEvents.length; // fake seq
81
+ },
82
+ },
83
+ broadcast: (m: SentMessage) => {
84
+ broadcasts.push(m);
85
+ },
86
+ sendTo: (_ws: unknown, m: SentMessage) => {
87
+ broadcasts.push(m);
88
+ },
89
+ } as any;
90
+
91
+ return {
92
+ ctx,
93
+ broadcasts,
94
+ insertedEvents,
95
+ killCalls,
96
+ registerCalls,
97
+ sessionUpdates,
98
+ pidBySession,
99
+ sessions,
100
+ };
101
+ }
102
+
103
+ function findFeedback(events: InsertedEvent[]) {
104
+ return events
105
+ .filter((e) => e.event.eventType === "command_feedback")
106
+ .map((e) => e.event.data);
107
+ }
108
+
109
+ describe("handleHeadlessReload — happy path", () => {
110
+ beforeEach(() => vi.clearAllMocks());
111
+ afterEach(() => vi.restoreAllMocks());
112
+
113
+ it("kills old pi, spawns new pi, registers new PID, emits started+completed", async () => {
114
+ (spawnPiSession as any).mockResolvedValueOnce({
115
+ success: true,
116
+ pid: 9999,
117
+ process: { _fake: true },
118
+ message: "ok",
119
+ });
120
+
121
+ const { ctx, killCalls, registerCalls, insertedEvents } = makeCtx({
122
+ pidBySession: { S1: 1234 },
123
+ sessions: {
124
+ S1: {
125
+ id: "S1",
126
+ cwd: "/home/u/proj",
127
+ sessionFile: "/home/u/proj/.pi/sessions/abc.jsonl",
128
+ status: "active",
129
+ },
130
+ },
131
+ });
132
+
133
+ await handleHeadlessReload(
134
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
135
+ ctx,
136
+ );
137
+
138
+ // Kill came before spawn
139
+ expect(killCalls).toEqual(["S1"]);
140
+ expect(spawnPiSession).toHaveBeenCalledTimes(1);
141
+ expect(spawnPiSession).toHaveBeenCalledWith(
142
+ "/home/u/proj",
143
+ expect.objectContaining({
144
+ sessionFile: "/home/u/proj/.pi/sessions/abc.jsonl",
145
+ mode: "continue",
146
+ strategy: "headless",
147
+ }),
148
+ );
149
+
150
+ // New PID registered
151
+ expect(registerCalls).toHaveLength(1);
152
+ expect(registerCalls[0].pid).toBe(9999);
153
+
154
+ // Feedback sequence: started → completed
155
+ const feedback = findFeedback(insertedEvents);
156
+ expect(feedback.map((f) => f.status)).toEqual(["started", "completed"]);
157
+ });
158
+ });
159
+
160
+ describe("handleHeadlessReload — streaming session", () => {
161
+ beforeEach(() => vi.clearAllMocks());
162
+ afterEach(() => vi.restoreAllMocks());
163
+
164
+ it("rejects reload when session is streaming; no kill, no spawn", async () => {
165
+ const { ctx, killCalls, insertedEvents } = makeCtx({
166
+ pidBySession: { S1: 1234 },
167
+ sessions: {
168
+ S1: {
169
+ id: "S1",
170
+ cwd: "/p",
171
+ sessionFile: "/p/s.jsonl",
172
+ status: "streaming",
173
+ },
174
+ },
175
+ });
176
+
177
+ await handleHeadlessReload(
178
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
179
+ ctx,
180
+ );
181
+
182
+ expect(killCalls).toEqual([]);
183
+ expect(spawnPiSession).not.toHaveBeenCalled();
184
+
185
+ const feedback = findFeedback(insertedEvents);
186
+ expect(feedback).toHaveLength(1);
187
+ expect(feedback[0].status).toBe("error");
188
+ expect(String(feedback[0].message)).toMatch(/response/i);
189
+ });
190
+ });
191
+
192
+ describe("handleHeadlessReload — spawn failure", () => {
193
+ beforeEach(() => vi.clearAllMocks());
194
+ afterEach(() => vi.restoreAllMocks());
195
+
196
+ it("broadcasts session_updated{status:ended} and error feedback when spawnPiSession returns failure", async () => {
197
+ (spawnPiSession as any).mockResolvedValueOnce({
198
+ success: false,
199
+ message: "tmux unavailable, headless failed",
200
+ });
201
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
202
+
203
+ const { ctx, broadcasts, sessionUpdates, insertedEvents } = makeCtx({
204
+ pidBySession: { S1: 1234 },
205
+ sessions: {
206
+ S1: {
207
+ id: "S1",
208
+ cwd: "/p",
209
+ sessionFile: "/p/s.jsonl",
210
+ status: "active",
211
+ },
212
+ },
213
+ });
214
+
215
+ await handleHeadlessReload(
216
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
217
+ ctx,
218
+ );
219
+
220
+ expect(errSpy).toHaveBeenCalledWith(
221
+ expect.stringContaining("headless reload spawn failed"),
222
+ );
223
+
224
+ // Session marked ended
225
+ expect(
226
+ sessionUpdates.some(
227
+ (u) => u.id === "S1" && u.updates.status === "ended",
228
+ ),
229
+ ).toBe(true);
230
+ // session_updated broadcast
231
+ expect(
232
+ broadcasts.some(
233
+ (m) =>
234
+ m.type === "session_updated" &&
235
+ (m as any).sessionId === "S1" &&
236
+ ((m as any).updates as any).status === "ended",
237
+ ),
238
+ ).toBe(true);
239
+
240
+ // Final feedback is error
241
+ const feedback = findFeedback(insertedEvents);
242
+ expect(feedback[feedback.length - 1].status).toBe("error");
243
+ expect(String(feedback[feedback.length - 1].message)).toContain(
244
+ "tmux unavailable",
245
+ );
246
+
247
+ errSpy.mockRestore();
248
+ });
249
+
250
+ it("handles spawnPiSession throwing", async () => {
251
+ (spawnPiSession as any).mockRejectedValueOnce(new Error("ENOENT: pi not found"));
252
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
253
+
254
+ const { ctx, sessionUpdates, insertedEvents } = makeCtx({
255
+ pidBySession: { S1: 1234 },
256
+ sessions: {
257
+ S1: { id: "S1", cwd: "/p", sessionFile: "/p/s.jsonl", status: "active" },
258
+ },
259
+ });
260
+
261
+ await handleHeadlessReload(
262
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
263
+ ctx,
264
+ );
265
+
266
+ expect(
267
+ sessionUpdates.some((u) => u.updates.status === "ended"),
268
+ ).toBe(true);
269
+ const feedback = findFeedback(insertedEvents);
270
+ expect(feedback[feedback.length - 1].status).toBe("error");
271
+ expect(String(feedback[feedback.length - 1].message)).toMatch(/ENOENT/);
272
+
273
+ errSpy.mockRestore();
274
+ });
275
+ });
276
+
277
+ describe("handleHeadlessReload — missing session file", () => {
278
+ beforeEach(() => vi.clearAllMocks());
279
+ afterEach(() => vi.restoreAllMocks());
280
+
281
+ it("errors when the session has no sessionFile", async () => {
282
+ const { ctx, insertedEvents, killCalls } = makeCtx({
283
+ pidBySession: { S1: 1234 },
284
+ sessions: {
285
+ S1: { id: "S1", cwd: "/p", status: "active" },
286
+ },
287
+ });
288
+
289
+ await handleHeadlessReload(
290
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
291
+ ctx,
292
+ );
293
+
294
+ expect(killCalls).toEqual([]);
295
+ expect(spawnPiSession).not.toHaveBeenCalled();
296
+
297
+ const feedback = findFeedback(insertedEvents);
298
+ expect(feedback).toHaveLength(1);
299
+ expect(feedback[0].status).toBe("error");
300
+ });
301
+ });
302
+
303
+ describe("handleHeadlessReload — concurrent calls", () => {
304
+ beforeEach(() => vi.clearAllMocks());
305
+ afterEach(() => vi.restoreAllMocks());
306
+
307
+ it("two back-to-back /reload calls still register exactly one live PID (the second)", async () => {
308
+ let nextPid = 7001;
309
+ (spawnPiSession as any).mockImplementation(async () => ({
310
+ success: true,
311
+ pid: nextPid++,
312
+ process: { _fake: true },
313
+ }));
314
+
315
+ const { ctx, killCalls, registerCalls, pidBySession } = makeCtx({
316
+ pidBySession: { S1: 1234 },
317
+ sessions: {
318
+ S1: {
319
+ id: "S1",
320
+ cwd: "/p",
321
+ sessionFile: "/p/s.jsonl",
322
+ status: "active",
323
+ },
324
+ },
325
+ });
326
+
327
+ // Fire two concurrent reloads.
328
+ const [r1, r2] = await Promise.all([
329
+ handleHeadlessReload(
330
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
331
+ ctx,
332
+ ),
333
+ handleHeadlessReload(
334
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
335
+ ctx,
336
+ ),
337
+ ]);
338
+ expect(r1).toBeUndefined();
339
+ expect(r2).toBeUndefined();
340
+
341
+ // First call kills the original; second call observes no PID and kills noop.
342
+ expect(killCalls.length).toBeGreaterThanOrEqual(1);
343
+ expect(killCalls.length).toBeLessThanOrEqual(2);
344
+
345
+ // Both calls spawned, but registry ended with one live PID (the later one).
346
+ expect(spawnPiSession).toHaveBeenCalledTimes(2);
347
+ expect(registerCalls).toHaveLength(2);
348
+ expect(pidBySession.S1).toBe(registerCalls[registerCalls.length - 1].pid);
349
+ });
350
+ });
351
+
352
+ describe("handleSendPrompt — interception wiring", () => {
353
+ beforeEach(() => vi.clearAllMocks());
354
+ afterEach(() => vi.restoreAllMocks());
355
+
356
+ it("/reload on a headless session triggers respawn, NOT bridge forward", async () => {
357
+ (spawnPiSession as any).mockResolvedValueOnce({
358
+ success: true,
359
+ pid: 4242,
360
+ process: { _fake: true },
361
+ });
362
+
363
+ const { ctx } = makeCtx({
364
+ pidBySession: { S1: 1234 },
365
+ sessions: {
366
+ S1: {
367
+ id: "S1",
368
+ cwd: "/p",
369
+ sessionFile: "/p/s.jsonl",
370
+ status: "active",
371
+ },
372
+ },
373
+ });
374
+
375
+ await handleSendPrompt(
376
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
377
+ ctx,
378
+ );
379
+
380
+ expect(spawnPiSession).toHaveBeenCalledTimes(1);
381
+ expect(ctx.piGateway.sendToSession).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it("/reload on a non-headless (tmux) session forwards to the bridge unchanged", async () => {
385
+ const { ctx } = makeCtx({
386
+ pidBySession: { S1: undefined }, // no PID → non-headless
387
+ sessions: {
388
+ S1: {
389
+ id: "S1",
390
+ cwd: "/p",
391
+ sessionFile: "/p/s.jsonl",
392
+ status: "active",
393
+ },
394
+ },
395
+ });
396
+
397
+ await handleSendPrompt(
398
+ { type: "send_prompt", sessionId: "S1", text: "/reload" } as any,
399
+ ctx,
400
+ );
401
+
402
+ expect(spawnPiSession).not.toHaveBeenCalled();
403
+ expect(ctx.piGateway.sendToSession).toHaveBeenCalledWith(
404
+ "S1",
405
+ expect.objectContaining({
406
+ type: "send_prompt",
407
+ text: "/reload",
408
+ }),
409
+ );
410
+ });
411
+
412
+ it("non-/reload prompt on a headless session still forwards to the bridge", async () => {
413
+ const { ctx } = makeCtx({
414
+ pidBySession: { S1: 1234 }, // headless
415
+ sessions: {
416
+ S1: {
417
+ id: "S1",
418
+ cwd: "/p",
419
+ sessionFile: "/p/s.jsonl",
420
+ status: "active",
421
+ },
422
+ },
423
+ });
424
+
425
+ await handleSendPrompt(
426
+ {
427
+ type: "send_prompt",
428
+ sessionId: "S1",
429
+ text: "please do the thing",
430
+ } as any,
431
+ ctx,
432
+ );
433
+
434
+ expect(spawnPiSession).not.toHaveBeenCalled();
435
+ expect(ctx.piGateway.sendToSession).toHaveBeenCalledWith(
436
+ "S1",
437
+ expect.objectContaining({ text: "please do the thing" }),
438
+ );
439
+ });
440
+
441
+ it("/reload with images on a headless session is NOT intercepted (falls through to bridge)", async () => {
442
+ const { ctx } = makeCtx({
443
+ pidBySession: { S1: 1234 },
444
+ sessions: {
445
+ S1: {
446
+ id: "S1",
447
+ cwd: "/p",
448
+ sessionFile: "/p/s.jsonl",
449
+ status: "active",
450
+ },
451
+ },
452
+ });
453
+
454
+ await handleSendPrompt(
455
+ {
456
+ type: "send_prompt",
457
+ sessionId: "S1",
458
+ text: "/reload",
459
+ images: [{ type: "image", data: "x" }],
460
+ } as any,
461
+ ctx,
462
+ );
463
+
464
+ expect(spawnPiSession).not.toHaveBeenCalled();
465
+ expect(ctx.piGateway.sendToSession).toHaveBeenCalled();
466
+ });
467
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { shouldInterceptReload } from "../browser-handlers/session-action-helpers.js";
3
+
4
+ function makeRegistry(pidBySessionId: Record<string, number | undefined>) {
5
+ return {
6
+ getPid(sid: string) {
7
+ return pidBySessionId[sid];
8
+ },
9
+ };
10
+ }
11
+
12
+ function msg(overrides: Partial<{ text: string; images: unknown[]; sessionId: string }> = {}) {
13
+ return {
14
+ type: "send_prompt" as const,
15
+ sessionId: overrides.sessionId ?? "S1",
16
+ text: overrides.text ?? "/reload",
17
+ images: overrides.images as any,
18
+ };
19
+ }
20
+
21
+ describe("shouldInterceptReload", () => {
22
+ it("returns true for exact '/reload' on a tracked headless session", () => {
23
+ const reg = makeRegistry({ S1: 1234 });
24
+ expect(shouldInterceptReload(msg() as any, reg)).toBe(true);
25
+ });
26
+
27
+ it("returns false for trailing whitespace ' /reload '", () => {
28
+ const reg = makeRegistry({ S1: 1234 });
29
+ expect(shouldInterceptReload(msg({ text: " /reload" }) as any, reg)).toBe(false);
30
+ expect(shouldInterceptReload(msg({ text: "/reload " }) as any, reg)).toBe(false);
31
+ });
32
+
33
+ it("returns false for '/reload something'", () => {
34
+ const reg = makeRegistry({ S1: 1234 });
35
+ expect(shouldInterceptReload(msg({ text: "/reload arg" }) as any, reg)).toBe(false);
36
+ });
37
+
38
+ it("returns false when images are attached", () => {
39
+ const reg = makeRegistry({ S1: 1234 });
40
+ expect(
41
+ shouldInterceptReload(msg({ images: [{ type: "image", data: "xxx" }] }) as any, reg),
42
+ ).toBe(false);
43
+ });
44
+
45
+ it("returns true when images is an empty array", () => {
46
+ const reg = makeRegistry({ S1: 1234 });
47
+ expect(shouldInterceptReload(msg({ images: [] }) as any, reg)).toBe(true);
48
+ });
49
+
50
+ it("returns false when the session has no tracked PID (non-headless)", () => {
51
+ const reg = makeRegistry({ S1: undefined });
52
+ expect(shouldInterceptReload(msg() as any, reg)).toBe(false);
53
+ });
54
+
55
+ it("returns false for the wrong session id", () => {
56
+ const reg = makeRegistry({ S1: 1234 });
57
+ expect(shouldInterceptReload(msg({ sessionId: "OTHER" }) as any, reg)).toBe(false);
58
+ });
59
+
60
+ it("returns false for unrelated slash commands", () => {
61
+ const reg = makeRegistry({ S1: 1234 });
62
+ expect(shouldInterceptReload(msg({ text: "/new" }) as any, reg)).toBe(false);
63
+ expect(shouldInterceptReload(msg({ text: "/quit" }) as any, reg)).toBe(false);
64
+ expect(shouldInterceptReload(msg({ text: "hello" }) as any, reg)).toBe(false);
65
+ });
66
+
67
+ it("still returns true even if the tracked PID is stale — liveness is checked later, not here", () => {
68
+ // shouldInterceptReload is a cheap gate. Liveness is the handler's job;
69
+ // killBySessionId is a no-op when the process is already dead.
70
+ const reg = makeRegistry({ S1: 99999999 });
71
+ expect(shouldInterceptReload(msg() as any, reg)).toBe(true);
72
+ });
73
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // Mock spawnPiSession BEFORE importing the handler.
4
+ vi.mock("../process-manager.js", () => ({
5
+ spawnPiSession: vi.fn(),
6
+ }));
7
+ vi.mock("../../../shared/src/config.js", () => ({
8
+ loadConfig: () => ({ spawnStrategy: "headless" as const }),
9
+ }));
10
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/config.js", () => ({
11
+ loadConfig: () => ({ spawnStrategy: "headless" as const }),
12
+ }));
13
+
14
+ import { handleSpawnSession } from "../browser-handlers/session-action-handler.js";
15
+ import { spawnPiSession } from "../process-manager.js";
16
+
17
+ type SentMessage = { type: string; [k: string]: unknown };
18
+
19
+ function makeCtx() {
20
+ const sent: SentMessage[] = [];
21
+ const ws = { readyState: 1 } as unknown as WebSocket;
22
+ const ctx = {
23
+ ws,
24
+ headlessPidRegistry: { register: vi.fn() },
25
+ pendingDashboardSpawns: new Map<string, number>(),
26
+ sendTo: (_ws: unknown, msg: SentMessage) => { sent.push(msg); },
27
+ } as any;
28
+ return { ctx, sent };
29
+ }
30
+
31
+ describe("handleSpawnSession — error propagation", () => {
32
+ beforeEach(() => { vi.clearAllMocks(); });
33
+ afterEach(() => { vi.restoreAllMocks(); });
34
+
35
+ it("emits spawn_error when spawnPiSession throws", async () => {
36
+ (spawnPiSession as any).mockRejectedValueOnce(new Error("ENOENT: pi not found"));
37
+ const { ctx, sent } = makeCtx();
38
+ await handleSpawnSession({ type: "spawn_session", cwd: "C:\\proj" } as any, ctx);
39
+ const errMsg = sent.find(m => m.type === "spawn_error");
40
+ const resMsg = sent.find(m => m.type === "spawn_result");
41
+ expect(resMsg).toBeDefined();
42
+ expect(resMsg!.success).toBe(false);
43
+ expect(errMsg).toBeDefined();
44
+ expect(errMsg!.cwd).toBe("C:\\proj");
45
+ expect(errMsg!.strategy).toBe("headless");
46
+ expect(errMsg!.message).toMatch(/ENOENT/);
47
+ });
48
+
49
+ it("emits spawn_error when spawnPiSession returns { success: false }", async () => {
50
+ (spawnPiSession as any).mockResolvedValueOnce({ success: false, message: "tmux unavailable" });
51
+ const { ctx, sent } = makeCtx();
52
+ await handleSpawnSession({ type: "spawn_session", cwd: "/app" } as any, ctx);
53
+ const errMsg = sent.find(m => m.type === "spawn_error");
54
+ expect(errMsg).toBeDefined();
55
+ expect(errMsg!.message).toBe("tmux unavailable");
56
+ });
57
+
58
+ it("does NOT emit spawn_error on successful spawn", async () => {
59
+ (spawnPiSession as any).mockResolvedValueOnce({ success: true, message: "ok", pid: 1234 });
60
+ const { ctx, sent } = makeCtx();
61
+ await handleSpawnSession({ type: "spawn_session", cwd: "/app" } as any, ctx);
62
+ expect(sent.some(m => m.type === "spawn_error")).toBe(false);
63
+ expect(sent.some(m => m.type === "spawn_result" && m.success === true)).toBe(true);
64
+ });
65
+
66
+ it("includes stderr tail when thrown error carries one", async () => {
67
+ const err = Object.assign(new Error("boom"), { stderr: "line1\nline2\nline3" });
68
+ (spawnPiSession as any).mockRejectedValueOnce(err);
69
+ const { ctx, sent } = makeCtx();
70
+ await handleSpawnSession({ type: "spawn_session", cwd: "/x" } as any, ctx);
71
+ const errMsg = sent.find(m => m.type === "spawn_error");
72
+ expect(errMsg!.stderr).toContain("line3");
73
+ });
74
+ });
@@ -25,6 +25,12 @@ vi.mock("node-pty", () => ({
25
25
  })),
26
26
  }));
27
27
 
28
+ // Mock platform/process.ts killProcess so the Windows path is observable in tests.
29
+ const mockKillProcess = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true, forced: false }));
30
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/process.js", () => ({
31
+ killProcess: (...args: unknown[]) => mockKillProcess(...args),
32
+ }));
33
+
28
34
  describe("TerminalManager", () => {
29
35
  let manager: TerminalManager;
30
36
  let exitCallbacks: Array<(termId: string) => void>;
@@ -112,10 +118,44 @@ describe("TerminalManager", () => {
112
118
  });
113
119
 
114
120
  describe("kill", () => {
115
- it("sends SIGHUP to PTY (bash on Linux ignores SIGTERM)", () => {
121
+ beforeEach(() => {
122
+ mockKillProcess.mockClear();
123
+ });
124
+
125
+ it("POSIX: sends SIGHUP to PTY (bash on Linux ignores SIGTERM)", () => {
126
+ if (process.platform === "win32") return; // skipped on Windows; covered below
116
127
  const session = manager.spawn("/tmp");
117
128
  manager.kill(session.id);
118
129
  expect(mockPtyKill).toHaveBeenCalledWith("SIGHUP");
130
+ expect(mockKillProcess).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("Windows: routes kill through platform killProcess (tree kill via taskkill /F /T)", () => {
134
+ if (process.platform !== "win32") return; // skipped off-Windows
135
+ const session = manager.spawn("C:\\tmp");
136
+ manager.kill(session.id);
137
+ // pty.kill MUST NOT be called on Windows — killProcess(pid) does the tree-kill.
138
+ expect(mockPtyKill).not.toHaveBeenCalled();
139
+ expect(mockKillProcess).toHaveBeenCalledWith(12345, expect.objectContaining({ timeoutMs: 2000 }));
140
+ });
141
+
142
+ it("fallback cleanup fires if onExit does not within 3 s (simulates Windows ConPTY)", async () => {
143
+ vi.useFakeTimers();
144
+ try {
145
+ const session = manager.spawn("/tmp");
146
+ let exitCalled = false;
147
+ manager = createTerminalManager({
148
+ onExit: () => { exitCalled = true; },
149
+ });
150
+ const session2 = manager.spawn("/tmp");
151
+ manager.kill(session2.id);
152
+ // Simulate node-pty NOT firing onExit (the actual Windows failure mode).
153
+ await vi.advanceTimersByTimeAsync(3001);
154
+ expect(exitCalled).toBe(true);
155
+ expect(manager.get(session2.id)).toBeUndefined(); // removed from map
156
+ } finally {
157
+ vi.useRealTimers();
158
+ }
119
159
  });
120
160
 
121
161
  it("throws for unknown ID", () => {