@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,495 @@
1
+ /**
2
+ * Browser Gateway - WebSocket handler for browser client connections.
3
+ * Runs on the HTTP server port via upgrade handling.
4
+ */
5
+ import { WebSocketServer, WebSocket } from "ws";
6
+ import type {
7
+ ServerToBrowserMessage,
8
+ BrowserToServerMessage,
9
+ } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
10
+ import type { SessionManager } from "./memory-session-manager.js";
11
+ import type { EventStore } from "./memory-event-store.js";
12
+ import type { PiGateway } from "./pi-gateway.js";
13
+ // PendingLoadManager removed — server loads sessions directly via DirectoryService
14
+ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-pid-registry.js";
15
+ import type { PendingForkRegistry } from "./pending-fork-registry.js";
16
+ import type { SessionOrderManager } from "./session-order-manager.js";
17
+ import type { PreferencesStore } from "./preferences-store.js";
18
+ import type { DirectoryService } from "./directory-service.js";
19
+ import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
20
+ import type { TerminalManager } from "./terminal-manager.js";
21
+ import type { BrowserHandlerContext } from "./browser-handlers/handler-context.js";
22
+ import { handleSubscribe } from "./browser-handlers/subscription-handler.js";
23
+ import { handleSendPrompt, handleResumeSession, handleSpawnSession, handleShutdown, handleAbort, handleFlowControl, handleForceKill, handleKillProcess } from "./browser-handlers/session-action-handler.js";
24
+ import { handleRenameSession, handleHideSession, handleUnhideSession, handleAttachProposal, handleDetachProposal, handleFetchContent, handleListSessions } from "./browser-handlers/session-meta-handler.js";
25
+ import { handleCreateTerminal, handleKillTerminal, handleRenameTerminal } from "./browser-handlers/terminal-handler.js";
26
+ import { handlePinDirectory, handleUnpinDirectory, handleReorderPinnedDirs, handleReorderSessions, handleOpenSpecRefresh, handleOpenSpecBulkArchive, handleExtensionUiResponse, handlePiGatewayForward } from "./browser-handlers/directory-handler.js";
27
+
28
+
29
+
30
+ export interface BrowserGateway {
31
+ wss: WebSocketServer;
32
+ broadcastEvent(sessionId: string, seq: number, event: any): void;
33
+ broadcastSessionAdded(session: any): void;
34
+ broadcastSessionUpdated(sessionId: string, updates: any): void;
35
+ broadcastSessionRemoved(sessionId: string): void;
36
+ sendToSubscribers(sessionId: string, msg: ServerToBrowserMessage): void;
37
+ broadcastToAll(msg: ServerToBrowserMessage): void;
38
+ /** Get number of browser subscribers for a session */
39
+ getSubscriberCount(sessionId: string): number;
40
+ /** Track a pending interactive UI request for replay on reconnect */
41
+ trackUiRequest(sessionId: string, requestId: string, method: string, params: Record<string, unknown>): boolean | void;
42
+ /** Clear a pending interactive UI request (resolved or cancelled) */
43
+ clearUiRequest(sessionId: string, requestId: string): void;
44
+ /** Tell browser subscribers to reset accumulated state for a session (bridge reconnected) */
45
+ broadcastSessionStateReset(sessionId: string): void;
46
+ /** Shut down all tracked headless child processes */
47
+ shutdownHeadlessProcesses(): void;
48
+ /** Registry for linking headless PIDs to session IDs */
49
+ headlessPidRegistry: HeadlessPidRegistry;
50
+ /** Registry for pending auto-resume prompts */
51
+ pendingResumeRegistry: PendingResumeRegistry;
52
+ /** Send a message to a specific WebSocket client */
53
+ sendToClient(ws: WebSocket, msg: ServerToBrowserMessage): void;
54
+ /** Callback invoked when a new browser client connects */
55
+ onConnect?: (ws: WebSocket) => void;
56
+ /** Broadcast a message to all connected clients */
57
+ broadcast(msg: ServerToBrowserMessage): void;
58
+ }
59
+
60
+ export function createBrowserGateway(
61
+ sessionManager: SessionManager,
62
+ eventStore: EventStore,
63
+ piGateway: PiGateway,
64
+ _pendingLoadManager?: unknown,
65
+ pendingForkRegistry?: PendingForkRegistry,
66
+ sessionOrderManager?: SessionOrderManager,
67
+ preferencesStore?: PreferencesStore,
68
+ directoryService?: DirectoryService,
69
+ terminalManager?: TerminalManager,
70
+ pendingDashboardSpawns?: Map<string, number>,
71
+ maxWsBufferBytes?: number,
72
+ ): BrowserGateway {
73
+ const wss = new WebSocketServer({ noServer: true });
74
+
75
+ // Track subscriptions: ws → Set<sessionId>
76
+ const subscriptions = new Map<WebSocket, Set<string>>();
77
+ // Track which sessions are mid-replay per WebSocket (suppress live events)
78
+ const replayingSessions = new Map<WebSocket, Set<string>>();
79
+
80
+ // Track headless child processes with sessionId linkage
81
+ const headlessPidRegistry = createHeadlessPidRegistry();
82
+
83
+ // Track pending interactive UI requests per session for replay on reconnect
84
+ const pendingUiRequests = new Map<string, Map<string, { requestId: string; method: string; params: Record<string, unknown> }>>();
85
+
86
+ // Track pending auto-resume prompts for ended sessions
87
+ const pendingResumeRegistry = createPendingResumeRegistry({
88
+ onTimeout(oldSessionId) {
89
+ // Clear resuming flag when resume times out
90
+ sessionManager.update(oldSessionId, { resuming: false });
91
+ broadcast({ type: "session_updated", sessionId: oldSessionId, updates: { resuming: false } });
92
+ },
93
+ });
94
+
95
+ /** Send any pending interactive UI requests to a specific browser socket */
96
+ function replayPendingUiRequests(ws: WebSocket, sessionId: string) {
97
+ const sessionPending = pendingUiRequests.get(sessionId);
98
+ if (!sessionPending) return;
99
+ for (const req of sessionPending.values()) {
100
+ sendTo(ws, {
101
+ type: "extension_ui_request",
102
+ sessionId,
103
+ requestId: req.requestId,
104
+ method: req.method,
105
+ params: req.params,
106
+ });
107
+ }
108
+ }
109
+
110
+ function trackUiRequest(sessionId: string, requestId: string, method: string, params: Record<string, unknown>): boolean | void {
111
+ let sessionMap = pendingUiRequests.get(sessionId);
112
+ if (!sessionMap) {
113
+ sessionMap = new Map();
114
+ pendingUiRequests.set(sessionId, sessionMap);
115
+ }
116
+ const title = params.title;
117
+ if (title !== undefined) {
118
+ for (const existing of sessionMap.values()) {
119
+ if (existing.method === method && existing.params.title === title) {
120
+ return false;
121
+ }
122
+ }
123
+ }
124
+ sessionMap.set(requestId, { requestId, method, params });
125
+ return true;
126
+ }
127
+
128
+ function getSubscribers(sessionId: string): WebSocket[] {
129
+ const result: WebSocket[] = [];
130
+ for (const [ws, subs] of subscriptions) {
131
+ if (subs.has(sessionId) && ws.readyState === WebSocket.OPEN) {
132
+ result.push(ws);
133
+ }
134
+ }
135
+ return result;
136
+ }
137
+
138
+ /** Max buffered bytes per browser WebSocket before dropping messages (0 = no limit) */
139
+ const MAX_WS_BUFFER = maxWsBufferBytes ?? 4 * 1024 * 1024; // 4MB default
140
+
141
+ function sendTo(ws: WebSocket, msg: ServerToBrowserMessage) {
142
+ if (ws.readyState === WebSocket.OPEN) {
143
+ // Drop messages if the send buffer is full (browser not consuming)
144
+ if (MAX_WS_BUFFER > 0 && ws.bufferedAmount > MAX_WS_BUFFER) return;
145
+ ws.send(JSON.stringify(msg));
146
+ }
147
+ }
148
+
149
+ function broadcast(msg: ServerToBrowserMessage) {
150
+ for (const [ws] of subscriptions) {
151
+ sendTo(ws, msg);
152
+ }
153
+ }
154
+
155
+ wss.on("connection", (ws, req) => {
156
+ const remoteAddr = req?.socket?.remoteAddress ?? 'unknown';
157
+ const origin = req?.headers?.origin ?? 'no-origin';
158
+ const ua = req?.headers?.['user-agent'] ?? 'no-ua';
159
+ console.error(`[browser-gw] browser client connected from ${remoteAddr} origin=${origin} ua=${ua.slice(0, 80)} (total: ${subscriptions.size + 1})`);
160
+ const subs = new Set<string>();
161
+ subscriptions.set(ws, subs);
162
+
163
+ // Send all sessions on connect (client filters by hidden flag)
164
+ const allSessions = sessionManager.listAll();
165
+ for (const session of allSessions) {
166
+ sendTo(ws, { type: "session_added", session });
167
+ }
168
+
169
+ // Send pinned directories on connect
170
+ if (preferencesStore) {
171
+ sendTo(ws, { type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
172
+ }
173
+
174
+ // Send session orders for all cwds
175
+ if (sessionOrderManager) {
176
+ const allOrders = sessionOrderManager.getAllOrders();
177
+ for (const [cwd, sessionIds] of Object.entries(allOrders)) {
178
+ if (sessionIds.length > 0) {
179
+ sendTo(ws, { type: "sessions_reordered", cwd, sessionIds });
180
+ }
181
+ }
182
+ }
183
+
184
+ // Send cached OpenSpec data for all known directories
185
+ if (directoryService) {
186
+ for (const cwd of directoryService.knownDirectories()) {
187
+ const data = directoryService.getOpenSpecData(cwd);
188
+ if (data && data.initialized) {
189
+ sendTo(ws, { type: "openspec_update", cwd, data });
190
+ }
191
+ }
192
+ }
193
+
194
+ // Send active terminals on connect
195
+ if (terminalManager) {
196
+ for (const terminal of terminalManager.list()) {
197
+ sendTo(ws, { type: "terminal_added", terminal });
198
+ }
199
+ }
200
+
201
+ // Notify server of new connection (for mDNS peer list etc.)
202
+ if (gateway.onConnect) {
203
+ gateway.onConnect(ws);
204
+ }
205
+
206
+
207
+ ws.on("message", async (raw) => {
208
+ try {
209
+ const msg = JSON.parse(raw.toString()) as BrowserToServerMessage;
210
+ const ctx: BrowserHandlerContext = {
211
+ ws, sessionManager, eventStore, piGateway,
212
+ pendingForkRegistry, sessionOrderManager, preferencesStore,
213
+ directoryService, terminalManager,
214
+ headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns,
215
+ sendTo, broadcast, getSubscribers, replayPendingUiRequests,
216
+ trackUiRequest: trackUiRequest,
217
+ markReplaying(targetWs, sessionId) {
218
+ let set = replayingSessions.get(targetWs);
219
+ if (!set) { set = new Set(); replayingSessions.set(targetWs, set); }
220
+ set.add(sessionId);
221
+ },
222
+ clearReplaying(targetWs, sessionId, lastReplayedSeq) {
223
+ const set = replayingSessions.get(targetWs);
224
+ if (set) {
225
+ set.delete(sessionId);
226
+ if (set.size === 0) replayingSessions.delete(targetWs);
227
+ }
228
+ // Send catch-up: any events after lastReplayedSeq
229
+ if (lastReplayedSeq > 0) {
230
+ const catchUp = eventStore.getEvents(sessionId, lastReplayedSeq + 1);
231
+ if (catchUp.length > 0) {
232
+ sendTo(targetWs, {
233
+ type: "event_replay",
234
+ sessionId,
235
+ events: catchUp.map((e) => ({ seq: e.seq, event: e.event })),
236
+ isLast: true,
237
+ });
238
+ }
239
+ }
240
+ },
241
+ };
242
+
243
+ switch (msg.type) {
244
+ case "subscribe":
245
+ handleSubscribe(msg, subs, ctx);
246
+ break;
247
+ case "unsubscribe":
248
+ subs.delete(msg.sessionId);
249
+ break;
250
+ case "send_prompt":
251
+ await handleSendPrompt(msg, ctx);
252
+ break;
253
+ case "abort":
254
+ handleAbort(msg, ctx);
255
+ break;
256
+ case "force_kill":
257
+ await handleForceKill(msg, ctx);
258
+ break;
259
+ case "flow_control":
260
+ handleFlowControl(msg, ctx);
261
+ break;
262
+ case "kill_process":
263
+ handleKillProcess(msg, ctx);
264
+ break;
265
+ case "shutdown":
266
+ handleShutdown(msg, ctx);
267
+ break;
268
+ case "rename_session":
269
+ handleRenameSession(msg, ctx);
270
+ break;
271
+ case "hide_session":
272
+ handleHideSession(msg, ctx);
273
+ break;
274
+ case "unhide_session":
275
+ handleUnhideSession(msg, ctx);
276
+ break;
277
+ case "attach_proposal":
278
+ handleAttachProposal(msg, ctx);
279
+ break;
280
+ case "detach_proposal":
281
+ handleDetachProposal(msg, ctx);
282
+ break;
283
+ case "fetch_content":
284
+ handleFetchContent(msg, ctx);
285
+ break;
286
+ case "list_sessions":
287
+ handleListSessions(msg, ctx);
288
+ break;
289
+ case "resume_session":
290
+ await handleResumeSession(msg, ctx);
291
+ break;
292
+ case "spawn_session":
293
+ await handleSpawnSession(msg, ctx);
294
+ break;
295
+ case "reorder_sessions":
296
+ handleReorderSessions(msg, ctx);
297
+ break;
298
+ case "pin_directory":
299
+ handlePinDirectory(msg, ctx);
300
+ break;
301
+ case "unpin_directory":
302
+ handleUnpinDirectory(msg, ctx);
303
+ break;
304
+ case "reorder_pinned_dirs":
305
+ handleReorderPinnedDirs(msg, ctx);
306
+ break;
307
+ case "openspec_refresh":
308
+ handleOpenSpecRefresh(msg, ctx);
309
+ break;
310
+ case "openspec_bulk_archive":
311
+ handleOpenSpecBulkArchive(msg, ctx);
312
+ break;
313
+ case "extension_ui_response": {
314
+ // Clear pending UI request tracking
315
+ const sessionMap = pendingUiRequests.get(msg.sessionId);
316
+ if (sessionMap) {
317
+ sessionMap.delete(msg.requestId);
318
+ if (sessionMap.size === 0) pendingUiRequests.delete(msg.sessionId);
319
+ }
320
+ handleExtensionUiResponse(msg, ctx);
321
+ break;
322
+ }
323
+
324
+ case "flow_management": {
325
+ ctx.piGateway.sendToSession(msg.sessionId, {
326
+ type: "flow_management",
327
+ sessionId: msg.sessionId,
328
+ action: msg.action,
329
+ flowName: msg.flowName,
330
+ task: msg.task,
331
+ description: msg.description,
332
+ });
333
+ break;
334
+ }
335
+ case "architect_prompt_response": {
336
+ ctx.piGateway.sendToSession(msg.sessionId, {
337
+ type: "architect_prompt_response",
338
+ sessionId: msg.sessionId,
339
+ promptId: msg.promptId,
340
+ answer: msg.answer,
341
+ cancelled: msg.cancelled,
342
+ });
343
+ break;
344
+ }
345
+ case "role_set": {
346
+ ctx.piGateway.sendToSession(msg.sessionId, {
347
+ type: "role_set",
348
+ sessionId: msg.sessionId,
349
+ role: (msg as any).role,
350
+ modelId: (msg as any).modelId,
351
+ });
352
+ break;
353
+ }
354
+ case "role_preset_load": {
355
+ ctx.piGateway.sendToSession(msg.sessionId, {
356
+ type: "role_preset_load",
357
+ sessionId: msg.sessionId,
358
+ presetName: (msg as any).presetName,
359
+ });
360
+ break;
361
+ }
362
+ case "role_preset_save": {
363
+ ctx.piGateway.sendToSession(msg.sessionId, {
364
+ type: "role_preset_save",
365
+ sessionId: msg.sessionId,
366
+ presetName: (msg as any).presetName,
367
+ });
368
+ break;
369
+ }
370
+ case "role_preset_delete": {
371
+ ctx.piGateway.sendToSession(msg.sessionId, {
372
+ type: "role_preset_delete",
373
+ sessionId: msg.sessionId,
374
+ presetName: (msg as any).presetName,
375
+ });
376
+ break;
377
+ }
378
+ case "request_roles": {
379
+ ctx.piGateway.sendToSession(msg.sessionId, {
380
+ type: "request_roles",
381
+ sessionId: msg.sessionId,
382
+ });
383
+ break;
384
+ }
385
+ case "create_terminal":
386
+ handleCreateTerminal(msg, ctx);
387
+ break;
388
+ case "kill_terminal":
389
+ handleKillTerminal(msg, ctx);
390
+ break;
391
+ case "rename_terminal":
392
+ handleRenameTerminal(msg, ctx);
393
+ break;
394
+ default:
395
+ // Forward simple pi-gateway commands
396
+ handlePiGatewayForward(msg, ctx);
397
+ break;
398
+ }
399
+ } catch {
400
+ // Ignore malformed messages
401
+ }
402
+ });
403
+
404
+ ws.on("close", () => {
405
+ console.error(`[browser-gw] browser client disconnected (remaining: ${subscriptions.size - 1})`);
406
+ subscriptions.delete(ws);
407
+ replayingSessions.delete(ws);
408
+ });
409
+ });
410
+
411
+ const gateway: BrowserGateway = {
412
+ wss,
413
+
414
+ sendToClient(ws: WebSocket, msg: ServerToBrowserMessage) {
415
+ sendTo(ws, msg);
416
+ },
417
+
418
+ broadcast(msg: ServerToBrowserMessage) {
419
+ broadcast(msg);
420
+ },
421
+
422
+ broadcastEvent(sessionId: string, seq: number, event: any) {
423
+ const subscribers = getSubscribers(sessionId);
424
+ const msg: ServerToBrowserMessage = {
425
+ type: "event",
426
+ sessionId,
427
+ seq,
428
+ event,
429
+ };
430
+ for (const ws of subscribers) {
431
+ // Skip WebSockets that are mid-replay for this session
432
+ const replaying = replayingSessions.get(ws);
433
+ if (replaying?.has(sessionId)) continue;
434
+ sendTo(ws, msg);
435
+ }
436
+ },
437
+
438
+ broadcastSessionAdded(session: any) {
439
+ broadcast({ type: "session_added", session });
440
+ },
441
+
442
+ broadcastSessionUpdated(sessionId: string, updates: any) {
443
+ broadcast({ type: "session_updated", sessionId, updates });
444
+ },
445
+
446
+ broadcastSessionRemoved(sessionId: string) {
447
+ broadcast({ type: "session_removed", sessionId });
448
+ },
449
+
450
+ broadcastSessionStateReset(sessionId: string) {
451
+ const subscribers = getSubscribers(sessionId);
452
+ const msg: ServerToBrowserMessage = { type: "session_state_reset", sessionId };
453
+ for (const ws of subscribers) {
454
+ sendTo(ws, msg);
455
+ }
456
+ },
457
+
458
+ sendToSubscribers(sessionId: string, msg: ServerToBrowserMessage) {
459
+ const subscribers = getSubscribers(sessionId);
460
+ for (const ws of subscribers) {
461
+ sendTo(ws, msg);
462
+ }
463
+ },
464
+
465
+ broadcastToAll(msg: ServerToBrowserMessage) {
466
+ broadcast(msg);
467
+ },
468
+
469
+ getSubscriberCount(sessionId: string): number {
470
+ return getSubscribers(sessionId).length;
471
+ },
472
+
473
+ trackUiRequest,
474
+
475
+ clearUiRequest(sessionId: string, requestId: string) {
476
+ const sessionMap = pendingUiRequests.get(sessionId);
477
+ if (sessionMap) {
478
+ sessionMap.delete(requestId);
479
+ if (sessionMap.size === 0) {
480
+ pendingUiRequests.delete(sessionId);
481
+ }
482
+ }
483
+ },
484
+
485
+ shutdownHeadlessProcesses() {
486
+ headlessPidRegistry.killAll();
487
+ },
488
+
489
+ headlessPidRegistry,
490
+
491
+ pendingResumeRegistry,
492
+ };
493
+
494
+ return gateway;
495
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Directory and preference handlers: pin, unpin, reorder, openspec, pi-gateway forwards.
3
+ */
4
+ import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
+ import type { BrowserHandlerContext } from "./handler-context.js";
6
+ import { safeRealpathSync } from "../resolve-path.js";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export function handlePinDirectory(
13
+ msg: Extract<BrowserToServerMessage, { type: "pin_directory" }>,
14
+ ctx: BrowserHandlerContext,
15
+ ): void {
16
+ const { preferencesStore, directoryService, sessionManager, broadcast } = ctx;
17
+ if (!preferencesStore) return;
18
+ const resolved = safeRealpathSync(msg.path);
19
+ preferencesStore.pinDirectory(resolved);
20
+ broadcast({ type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
21
+ if (directoryService) {
22
+ directoryService.onDirectoryAdded(resolved).then(({ sessions, openspecData }) => {
23
+ for (const hist of sessions) {
24
+ if (!sessionManager.get(hist.id)) {
25
+ sessionManager.register({
26
+ id: hist.id,
27
+ cwd: hist.cwd,
28
+ name: hist.name,
29
+ source: "tui",
30
+ sessionFile: hist.sessionFile,
31
+ sessionDir: hist.sessionDir,
32
+ firstMessage: hist.firstMessage,
33
+ startedAt: hist.startedAt,
34
+ });
35
+ sessionManager.unregister(hist.id);
36
+ sessionManager.update(hist.id, { hidden: true });
37
+ const s = sessionManager.get(hist.id);
38
+ if (s) broadcast({ type: "session_added", session: s });
39
+ }
40
+ }
41
+ broadcast({ type: "openspec_update", cwd: resolved, data: openspecData } as any);
42
+ }).catch(() => {});
43
+ }
44
+ }
45
+
46
+ export function handleUnpinDirectory(
47
+ msg: Extract<BrowserToServerMessage, { type: "unpin_directory" }>,
48
+ ctx: BrowserHandlerContext,
49
+ ): void {
50
+ if (ctx.preferencesStore) {
51
+ ctx.preferencesStore.unpinDirectory(safeRealpathSync(msg.path));
52
+ ctx.broadcast({ type: "pinned_dirs_updated", paths: ctx.preferencesStore.getPinnedDirectories() });
53
+ }
54
+ }
55
+
56
+ export function handleReorderPinnedDirs(
57
+ msg: Extract<BrowserToServerMessage, { type: "reorder_pinned_dirs" }>,
58
+ ctx: BrowserHandlerContext,
59
+ ): void {
60
+ if (ctx.preferencesStore) {
61
+ ctx.preferencesStore.reorderPinnedDirs(msg.paths.map(safeRealpathSync));
62
+ ctx.broadcast({ type: "pinned_dirs_updated", paths: ctx.preferencesStore.getPinnedDirectories() });
63
+ }
64
+ }
65
+
66
+ export function handleReorderSessions(
67
+ msg: Extract<BrowserToServerMessage, { type: "reorder_sessions" }>,
68
+ ctx: BrowserHandlerContext,
69
+ ): void {
70
+ if (ctx.sessionOrderManager) {
71
+ ctx.sessionOrderManager.reorder(msg.cwd, msg.sessionIds);
72
+ ctx.broadcast({ type: "sessions_reordered", cwd: msg.cwd, sessionIds: msg.sessionIds });
73
+ }
74
+ }
75
+
76
+ export function handleOpenSpecRefresh(
77
+ msg: Extract<BrowserToServerMessage, { type: "openspec_refresh" }>,
78
+ ctx: BrowserHandlerContext,
79
+ ): void {
80
+ if (ctx.directoryService) {
81
+ ctx.directoryService.refreshOpenSpec(msg.cwd).then((data) => {
82
+ ctx.broadcast({ type: "openspec_update", cwd: msg.cwd, data });
83
+ });
84
+ }
85
+ }
86
+
87
+ export function handleOpenSpecBulkArchive(
88
+ msg: Extract<BrowserToServerMessage, { type: "openspec_bulk_archive" }>,
89
+ ctx: BrowserHandlerContext,
90
+ ): void {
91
+ if (ctx.directoryService) {
92
+ execFileAsync("openspec", ["archive", "--completed"], { cwd: msg.cwd, timeout: 30000 })
93
+ .catch(() => {})
94
+ .then(() => ctx.directoryService!.refreshOpenSpec(msg.cwd))
95
+ .then((data) => {
96
+ if (data) ctx.broadcast({ type: "openspec_update", cwd: msg.cwd, data });
97
+ });
98
+ }
99
+ }
100
+
101
+ export function handleExtensionUiResponse(
102
+ msg: Extract<BrowserToServerMessage, { type: "extension_ui_response" }>,
103
+ ctx: BrowserHandlerContext,
104
+ ): void {
105
+ ctx.piGateway.sendToSession(msg.sessionId, {
106
+ type: "extension_ui_response",
107
+ sessionId: msg.sessionId,
108
+ requestId: msg.requestId,
109
+ result: msg.result,
110
+ cancelled: msg.cancelled,
111
+ });
112
+ }
113
+
114
+ /** Forward simple pi-gateway commands (request_commands, list_files, request_models, set_model, set_thinking_level) */
115
+ export function handlePiGatewayForward(
116
+ msg: BrowserToServerMessage,
117
+ ctx: BrowserHandlerContext,
118
+ ): void {
119
+ const { piGateway } = ctx;
120
+ switch (msg.type) {
121
+ case "request_commands":
122
+ piGateway.sendToSession(msg.sessionId, { type: "request_commands", sessionId: msg.sessionId });
123
+ break;
124
+ case "list_files":
125
+ piGateway.sendToSession(msg.sessionId, { type: "list_files", sessionId: msg.sessionId, query: msg.query });
126
+ break;
127
+ case "request_models":
128
+ piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
129
+ break;
130
+ case "set_thinking_level":
131
+ piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level });
132
+ break;
133
+ case "set_model":
134
+ piGateway.sendToSession(msg.sessionId, { type: "set_model", sessionId: msg.sessionId, provider: msg.provider, modelId: msg.modelId });
135
+ break;
136
+ }
137
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared context for browser message handlers.
3
+ * Each handler receives only what it needs via this context.
4
+ */
5
+ import type { WebSocket } from "ws";
6
+ import type { ServerToBrowserMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
7
+ import type { SessionManager } from "../memory-session-manager.js";
8
+ import type { EventStore } from "../memory-event-store.js";
9
+ import type { PiGateway } from "../pi-gateway.js";
10
+ import type { PendingForkRegistry } from "../pending-fork-registry.js";
11
+ import type { SessionOrderManager } from "../session-order-manager.js";
12
+ import type { PreferencesStore } from "../preferences-store.js";
13
+ import type { DirectoryService } from "../directory-service.js";
14
+ import type { TerminalManager } from "../terminal-manager.js";
15
+ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
16
+ import type { PendingResumeRegistry } from "../pending-resume-registry.js";
17
+
18
+ export interface BrowserHandlerContext {
19
+ ws: WebSocket;
20
+ sessionManager: SessionManager;
21
+ eventStore: EventStore;
22
+ piGateway: PiGateway;
23
+ pendingForkRegistry?: PendingForkRegistry;
24
+ sessionOrderManager?: SessionOrderManager;
25
+ preferencesStore?: PreferencesStore;
26
+ directoryService?: DirectoryService;
27
+ terminalManager?: TerminalManager;
28
+ headlessPidRegistry: HeadlessPidRegistry;
29
+ pendingResumeRegistry: PendingResumeRegistry;
30
+ pendingDashboardSpawns?: Map<string, number>;
31
+ /** Send message to a specific WebSocket */
32
+ sendTo(ws: WebSocket, msg: ServerToBrowserMessage): void;
33
+ /** Broadcast to all connected browsers */
34
+ broadcast(msg: ServerToBrowserMessage): void;
35
+ /** Get subscribers for a session */
36
+ getSubscribers(sessionId: string): WebSocket[];
37
+ /** Track UI request */
38
+ trackUiRequest(sessionId: string, requestId: string, method: string, params: Record<string, unknown>): boolean | void;
39
+ /** Replay pending UI requests to a browser */
40
+ replayPendingUiRequests(ws: WebSocket, sessionId: string): void;
41
+ /** Mark a session as mid-replay for a specific WebSocket (suppresses live events) */
42
+ markReplaying(ws: WebSocket, sessionId: string): void;
43
+ /** Clear replay flag and send catch-up events */
44
+ clearReplaying(ws: WebSocket, sessionId: string, lastReplayedSeq: number): void;
45
+ }