@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,110 @@
1
+ /**
2
+ * Tracks in-flight on-demand session load requests from bridge extensions.
3
+ * Handles deduplication, timeouts, and bridge disconnect cleanup.
4
+ */
5
+ import type WebSocket from "ws";
6
+
7
+ export interface PendingLoad {
8
+ sessionId: string;
9
+ requestedAt: number;
10
+ browsers: Set<WebSocket>;
11
+ bridgeSessionId: string;
12
+ timer: ReturnType<typeof setTimeout>;
13
+ }
14
+
15
+ export interface PendingLoadManager {
16
+ /** Start a pending load. Returns false if already pending (dedup). */
17
+ start(sessionId: string, browser: WebSocket, bridgeSessionId: string): boolean;
18
+ /** Add a browser to an existing pending load. Returns false if no pending load exists. */
19
+ addBrowser(sessionId: string, browser: WebSocket): boolean;
20
+ /** Check if a session has a pending load. */
21
+ isPending(sessionId: string): boolean;
22
+ /** Complete a pending load (success or error). Returns the waiting browsers, or null if not found. */
23
+ complete(sessionId: string): Set<WebSocket> | null;
24
+ /** Cancel all pending loads for a specific bridge. Returns Map of sessionId → browsers. */
25
+ cancelForBridge(bridgeSessionId: string): Map<string, Set<WebSocket>>;
26
+ /** Cancel a specific pending load. Returns the waiting browsers, or null. */
27
+ cancel(sessionId: string): Set<WebSocket> | null;
28
+ /** Clean up all timers. */
29
+ dispose(): void;
30
+ }
31
+
32
+ const DEFAULT_TIMEOUT_MS = 10_000;
33
+
34
+ export function createPendingLoadManager(
35
+ onTimeout: (sessionId: string, browsers: Set<WebSocket>) => void,
36
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
37
+ ): PendingLoadManager {
38
+ const pending = new Map<string, PendingLoad>();
39
+
40
+ function createTimer(sessionId: string): ReturnType<typeof setTimeout> {
41
+ return setTimeout(() => {
42
+ const load = pending.get(sessionId);
43
+ if (load) {
44
+ pending.delete(sessionId);
45
+ onTimeout(sessionId, load.browsers);
46
+ }
47
+ }, timeoutMs);
48
+ }
49
+
50
+ return {
51
+ start(sessionId: string, browser: WebSocket, bridgeSessionId: string): boolean {
52
+ if (pending.has(sessionId)) return false;
53
+ const load: PendingLoad = {
54
+ sessionId,
55
+ requestedAt: Date.now(),
56
+ browsers: new Set([browser]),
57
+ bridgeSessionId,
58
+ timer: createTimer(sessionId),
59
+ };
60
+ pending.set(sessionId, load);
61
+ return true;
62
+ },
63
+
64
+ addBrowser(sessionId: string, browser: WebSocket): boolean {
65
+ const load = pending.get(sessionId);
66
+ if (!load) return false;
67
+ load.browsers.add(browser);
68
+ return true;
69
+ },
70
+
71
+ isPending(sessionId: string): boolean {
72
+ return pending.has(sessionId);
73
+ },
74
+
75
+ complete(sessionId: string): Set<WebSocket> | null {
76
+ const load = pending.get(sessionId);
77
+ if (!load) return null;
78
+ clearTimeout(load.timer);
79
+ pending.delete(sessionId);
80
+ return load.browsers;
81
+ },
82
+
83
+ cancelForBridge(bridgeSessionId: string): Map<string, Set<WebSocket>> {
84
+ const cancelled = new Map<string, Set<WebSocket>>();
85
+ for (const [sessionId, load] of pending) {
86
+ if (load.bridgeSessionId === bridgeSessionId) {
87
+ clearTimeout(load.timer);
88
+ pending.delete(sessionId);
89
+ cancelled.set(sessionId, load.browsers);
90
+ }
91
+ }
92
+ return cancelled;
93
+ },
94
+
95
+ cancel(sessionId: string): Set<WebSocket> | null {
96
+ const load = pending.get(sessionId);
97
+ if (!load) return null;
98
+ clearTimeout(load.timer);
99
+ pending.delete(sessionId);
100
+ return load.browsers;
101
+ },
102
+
103
+ dispose(): void {
104
+ for (const load of pending.values()) {
105
+ clearTimeout(load.timer);
106
+ }
107
+ pending.clear();
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tracks pending auto-resume operations: prompts queued for ended sessions
3
+ * being resumed. Entries expire after 30 seconds if not consumed.
4
+ */
5
+
6
+ import type { ImageContent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
7
+
8
+ const EXPIRY_MS = 30_000;
9
+
10
+ export interface PendingResumeEntry {
11
+ text: string;
12
+ images?: ImageContent[];
13
+ oldSessionId: string;
14
+ sessionFile: string;
15
+ }
16
+
17
+ interface InternalEntry extends PendingResumeEntry {
18
+ timer: ReturnType<typeof setTimeout>;
19
+ }
20
+
21
+ export interface PendingResumeRegistryOptions {
22
+ /** Called when a pending resume expires without being consumed. */
23
+ onTimeout?: (oldSessionId: string) => void;
24
+ }
25
+
26
+ export interface PendingResumeRegistry {
27
+ /** Record a pending resume for a cwd. Overwrites any previous entry for the same cwd. */
28
+ record(cwd: string, entry: PendingResumeEntry): void;
29
+ /** Consume and return the pending resume for a cwd, or undefined if none pending. */
30
+ consume(cwd: string): PendingResumeEntry | undefined;
31
+ /** Clear all pending entries and timers. */
32
+ dispose(): void;
33
+ }
34
+
35
+ export function createPendingResumeRegistry(
36
+ options?: PendingResumeRegistryOptions,
37
+ ): PendingResumeRegistry {
38
+ const pending = new Map<string, InternalEntry>();
39
+
40
+ return {
41
+ record(cwd: string, entry: PendingResumeEntry): void {
42
+ const existing = pending.get(cwd);
43
+ if (existing) {
44
+ clearTimeout(existing.timer);
45
+ }
46
+ const timer = setTimeout(() => {
47
+ pending.delete(cwd);
48
+ options?.onTimeout?.(entry.oldSessionId);
49
+ }, EXPIRY_MS);
50
+ pending.set(cwd, { ...entry, timer });
51
+ },
52
+
53
+ consume(cwd: string): PendingResumeEntry | undefined {
54
+ const entry = pending.get(cwd);
55
+ if (!entry) return undefined;
56
+ clearTimeout(entry.timer);
57
+ pending.delete(cwd);
58
+ const { timer: _, ...result } = entry;
59
+ return result;
60
+ },
61
+
62
+ dispose(): void {
63
+ for (const entry of pending.values()) {
64
+ clearTimeout(entry.timer);
65
+ }
66
+ pending.clear();
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Pi Gateway - WebSocket server for bridge extension connections.
3
+ */
4
+ import { WebSocketServer, WebSocket } from "ws";
5
+ import type { ExtensionToServerMessage, ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
6
+ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
7
+ import type { SessionManager } from "./memory-session-manager.js";
8
+
9
+ export const HEARTBEAT_TIMEOUT = 180_000;
10
+ export const WS_PING_INTERVAL = 60_000;
11
+
12
+ export interface PiGatewayOptions {
13
+ heartbeatTimeout?: number;
14
+ pingInterval?: number;
15
+ }
16
+
17
+ export interface PiGateway {
18
+ start(port: number): void;
19
+ stop(): void;
20
+ sendToSession(sessionId: string, msg: ServerToExtensionMessage): boolean;
21
+ broadcast(msg: ServerToExtensionMessage): void;
22
+ connectionCount(): number;
23
+ findSessionByCwd(cwd: string): string | undefined;
24
+ getConnectedSessionIds(): string[];
25
+ isSessionConnected(sessionId: string): boolean;
26
+ /** Force-close the WebSocket connection for a session */
27
+ closeSession(sessionId: string): boolean;
28
+ onEvent?: (sessionId: string, msg: ExtensionToServerMessage) => void;
29
+ onEmpty?: () => void;
30
+ onConnection?: () => void;
31
+ onDisconnect?: (sessionId: string) => void;
32
+ onSessionCreated?: (sessionId: string) => void;
33
+ }
34
+
35
+ export function createPiGateway(
36
+ sessionManager: SessionManager,
37
+ options?: PiGatewayOptions,
38
+ ): PiGateway {
39
+ const hbTimeout = options?.heartbeatTimeout ?? HEARTBEAT_TIMEOUT;
40
+ const pingMs = options?.pingInterval ?? WS_PING_INTERVAL;
41
+ let wss: WebSocketServer | null = null;
42
+ let pingTimer: ReturnType<typeof setInterval> | null = null;
43
+
44
+ // Map sessionId → WebSocket
45
+ const connections = new Map<string, WebSocket>();
46
+ // Track connection liveness for WS ping/pong (miss counter: kill after 2 consecutive misses)
47
+ const aliveMisses = new Map<WebSocket, number>();
48
+ // Map sessionId → heartbeat timeout
49
+ const heartbeatTimers = new Map<string, ReturnType<typeof setTimeout>>();
50
+ // Map sessionId → { setAt: timestamp, sleepRetried: boolean } for sleep detection
51
+ const heartbeatMeta = new Map<string, { setAt: number; sleepRetried: boolean }>();
52
+
53
+ let onEvent: ((sessionId: string, msg: ExtensionToServerMessage) => void) | undefined;
54
+ let onEmpty: (() => void) | undefined;
55
+ let onConnection: (() => void) | undefined;
56
+ let onDisconnect: ((sessionId: string) => void) | undefined;
57
+ let onSessionCreated: ((sessionId: string) => void) | undefined;
58
+
59
+ function checkEmpty() {
60
+ if (connections.size === 0) {
61
+ onEmpty?.();
62
+ }
63
+ }
64
+
65
+ function resetHeartbeat(sessionId: string) {
66
+ const existing = heartbeatTimers.get(sessionId);
67
+ if (existing) clearTimeout(existing);
68
+
69
+ const now = Date.now();
70
+ heartbeatMeta.set(sessionId, { setAt: now, sleepRetried: false });
71
+
72
+ heartbeatTimers.set(
73
+ sessionId,
74
+ setTimeout(() => {
75
+ // If the WebSocket TCP connection is still open, don't kill the session.
76
+ // The bridge is just busy (e.g. running a long tool execution) and can't
77
+ // send heartbeats, but the connection itself is alive. Reschedule.
78
+ const ws = connections.get(sessionId);
79
+ if (ws && ws.readyState === WebSocket.OPEN) {
80
+ console.error(`[gateway] heartbeat timeout but WS still OPEN for ${sessionId}, rescheduling`);
81
+ resetHeartbeat(sessionId);
82
+ return;
83
+ }
84
+ // Session status check: if the session is still streaming/active
85
+ // (not manually ended), give it more time to reconnect.
86
+ // Forked child processes (vitest) can kill the WS connection by
87
+ // inheriting and closing the FD, but the bridge will reconnect
88
+ // once the event loop is free.
89
+ const session = sessionManager.get(sessionId);
90
+ const meta = heartbeatMeta.get(sessionId);
91
+ if (session && session.status !== "ended" && !meta?.sleepRetried) {
92
+ console.error(`[gateway] heartbeat timeout but session ${sessionId} still active, giving reconnect grace period`);
93
+ if (meta) {
94
+ meta.sleepRetried = true;
95
+ meta.setAt = Date.now();
96
+ }
97
+ heartbeatTimers.set(
98
+ sessionId,
99
+ setTimeout(() => {
100
+ const ws2 = connections.get(sessionId);
101
+ if (ws2 && ws2.readyState === WebSocket.OPEN) {
102
+ resetHeartbeat(sessionId);
103
+ return;
104
+ }
105
+ console.error(`[gateway] session timed out: ${sessionId} (reconnect grace period expired)`);
106
+ sessionManager.unregister(sessionId);
107
+ connections.delete(sessionId);
108
+ heartbeatTimers.delete(sessionId);
109
+ heartbeatMeta.delete(sessionId);
110
+ checkEmpty();
111
+ }, hbTimeout),
112
+ );
113
+ return;
114
+ }
115
+ console.error(`[gateway] heartbeat timeout, WS state=${ws?.readyState} for ${sessionId}`);
116
+
117
+ const meta2 = heartbeatMeta.get(sessionId);
118
+ const elapsed = Date.now() - (meta2?.setAt ?? now);
119
+
120
+ // Detect sleep: elapsed >> expected means system was suspended
121
+ if (meta2 && !meta2.sleepRetried && elapsed > hbTimeout * 2) {
122
+ // Give one more cycle for the extension to reconnect
123
+ meta2.sleepRetried = true;
124
+ meta2.setAt = Date.now();
125
+ heartbeatTimers.set(
126
+ sessionId,
127
+ setTimeout(() => {
128
+ const ws2 = connections.get(sessionId);
129
+ if (ws2 && ws2.readyState === WebSocket.OPEN) {
130
+ resetHeartbeat(sessionId);
131
+ return;
132
+ }
133
+ console.error(`[gateway] session timed out: ${sessionId} (sleep recovery failed)`);
134
+ sessionManager.unregister(sessionId);
135
+ connections.delete(sessionId);
136
+ heartbeatTimers.delete(sessionId);
137
+ heartbeatMeta.delete(sessionId);
138
+ checkEmpty();
139
+ }, hbTimeout),
140
+ );
141
+ return;
142
+ }
143
+
144
+ console.error(`[gateway] session timed out: ${sessionId} (no heartbeat for ${hbTimeout}ms)`);
145
+ sessionManager.unregister(sessionId);
146
+ connections.delete(sessionId);
147
+ heartbeatTimers.delete(sessionId);
148
+ heartbeatMeta.delete(sessionId);
149
+ checkEmpty();
150
+ }, hbTimeout)
151
+ );
152
+ }
153
+
154
+ return {
155
+ set onEvent(handler: ((sessionId: string, msg: ExtensionToServerMessage) => void) | undefined) {
156
+ onEvent = handler;
157
+ },
158
+
159
+ set onEmpty(handler: (() => void) | undefined) {
160
+ onEmpty = handler;
161
+ },
162
+
163
+ set onConnection(handler: (() => void) | undefined) {
164
+ onConnection = handler;
165
+ },
166
+
167
+ set onDisconnect(handler: ((sessionId: string) => void) | undefined) {
168
+ onDisconnect = handler;
169
+ },
170
+
171
+ set onSessionCreated(handler: ((sessionId: string) => void) | undefined) {
172
+ onSessionCreated = handler;
173
+ },
174
+
175
+ start(port: number) {
176
+ wss = new WebSocketServer({ port });
177
+
178
+ // WS-level ping/pong: detect truly dead connections.
179
+ // Pong responses are processed in the event loop, so a busy bridge
180
+ // won't respond to pings. We check the underlying TCP socket's
181
+ // writable state as a fallback — if TCP is alive, the bridge is just
182
+ // busy, not dead.
183
+ const PING_MISS_THRESHOLD = 3;
184
+ if (pingMs > 0) pingTimer = setInterval(() => {
185
+ if (!wss) return;
186
+ for (const client of wss.clients) {
187
+ const misses = aliveMisses.get(client) ?? 0;
188
+ if (misses >= PING_MISS_THRESHOLD) {
189
+ // Check if the underlying TCP socket is still alive.
190
+ // If the socket is writable, the connection is physically intact —
191
+ // the bridge is just too busy to process pong frames.
192
+ const socket = (client as any)._socket;
193
+ const socketAlive = socket && !socket.destroyed && socket.writable;
194
+ if (socketAlive) {
195
+ // TCP alive but no pong — bridge is busy. Reset counter, keep alive.
196
+ console.error(`[gateway] ping: ${misses} misses but TCP alive, keeping session (socket.destroyed=${socket?.destroyed} writable=${socket?.writable})`);
197
+ aliveMisses.set(client, 0);
198
+ client.ping();
199
+ continue;
200
+ }
201
+ // TCP is dead — clean up
202
+ console.error(`[gateway] ping: TCP dead (socket=${!!socket} destroyed=${socket?.destroyed} writable=${socket?.writable})`);
203
+
204
+ for (const [sid, ws] of connections) {
205
+ if (ws === client) {
206
+ console.error(`[gateway] connection dead (ping timeout, ${misses} misses): ${sid}`);
207
+ sessionManager.unregister(sid);
208
+ connections.delete(sid);
209
+ const timer = heartbeatTimers.get(sid);
210
+ if (timer) clearTimeout(timer);
211
+ heartbeatTimers.delete(sid);
212
+ heartbeatMeta.delete(sid);
213
+ break;
214
+ }
215
+ }
216
+ client.terminate();
217
+ aliveMisses.delete(client);
218
+ checkEmpty();
219
+ continue;
220
+ }
221
+ aliveMisses.set(client, misses + 1);
222
+ client.ping();
223
+ }
224
+ }, pingMs);
225
+
226
+ wss.on("connection", (ws) => {
227
+ let currentSessionId: string | null = null;
228
+ aliveMisses.set(ws, 0);
229
+ ws.on("pong", () => { aliveMisses.set(ws, 0); });
230
+
231
+ ws.on("message", (raw) => {
232
+ // Any received message proves the connection is alive
233
+ aliveMisses.set(ws, 0);
234
+ try {
235
+ const msg = JSON.parse(raw.toString()) as ExtensionToServerMessage;
236
+
237
+ // Track session identity from any message with a sessionId
238
+ if (!currentSessionId && "sessionId" in msg && (msg as any).sessionId) {
239
+ const sid: string = (msg as any).sessionId;
240
+ currentSessionId = sid;
241
+ connections.set(sid, ws);
242
+ // Auto-create a placeholder session so events aren't lost
243
+ if (!sessionManager.get(sid)) {
244
+ sessionManager.register({
245
+ id: sid,
246
+ cwd: "",
247
+ source: "unknown",
248
+ });
249
+ onSessionCreated?.(sid);
250
+ }
251
+ resetHeartbeat(sid);
252
+ onConnection?.();
253
+ }
254
+
255
+ if (msg.type === "session_register") {
256
+ // If session ID changed (e.g., after /reload), clean up the old placeholder
257
+ if (currentSessionId && currentSessionId !== msg.sessionId) {
258
+ const oldSession = sessionManager.get(currentSessionId);
259
+ // Clean up if it's an auto-created placeholder (source unknown)
260
+ // or a ghost session (no sessionFile, created by duplicate bridge)
261
+ if (oldSession && (oldSession.source === "unknown" || !oldSession.sessionFile)) {
262
+ sessionManager.unregister(currentSessionId);
263
+ connections.delete(currentSessionId);
264
+ }
265
+ }
266
+ currentSessionId = msg.sessionId;
267
+ connections.set(msg.sessionId, ws);
268
+
269
+ sessionManager.register({
270
+ id: msg.sessionId,
271
+ cwd: msg.cwd,
272
+ name: msg.name,
273
+ source: msg.source,
274
+ model: msg.model,
275
+ thinkingLevel: msg.thinkingLevel,
276
+ sessionFile: msg.sessionFile,
277
+ sessionDir: msg.sessionDir,
278
+ firstMessage: msg.firstMessage,
279
+ pid: msg.pid,
280
+ });
281
+ console.error(`[gateway] session registered: ${msg.sessionId} cwd=${msg.cwd}`);
282
+
283
+ resetHeartbeat(msg.sessionId);
284
+ onConnection?.();
285
+ }
286
+
287
+ if (msg.type === "session_heartbeat" && msg.sessionId) {
288
+ resetHeartbeat(msg.sessionId);
289
+ // Store process metrics on the session if provided
290
+ if (msg.metrics) {
291
+ sessionManager.update(msg.sessionId, {
292
+ processMetrics: { ...msg.metrics, updatedAt: Date.now() },
293
+ });
294
+ }
295
+ // Respond with ack so the bridge can track server liveness
296
+ if (ws.readyState === WebSocket.OPEN) {
297
+ ws.send(JSON.stringify({ type: "heartbeat_ack" }));
298
+ }
299
+ }
300
+
301
+ if (msg.type === "session_unregister" && msg.sessionId) {
302
+ console.error(`[gateway] session unregistered: ${msg.sessionId} (explicit)`);
303
+ sessionManager.unregister(msg.sessionId);
304
+ connections.delete(msg.sessionId);
305
+ const timer = heartbeatTimers.get(msg.sessionId);
306
+ if (timer) {
307
+ clearTimeout(timer);
308
+ heartbeatTimers.delete(msg.sessionId);
309
+ }
310
+ heartbeatMeta.delete(msg.sessionId);
311
+ checkEmpty();
312
+ }
313
+
314
+ if (msg.type === "model_update") {
315
+ const session = sessionManager.get(msg.sessionId);
316
+ if (session) {
317
+ const updates: Partial<typeof session> = { model: msg.model };
318
+ if (msg.thinkingLevel !== undefined) {
319
+ updates.thinkingLevel = msg.thinkingLevel;
320
+ }
321
+ sessionManager.update(msg.sessionId, updates);
322
+ }
323
+ }
324
+
325
+ // Notify listeners
326
+ const eventSessionId = "sessionId" in msg ? (msg as any).sessionId : undefined;
327
+ onEvent?.(eventSessionId ?? currentSessionId ?? "", msg);
328
+ } catch {
329
+ // Ignore malformed messages
330
+ }
331
+ });
332
+
333
+ ws.on("close", () => {
334
+ if (currentSessionId) {
335
+ console.error(`[gateway] connection closed: ${currentSessionId}`);
336
+ // Don't immediately unregister - wait for heartbeat timeout
337
+ // This handles temporary disconnects
338
+ onDisconnect?.(currentSessionId);
339
+ }
340
+ aliveMisses.delete(ws);
341
+ });
342
+ });
343
+ },
344
+
345
+ stop() {
346
+ if (pingTimer) {
347
+ clearInterval(pingTimer);
348
+ pingTimer = null;
349
+ }
350
+ for (const timer of heartbeatTimers.values()) {
351
+ clearTimeout(timer);
352
+ }
353
+ heartbeatTimers.clear();
354
+ heartbeatMeta.clear();
355
+ aliveMisses.clear();
356
+ // Forcibly terminate all extension connections
357
+ for (const ws of connections.values()) {
358
+ ws.terminate();
359
+ }
360
+ connections.clear();
361
+ wss?.close();
362
+ wss = null;
363
+ },
364
+
365
+ sendToSession(sessionId: string, msg: ServerToExtensionMessage): boolean {
366
+ const ws = connections.get(sessionId);
367
+ if (ws && ws.readyState === WebSocket.OPEN) {
368
+ ws.send(JSON.stringify(msg));
369
+ return true;
370
+ }
371
+ return false;
372
+ },
373
+
374
+ broadcast(msg: ServerToExtensionMessage): void {
375
+ const payload = JSON.stringify(msg);
376
+ for (const ws of connections.values()) {
377
+ if (ws.readyState === WebSocket.OPEN) {
378
+ ws.send(payload);
379
+ }
380
+ }
381
+ },
382
+
383
+ connectionCount(): number {
384
+ return connections.size;
385
+ },
386
+
387
+ isSessionConnected(sessionId: string): boolean {
388
+ const ws = connections.get(sessionId);
389
+ return ws !== undefined && ws.readyState === WebSocket.OPEN;
390
+ },
391
+
392
+ findSessionByCwd(cwd: string): string | undefined {
393
+ // Find a connected session whose cwd matches or is a prefix
394
+ for (const sid of connections.keys()) {
395
+ const session = sessionManager.get(sid);
396
+ if (session && (session.cwd === cwd || session.cwd.startsWith(cwd + "/") || cwd.startsWith(session.cwd + "/"))) {
397
+ return sid;
398
+ }
399
+ }
400
+ return undefined;
401
+ },
402
+
403
+ getConnectedSessionIds(): string[] {
404
+ return [...connections.keys()].filter(
405
+ (sid) => connections.get(sid)?.readyState === WebSocket.OPEN,
406
+ );
407
+ },
408
+
409
+ closeSession(sessionId: string): boolean {
410
+ const ws = connections.get(sessionId);
411
+ if (ws) {
412
+ ws.close();
413
+ connections.delete(sessionId);
414
+ return true;
415
+ }
416
+ return false;
417
+ },
418
+ };
419
+ }