@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,271 @@
1
+ /**
2
+ * Session action handlers: send_prompt, abort, resume, spawn, shutdown, flow_control.
3
+ */
4
+ import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
+ import type { BrowserHandlerContext } from "./handler-context.js";
6
+ import { spawnPiSession } from "../process-manager.js";
7
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
+ import { createBranchedSessionFile } from "../session-file-reader.js";
9
+ import { execSync } from "node:child_process";
10
+
11
+ function killHeadlessBySessionId(sessionId: string): boolean {
12
+ if (process.platform === "win32") return false;
13
+ try {
14
+ const output = execSync(
15
+ `ps -eo pid,command | grep "${sessionId}" | grep "sleep 2147483647" | grep -v grep`,
16
+ { encoding: "utf8", timeout: 3000 },
17
+ ).trim();
18
+ if (!output) return false;
19
+ for (const line of output.split("\n")) {
20
+ const pid = parseInt(line.trim(), 10);
21
+ if (pid > 0) {
22
+ try { process.kill(-pid, "SIGTERM"); } catch {
23
+ try { process.kill(pid, "SIGTERM"); } catch { /* ignore */ }
24
+ }
25
+ }
26
+ }
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export async function handleSendPrompt(
34
+ msg: Extract<BrowserToServerMessage, { type: "send_prompt" }>,
35
+ ctx: BrowserHandlerContext,
36
+ ): Promise<void> {
37
+ const { sessionManager, piGateway, headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns, broadcast } = ctx;
38
+ const promptSession = sessionManager.get(msg.sessionId);
39
+
40
+ if (promptSession?.status === "ended") {
41
+ if (!promptSession.sessionFile) {
42
+ console.error(`[dashboard] auto-resume failed: no session file for session ${msg.sessionId}`);
43
+ return;
44
+ }
45
+ const alreadyResuming = promptSession.resuming;
46
+ pendingResumeRegistry.record(promptSession.cwd, {
47
+ text: msg.text,
48
+ images: msg.images,
49
+ oldSessionId: msg.sessionId,
50
+ sessionFile: promptSession.sessionFile,
51
+ });
52
+ if (alreadyResuming) return;
53
+ sessionManager.update(msg.sessionId, { resuming: true });
54
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { resuming: true } });
55
+ const autoResumeConfig = loadConfig();
56
+ const spawnResult = await spawnPiSession(promptSession.cwd, {
57
+ sessionFile: promptSession.sessionFile,
58
+ mode: "continue",
59
+ strategy: autoResumeConfig.spawnStrategy,
60
+ });
61
+ if (!spawnResult.success) {
62
+ console.error(`[dashboard] auto-resume spawn failed: ${spawnResult.message}`);
63
+ pendingResumeRegistry.consume(promptSession.cwd);
64
+ sessionManager.update(msg.sessionId, { resuming: false });
65
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { resuming: false } });
66
+ }
67
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
68
+ pendingDashboardSpawns?.set(promptSession.cwd, (pendingDashboardSpawns?.get(promptSession.cwd) ?? 0) + 1);
69
+ }
70
+ if (spawnResult.process && spawnResult.pid) {
71
+ headlessPidRegistry.register(spawnResult.pid, promptSession.cwd, spawnResult.process);
72
+ }
73
+ } else {
74
+ const sent = piGateway.sendToSession(msg.sessionId, {
75
+ type: "send_prompt",
76
+ sessionId: msg.sessionId,
77
+ text: msg.text,
78
+ images: msg.images,
79
+ });
80
+ if (!sent) {
81
+ console.error(`[dashboard] send_prompt failed: no bridge connection for session ${msg.sessionId}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ export async function handleResumeSession(
87
+ msg: Extract<BrowserToServerMessage, { type: "resume_session" }>,
88
+ ctx: BrowserHandlerContext,
89
+ ): Promise<void> {
90
+ const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
91
+ const session = sessionManager.get(msg.sessionId);
92
+ if (!session) {
93
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
94
+ return;
95
+ }
96
+ if (!session.sessionFile) {
97
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)" });
98
+ return;
99
+ }
100
+ if (msg.mode === "continue" && session.status !== "ended") {
101
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already active" });
102
+ return;
103
+ }
104
+ if (session.resuming) {
105
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session is already being resumed" });
106
+ return;
107
+ }
108
+ if (msg.mode === "fork" && pendingForkRegistry) {
109
+ pendingForkRegistry.recordFork(session.cwd, msg.sessionId);
110
+ }
111
+
112
+ // For fork-from-message: create a pruned session file first
113
+ let forkSessionFile = session.sessionFile;
114
+ if (msg.mode === "fork" && msg.entryId) {
115
+ try {
116
+ forkSessionFile = createBranchedSessionFile(session.sessionFile, msg.entryId);
117
+ } catch (err: any) {
118
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: `Fork from entry failed: ${err.message}` });
119
+ return;
120
+ }
121
+ }
122
+
123
+ const resumeConfig = loadConfig();
124
+ const result = await spawnPiSession(session.cwd, {
125
+ sessionFile: forkSessionFile,
126
+ mode: msg.mode,
127
+ strategy: resumeConfig.spawnStrategy,
128
+ });
129
+ if (result.dashboardSpawned && result.success) {
130
+ pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
131
+ }
132
+ if (result.process && result.pid) {
133
+ headlessPidRegistry.register(result.pid, session.cwd, result.process);
134
+ }
135
+ sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message });
136
+ }
137
+
138
+ export async function handleSpawnSession(
139
+ msg: Extract<BrowserToServerMessage, { type: "spawn_session" }>,
140
+ ctx: BrowserHandlerContext,
141
+ ): Promise<void> {
142
+ const { ws, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
143
+ const config = loadConfig();
144
+ const spawnResult = await spawnPiSession(msg.cwd, { strategy: config.spawnStrategy });
145
+ if (spawnResult.process && spawnResult.pid) {
146
+ headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process);
147
+ }
148
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
149
+ pendingDashboardSpawns?.set(msg.cwd, (pendingDashboardSpawns?.get(msg.cwd) ?? 0) + 1);
150
+ }
151
+ sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
152
+ }
153
+
154
+ export function handleShutdown(
155
+ msg: Extract<BrowserToServerMessage, { type: "shutdown" }>,
156
+ ctx: BrowserHandlerContext,
157
+ ): void {
158
+ const { sessionManager, piGateway, headlessPidRegistry, broadcast } = ctx;
159
+ piGateway.sendToSession(msg.sessionId, { type: "shutdown", sessionId: msg.sessionId });
160
+ headlessPidRegistry.killBySessionId(msg.sessionId);
161
+ killHeadlessBySessionId(msg.sessionId);
162
+ sessionManager.unregister(msg.sessionId);
163
+ broadcast({ type: "session_removed", sessionId: msg.sessionId });
164
+ }
165
+
166
+ export function handleAbort(
167
+ msg: Extract<BrowserToServerMessage, { type: "abort" }>,
168
+ ctx: BrowserHandlerContext,
169
+ ): void {
170
+ ctx.piGateway.sendToSession(msg.sessionId, { type: "abort", sessionId: msg.sessionId });
171
+ }
172
+
173
+ export function handleFlowControl(
174
+ msg: Extract<BrowserToServerMessage, { type: "flow_control" }>,
175
+ ctx: BrowserHandlerContext,
176
+ ): void {
177
+ ctx.piGateway.sendToSession(msg.sessionId, { type: "flow_control", sessionId: msg.sessionId, action: msg.action });
178
+ }
179
+
180
+ export function handleKillProcess(
181
+ msg: Extract<BrowserToServerMessage, { type: "kill_process" }>,
182
+ ctx: BrowserHandlerContext,
183
+ ): void {
184
+ ctx.piGateway.sendToSession(msg.sessionId, { type: "kill_process", sessionId: msg.sessionId, pgid: msg.pgid });
185
+ }
186
+
187
+ /**
188
+ * Check if a PID belongs to a pi/node process (safety check before SIGKILL).
189
+ * Returns true if the process looks like a pi-related process, false otherwise.
190
+ */
191
+ function isPiProcess(pid: number): boolean {
192
+ try {
193
+ const cmd = process.platform === "darwin"
194
+ ? `ps -p ${pid} -o command=`
195
+ : `cat /proc/${pid}/cmdline 2>/dev/null || ps -p ${pid} -o command=`;
196
+ const output = execSync(cmd, { encoding: "utf8", timeout: 2000 }).trim();
197
+ return /\bpi\b|\bnode\b/.test(output);
198
+ } catch {
199
+ // Process already exited — treat as dead
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Check if a process is still alive.
206
+ */
207
+ function isProcessAlive(pid: number): boolean {
208
+ try {
209
+ process.kill(pid, 0);
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ export async function handleForceKill(
217
+ msg: Extract<BrowserToServerMessage, { type: "force_kill" }>,
218
+ ctx: BrowserHandlerContext,
219
+ ): Promise<void> {
220
+ const { sessionManager, piGateway, headlessPidRegistry, broadcast, sendTo, ws } = ctx;
221
+ const session = sessionManager.get(msg.sessionId);
222
+ if (!session) {
223
+ sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
224
+ return;
225
+ }
226
+
227
+ // Force-close the bridge WebSocket regardless of PID availability
228
+ piGateway.closeSession(msg.sessionId);
229
+
230
+ const pid = session?.pid;
231
+ if (!pid) {
232
+ // No PID — we can only close the WebSocket
233
+ sessionManager.update(msg.sessionId, { status: "ended", endedAt: Date.now() });
234
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { status: "ended", endedAt: Date.now() } });
235
+ sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true, message: "WebSocket closed (no PID available)" });
236
+ return;
237
+ }
238
+
239
+ // Step 1: SIGTERM
240
+ try {
241
+ process.kill(pid, "SIGTERM");
242
+ } catch {
243
+ // Process already dead
244
+ sessionManager.update(msg.sessionId, { status: "ended", endedAt: Date.now() });
245
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { status: "ended", endedAt: Date.now() } });
246
+ sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true, message: "Process already exited" });
247
+ return;
248
+ }
249
+
250
+ // Also kill via headless registry if applicable
251
+ headlessPidRegistry.killBySessionId(msg.sessionId);
252
+
253
+ // Step 2: Wait 2s, then SIGKILL if still alive
254
+ await new Promise<void>((resolve) => {
255
+ setTimeout(() => {
256
+ if (isProcessAlive(pid)) {
257
+ // Safety check: verify PID still belongs to a pi process
258
+ if (isPiProcess(pid)) {
259
+ try {
260
+ process.kill(pid, "SIGKILL");
261
+ } catch { /* already dead */ }
262
+ }
263
+ }
264
+ resolve();
265
+ }, 2000);
266
+ });
267
+
268
+ sessionManager.update(msg.sessionId, { status: "ended", endedAt: Date.now() });
269
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { status: "ended", endedAt: Date.now() } });
270
+ sendTo(ws, { type: "force_kill_result", sessionId: msg.sessionId, success: true });
271
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Session metadata handlers: rename, hide, unhide, attach/detach proposal, fetch_content, list_sessions.
3
+ */
4
+ import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
+ import type { BrowserHandlerContext } from "./handler-context.js";
6
+
7
+ export function handleRenameSession(
8
+ msg: Extract<BrowserToServerMessage, { type: "rename_session" }>,
9
+ ctx: BrowserHandlerContext,
10
+ ): void {
11
+ const { sessionManager, piGateway, broadcast } = ctx;
12
+ const nameUpdates = { name: msg.name || undefined };
13
+ sessionManager.update(msg.sessionId, nameUpdates);
14
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: nameUpdates });
15
+ piGateway.sendToSession(msg.sessionId, { type: "rename_session", sessionId: msg.sessionId, name: msg.name });
16
+ }
17
+
18
+ export function handleHideSession(
19
+ msg: Extract<BrowserToServerMessage, { type: "hide_session" }>,
20
+ ctx: BrowserHandlerContext,
21
+ ): void {
22
+ const updates = { hidden: true };
23
+ ctx.sessionManager.update(msg.sessionId, updates);
24
+ ctx.broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
25
+ }
26
+
27
+ export function handleUnhideSession(
28
+ msg: Extract<BrowserToServerMessage, { type: "unhide_session" }>,
29
+ ctx: BrowserHandlerContext,
30
+ ): void {
31
+ const updates = { hidden: false };
32
+ ctx.sessionManager.update(msg.sessionId, updates);
33
+ ctx.broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
34
+ }
35
+
36
+ export function handleAttachProposal(
37
+ msg: Extract<BrowserToServerMessage, { type: "attach_proposal" }>,
38
+ ctx: BrowserHandlerContext,
39
+ ): void {
40
+ const { sessionManager, piGateway, broadcast } = ctx;
41
+ const updates: Record<string, unknown> = { attachedProposal: msg.changeName };
42
+ const session = sessionManager.get(msg.sessionId);
43
+ if (session && !session.name?.trim()) {
44
+ updates.name = msg.changeName;
45
+ piGateway.sendToSession(msg.sessionId, { type: "rename_session", sessionId: msg.sessionId, name: msg.changeName });
46
+ }
47
+ sessionManager.update(msg.sessionId, updates);
48
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
49
+ }
50
+
51
+ export function handleDetachProposal(
52
+ msg: Extract<BrowserToServerMessage, { type: "detach_proposal" }>,
53
+ ctx: BrowserHandlerContext,
54
+ ): void {
55
+ const updates = { attachedProposal: null, openspecPhase: null, openspecChange: null };
56
+ ctx.sessionManager.update(msg.sessionId, updates);
57
+ ctx.broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
58
+ }
59
+
60
+ export function handleFetchContent(
61
+ msg: Extract<BrowserToServerMessage, { type: "fetch_content" }>,
62
+ ctx: BrowserHandlerContext,
63
+ ): void {
64
+ const event = ctx.eventStore.getEvent(msg.sessionId, msg.seq);
65
+ if (event) {
66
+ ctx.sendTo(ctx.ws, { type: "event", sessionId: msg.sessionId, seq: msg.seq, event });
67
+ }
68
+ }
69
+
70
+ export function handleListSessions(
71
+ msg: Extract<BrowserToServerMessage, { type: "list_sessions" }>,
72
+ ctx: BrowserHandlerContext,
73
+ ): void {
74
+ const { ws, sessionManager, piGateway, sendTo } = ctx;
75
+ const cwd = msg.cwd;
76
+ const bridgeSessionId = piGateway.findSessionByCwd(cwd);
77
+ if (bridgeSessionId) {
78
+ piGateway.sendToSession(bridgeSessionId, { type: "list_sessions", sessionId: bridgeSessionId, cwd });
79
+ } else {
80
+ const allSessions = sessionManager.listAll();
81
+ const filtered = allSessions
82
+ .filter((s) => s.cwd === cwd || s.cwd.startsWith(cwd + "/") || cwd.startsWith(s.cwd + "/"))
83
+ .map((s) => ({
84
+ id: s.id,
85
+ path: s.sessionFile || "",
86
+ cwd: s.cwd,
87
+ name: s.name,
88
+ created: new Date(s.startedAt).toISOString(),
89
+ modified: new Date(s.endedAt || s.startedAt).toISOString(),
90
+ messageCount: 0,
91
+ firstMessage: s.firstMessage,
92
+ }));
93
+ sendTo(ws, { type: "sessions_list", sessionId: "", cwd, sessions: filtered });
94
+ }
95
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Subscription message handlers: subscribe, unsubscribe.
3
+ */
4
+ import type { WebSocket } from "ws";
5
+ import type { ServerToBrowserMessage, BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
6
+ import type { BrowserHandlerContext } from "./handler-context.js";
7
+ import { extractStatsFromEvents } from "../event-status-extraction.js";
8
+ import type { StoredEvent } from "../memory-event-store.js";
9
+
10
+ const REPLAY_BATCH_SIZE = 50;
11
+ /** Max events to replay per session subscription (0 = unlimited) */
12
+ const MAX_REPLAY_EVENTS = 0;
13
+ /** Max buffered bytes before pausing replay sends (1MB) */
14
+ const BACKPRESSURE_THRESHOLD = 1_024 * 1_024;
15
+
16
+ /**
17
+ * Send stored events to a WebSocket in batches with backpressure handling.
18
+ * Yields between batches to let the event loop flush data and avoid OOM.
19
+ */
20
+ /**
21
+ * Send stored events to a WebSocket in batches with backpressure handling.
22
+ * Returns the highest seq sent, or 0 if no events were sent.
23
+ */
24
+ async function sendEventBatches(
25
+ ws: WebSocket,
26
+ sessionId: string,
27
+ stored: StoredEvent[],
28
+ sendTo: (ws: WebSocket, msg: ServerToBrowserMessage) => void,
29
+ ): Promise<number> {
30
+ for (let i = 0; i < stored.length; i += REPLAY_BATCH_SIZE) {
31
+ if (ws.readyState !== ws.OPEN) return 0;
32
+ const batch = stored.slice(i, i + REPLAY_BATCH_SIZE);
33
+ sendTo(ws, {
34
+ type: "event_replay",
35
+ sessionId,
36
+ events: batch.map((e) => ({ seq: e.seq, event: e.event })),
37
+ isLast: i + REPLAY_BATCH_SIZE >= stored.length,
38
+ });
39
+ // Yield to event loop between batches to allow GC and buffer flushing
40
+ if (ws.bufferedAmount > BACKPRESSURE_THRESHOLD) {
41
+ await new Promise<void>((resolve) => {
42
+ const check = () => {
43
+ if (ws.readyState !== ws.OPEN || ws.bufferedAmount < BACKPRESSURE_THRESHOLD) {
44
+ resolve();
45
+ } else {
46
+ setTimeout(check, 50);
47
+ }
48
+ };
49
+ setTimeout(check, 10);
50
+ });
51
+ } else {
52
+ await new Promise<void>((r) => setImmediate(r));
53
+ }
54
+ }
55
+ return stored.length > 0 ? stored[stored.length - 1].seq : 0;
56
+ }
57
+
58
+ export function handleSubscribe(
59
+ msg: Extract<BrowserToServerMessage, { type: "subscribe" }>,
60
+ subs: Set<string>,
61
+ ctx: BrowserHandlerContext,
62
+ ): void {
63
+ const { ws, sessionManager, eventStore, directoryService, piGateway, sendTo, broadcast, getSubscribers, replayPendingUiRequests, markReplaying, clearReplaying } = ctx;
64
+ subs.add(msg.sessionId);
65
+
66
+ // Request metadata from the extension so commands/flows/models/roles arrive
67
+ // while the browser is actually subscribed (responses use sendToSubscribers).
68
+ piGateway.sendToSession(msg.sessionId, { type: "request_commands", sessionId: msg.sessionId });
69
+ piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
70
+ piGateway.sendToSession(msg.sessionId, { type: "request_roles", sessionId: msg.sessionId });
71
+
72
+ if (eventStore.hasEvents(msg.sessionId)) {
73
+ const lastSeq = msg.lastSeq ?? 0;
74
+ const maxSeq = eventStore.getMaxSeq(msg.sessionId);
75
+
76
+ // Stale lastSeq: client has higher seq than server (e.g. server restarted)
77
+ if (lastSeq > 0 && lastSeq > maxSeq) {
78
+ sendTo(ws, { type: "session_state_reset", sessionId: msg.sessionId });
79
+ // Full replay from seq 1
80
+ let events = eventStore.getEvents(msg.sessionId, 1);
81
+ if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
82
+ events = events.slice(events.length - MAX_REPLAY_EVENTS);
83
+ }
84
+ markReplaying(ws, msg.sessionId);
85
+ sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
86
+ clearReplaying(ws, msg.sessionId, lastSent);
87
+ replayPendingUiRequests(ws, msg.sessionId);
88
+ });
89
+ } else {
90
+ let events = eventStore.getEvents(msg.sessionId, lastSeq + 1);
91
+ if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
92
+ events = events.slice(events.length - MAX_REPLAY_EVENTS);
93
+ }
94
+ // Suppress live events during delta replay to prevent out-of-order delivery
95
+ if (lastSeq > 0 && events.length > 0) {
96
+ markReplaying(ws, msg.sessionId);
97
+ sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
98
+ clearReplaying(ws, msg.sessionId, lastSent);
99
+ replayPendingUiRequests(ws, msg.sessionId);
100
+ });
101
+ } else {
102
+ sendEventBatches(ws, msg.sessionId, events, sendTo).then(() => {
103
+ replayPendingUiRequests(ws, msg.sessionId);
104
+ });
105
+ }
106
+ }
107
+ } else if (directoryService) {
108
+ const session = sessionManager.get(msg.sessionId);
109
+ if (session?.sessionFile) {
110
+ sendTo(ws, {
111
+ type: "event_replay",
112
+ sessionId: msg.sessionId,
113
+ events: [],
114
+ isLast: false,
115
+ });
116
+ directoryService.loadSessionEvents(msg.sessionId, session.sessionFile).then(async (result) => {
117
+ if (result.success) {
118
+ for (const evt of result.events) {
119
+ eventStore.insertEvent(msg.sessionId, evt);
120
+ }
121
+ const statsUpdates = extractStatsFromEvents(result.events);
122
+ const metaUpdates: Record<string, unknown> = { dataUnavailable: false, ...statsUpdates };
123
+ sessionManager.update(msg.sessionId, metaUpdates);
124
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: metaUpdates });
125
+ let stored = eventStore.getEvents(msg.sessionId, 1);
126
+ if (MAX_REPLAY_EVENTS > 0 && stored.length > MAX_REPLAY_EVENTS) {
127
+ stored = stored.slice(stored.length - MAX_REPLAY_EVENTS);
128
+ }
129
+ const subscribers = getSubscribers(msg.sessionId);
130
+ for (const sub of subscribers) {
131
+ await sendEventBatches(sub, msg.sessionId, stored, sendTo);
132
+ replayPendingUiRequests(sub, msg.sessionId);
133
+ }
134
+ } else {
135
+ sendTo(ws, { type: "event_replay", sessionId: msg.sessionId, events: [], isLast: true });
136
+ sessionManager.update(msg.sessionId, { dataUnavailable: true });
137
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { dataUnavailable: true } });
138
+ }
139
+ }).catch(() => {
140
+ sendTo(ws, { type: "event_replay", sessionId: msg.sessionId, events: [], isLast: true });
141
+ sessionManager.update(msg.sessionId, { dataUnavailable: true });
142
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { dataUnavailable: true } });
143
+ });
144
+ } else {
145
+ sendTo(ws, { type: "event_replay", sessionId: msg.sessionId, events: [], isLast: true });
146
+ if (session) {
147
+ sessionManager.update(msg.sessionId, { dataUnavailable: true });
148
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { dataUnavailable: true } });
149
+ }
150
+ }
151
+ } else {
152
+ sendTo(ws, { type: "event_replay", sessionId: msg.sessionId, events: [], isLast: true });
153
+ }
154
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Terminal message handlers: create, kill, rename.
3
+ */
4
+ import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
+ import type { BrowserHandlerContext } from "./handler-context.js";
6
+
7
+ export function handleCreateTerminal(
8
+ msg: Extract<BrowserToServerMessage, { type: "create_terminal" }>,
9
+ ctx: BrowserHandlerContext,
10
+ ): void {
11
+ const { terminalManager, sessionOrderManager, broadcast } = ctx;
12
+ if (terminalManager && sessionOrderManager) {
13
+ const terminal = terminalManager.spawn(msg.cwd);
14
+ sessionOrderManager.insert(msg.cwd, terminal.id);
15
+ broadcast({ type: "terminal_added", terminal });
16
+ broadcast({ type: "sessions_reordered", cwd: msg.cwd, sessionIds: sessionOrderManager.getOrder(msg.cwd) });
17
+ }
18
+ }
19
+
20
+ export function handleKillTerminal(
21
+ msg: Extract<BrowserToServerMessage, { type: "kill_terminal" }>,
22
+ ctx: BrowserHandlerContext,
23
+ ): void {
24
+ if (ctx.terminalManager) {
25
+ try { ctx.terminalManager.kill(msg.terminalId); } catch { /* ignore */ }
26
+ }
27
+ }
28
+
29
+ export function handleRenameTerminal(
30
+ msg: Extract<BrowserToServerMessage, { type: "rename_terminal" }>,
31
+ ctx: BrowserHandlerContext,
32
+ ): void {
33
+ if (ctx.terminalManager) {
34
+ ctx.terminalManager.updateTitle(msg.terminalId, msg.title);
35
+ ctx.broadcast({ type: "terminal_updated", terminalId: msg.terminalId, updates: { title: msg.title } });
36
+ }
37
+ }