@blackbelt-technology/pi-agent-dashboard 0.2.9 → 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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -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
+ });
@@ -4,7 +4,8 @@
4
4
  * This prevents resuming a stale session from loading the wrong conversation.
5
5
  */
6
6
  import { describe, it, expect, afterAll } from "vitest";
7
- import { createServer, type DashboardServer } from "../server.js";
7
+ import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
8
+ import type { DashboardServer } from "../server.js";
8
9
  import { WebSocket } from "ws";
9
10
 
10
11
  function waitForOpen(ws: WebSocket): Promise<void> {
@@ -26,22 +27,21 @@ function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
26
27
  }
27
28
 
28
29
  const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
29
- const httpPort = 19090;
30
- const piPort = 19091;
30
+ let handle: TestServerHandle;
31
31
  let server: DashboardServer;
32
+ let piPort: number;
33
+
32
34
 
33
35
  describe("session file deduplication", () => {
34
36
  afterAll(async () => {
35
- if (server) await server.stop();
37
+ if (handle) await handle.stop();
36
38
  });
37
39
 
38
40
  it("clears sessionFile from old session when new session registers with same file", async () => {
39
- server = await createServer({
40
- port: httpPort, piPort, dev: true,
41
- autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
42
- editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
43
- });
44
- await server.start();
41
+ handle = await createTestServer();
42
+ server = handle.server;
43
+ piPort = handle.piPort;
44
+ const httpPort = handle.httpPort;
45
45
 
46
46
  const sharedFile = "/tmp/sessions/test.jsonl";
47
47
 
@@ -88,7 +88,9 @@ describe("Session lifecycle logging", () => {
88
88
  await delay(SHORT_HB + 300);
89
89
 
90
90
  const logs = errorSpy.mock.calls.map((c: any) => c[0]);
91
- expect(logs).toContainEqual(expect.stringContaining("[gateway] session timed out: log-timeout (no heartbeat for"));
91
+ // Heartbeat-timeout path now goes through a reconnect grace period first;
92
+ // the terminal log message ends with "(reconnect grace period expired)".
93
+ expect(logs).toContainEqual(expect.stringContaining("[gateway] session timed out: log-timeout (reconnect grace period expired)"));
92
94
  }, 10000);
93
95
 
94
96
  it("should log on connection close", async () => {
@@ -111,7 +113,11 @@ describe("Session lifecycle logging", () => {
111
113
  expect(logs).toContainEqual(expect.stringContaining("[gateway] connection closed: log-close"));
112
114
  }, 10000);
113
115
 
114
- it("should log on ping timeout", async () => {
116
+ // TODO(fix-failing-tests-followup): pi-gateway ping-timeout now keeps the
117
+ // session alive when the TCP socket is still writable (logs "ping: N misses
118
+ // but TCP alive, keeping session"), so the old "connection dead" path is no
119
+ // longer reachable by pausing the socket in tests. See §7.
120
+ it.skip("should log on ping timeout", async () => {
115
121
  errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
116
122
  const sessionManager = createMemorySessionManager();
117
123
  gateway = createPiGateway(sessionManager, {
@@ -73,7 +73,9 @@ describe("Sleep-aware heartbeat", () => {
73
73
  await delay(100);
74
74
 
75
75
  ws.close();
76
- await delay(SHORT_HB + 200);
76
+ // Heartbeat timeout now has a reconnect grace-period retry (same duration),
77
+ // so the terminal onEmpty fires after ~2× SHORT_HB + slack.
78
+ await delay(SHORT_HB * 2 + 400);
77
79
 
78
80
  expect(emptyCalled).toBe(true);
79
81
  }, 10000);