@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,554 @@
1
+ /**
2
+ * Dashboard HTTP + WebSocket server.
3
+ */
4
+ import Fastify from "fastify";
5
+ import fastifyStatic from "@fastify/static";
6
+ import cors from "@fastify/cors";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import os from "node:os";
10
+ import { createRequire } from "node:module";
11
+ import { existsSync } from "node:fs";
12
+ import { createMemoryEventStore, type EventStore } from "./memory-event-store.js";
13
+ import { createMemorySessionManager, type SessionManager } from "./memory-session-manager.js";
14
+ import { createPiGateway, type PiGateway } from "./pi-gateway.js";
15
+ import { createBrowserGateway, type BrowserGateway } from "./browser-gateway.js";
16
+ import { createPreferencesStore, type PreferencesStore } from "./preferences-store.js";
17
+ import { createMetaPersistence, type MetaPersistence } from "./meta-persistence.js";
18
+ import { createSessionOrderManager, type SessionOrderManager } from "./session-order-manager.js";
19
+ import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js";
20
+
21
+ // pending-load-manager removed — server loads sessions directly via DirectoryService
22
+ import { createDirectoryService, type DirectoryService } from "./directory-service.js";
23
+ import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
24
+ import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js";
25
+ import { writePid, removePid } from "./server-pid.js";
26
+ import { advertiseDashboard, stopAdvertising, createBrowser, type DashboardBrowser, type DiscoveredServer } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
27
+ import { wireEvents } from "./event-wiring.js";
28
+ import { createIdleTimer } from "./idle-timer.js";
29
+ import { discoverAndBroadcastSessions } from "./session-bootstrap.js";
30
+ import { scanAllSessions } from "./session-scanner.js";
31
+ import { needsMigration, runMigration } from "./migrate-persistence.js";
32
+ import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel } from "./tunnel.js";
33
+ import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
34
+ import { ensureBridgeExtensionRegistered } from "./extension-register.js";
35
+ import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
36
+ import type { AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
37
+ import { registerSessionApi } from "./session-api.js";
38
+ import { registerSessionRoutes } from "./routes/session-routes.js";
39
+ import { registerGitRoutes } from "./routes/git-routes.js";
40
+ import { registerFileRoutes } from "./routes/file-routes.js";
41
+ import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
42
+ import { registerSystemRoutes } from "./routes/system-routes.js";
43
+ import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
44
+ import { registerPackageRoutes } from "./routes/package-routes.js";
45
+ import { registerProviderRoutes } from "./routes/provider-routes.js";
46
+ import { PackageManagerWrapper } from "./package-manager-wrapper.js";
47
+ import { createEditorManager, type EditorManager } from "./editor-manager.js";
48
+ import { registerEditorRoutes } from "./routes/editor-routes.js";
49
+ import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js";
50
+ import { detectCodeServerBinary } from "./editor-detection.js";
51
+
52
+ export interface ServerConfig {
53
+ port: number;
54
+ piPort: number;
55
+ dev: boolean;
56
+ autoShutdown: boolean;
57
+ shutdownIdleSeconds: number;
58
+ tunnel: boolean;
59
+ tunnelReservedToken?: string;
60
+ authConfig?: AuthConfig;
61
+ /** Override WS ping interval for pi-gateway (ms). Default 60000. Set 0 to disable. */
62
+ pingInterval?: number;
63
+ /** Memory limit overrides from config */
64
+ maxEventsPerSession?: number;
65
+ maxStringFieldSize?: number;
66
+ maxWsBufferBytes?: number;
67
+ /** Editor (code-server) config */
68
+ editor: import("@blackbelt-technology/pi-dashboard-shared/config.js").EditorConfig;
69
+ /** Merged trusted networks from config */
70
+ resolvedTrustedNetworks?: string[];
71
+ /** CORS allowed origins from config */
72
+ corsAllowedOrigins?: string[];
73
+ }
74
+
75
+ export interface DashboardServer {
76
+ start(): Promise<void>;
77
+ stop(): Promise<void>;
78
+ sessionManager: SessionManager;
79
+ eventStore: EventStore;
80
+ browserGateway: BrowserGateway;
81
+ }
82
+
83
+ export async function createServer(config: ServerConfig): Promise<DashboardServer> {
84
+ // Ensure bridge extension is registered in pi's global settings
85
+ // (needed for bundled installs where pi can't discover it from package.json)
86
+ ensureBridgeExtensionRegistered();
87
+
88
+ // Run migration from sessions.json + state.json if needed
89
+ if (needsMigration()) {
90
+ const migResult = runMigration();
91
+ console.log(`[dashboard] Migration complete: ${migResult.sessionsWritten} sessions, ${migResult.hiddenApplied} hidden applied, ${migResult.hiddenOrphaned} orphaned, renamed: ${migResult.oldFilesRenamed.join(", ")}`);
92
+ }
93
+
94
+ const preferencesStore = createPreferencesStore();
95
+ const sessionManager = createMemorySessionManager();
96
+ const metaPersistence = createMetaPersistence();
97
+ const sessionOrderManager = createSessionOrderManager(preferencesStore);
98
+ const pendingForkRegistry = createPendingForkRegistry();
99
+
100
+ // Restore sessions from per-session .meta.json files (scans ~/.pi/agent/sessions/)
101
+ const scanResult = scanAllSessions();
102
+ for (const session of scanResult.sessions) {
103
+ const restored = { ...session, dataUnavailable: true };
104
+ if (restored.status !== "ended") {
105
+ restored.status = "ended";
106
+ restored.endedAt = restored.endedAt ?? Date.now();
107
+ }
108
+ sessionManager.restore(restored);
109
+ }
110
+ if (scanResult.cacheUpdates > 0) {
111
+ console.log(`[dashboard] Session scan: ${scanResult.sessions.length} sessions, ${scanResult.cacheUpdates} cache updates`);
112
+ }
113
+
114
+ // Save per-session .meta.json on any change
115
+ sessionManager.onChange = (sessionId: string) => {
116
+ const session = sessionManager.get(sessionId);
117
+ if (!session?.sessionFile) return;
118
+ metaPersistence.save(session.sessionFile, {
119
+ source: session.source,
120
+ name: session.name,
121
+ attachedProposal: session.attachedProposal,
122
+ hidden: session.hidden,
123
+ cwd: session.cwd,
124
+ status: session.status,
125
+ startedAt: session.startedAt,
126
+ endedAt: session.endedAt,
127
+ model: session.model,
128
+ thinkingLevel: session.thinkingLevel,
129
+ tokensIn: session.tokensIn,
130
+ tokensOut: session.tokensOut,
131
+ cacheRead: session.cacheRead,
132
+ cacheWrite: session.cacheWrite,
133
+ cost: session.cost,
134
+ contextTokens: session.contextTokens,
135
+ contextWindow: session.contextWindow,
136
+ firstMessage: session.firstMessage,
137
+ cachedAt: Date.now(),
138
+ });
139
+ };
140
+
141
+ // Track cwds with pending dashboard-spawned sessions (for writing .meta.json).
142
+ // Uses a counter per cwd to handle multiple spawns and avoid reconnects consuming entries.
143
+ const pendingDashboardSpawns = new Map<string, number>();
144
+ // Track known session IDs so we can distinguish new sessions from reconnections.
145
+ const knownSessionIds = new Set<string>();
146
+ // Populate from persisted sessions
147
+ for (const s of sessionManager.listAll()) {
148
+ knownSessionIds.add(s.id);
149
+ }
150
+
151
+ const directoryService = createDirectoryService(preferencesStore, sessionManager);
152
+
153
+ // mDNS peer discovery state
154
+ let mdnsBrowser: DashboardBrowser | null = null;
155
+ const peerServers = new Map<string, DiscoveredServer>();
156
+
157
+ const piGateway = createPiGateway(sessionManager, {
158
+ ...(config.pingInterval !== undefined ? { pingInterval: config.pingInterval } : {}),
159
+ });
160
+
161
+ // Create event store with pinning callback and configurable limits
162
+ const eventStore = createMemoryEventStore(
163
+ (sessionId) =>
164
+ piGateway.isSessionConnected(sessionId) ||
165
+ browserGateway.getSubscriberCount(sessionId) > 0,
166
+ undefined, // maxCachedSessions (use default)
167
+ config.maxEventsPerSession,
168
+ config.maxStringFieldSize,
169
+ );
170
+
171
+ // Create terminal manager with exit callback
172
+ const terminalManager = createTerminalManager({
173
+ onExit: (terminalId) => {
174
+ // Find and remove from session order
175
+ const allOrders = sessionOrderManager.getAllOrders();
176
+ for (const [cwd, ids] of Object.entries(allOrders)) {
177
+ if (ids.includes(terminalId)) {
178
+ sessionOrderManager.remove(cwd, terminalId);
179
+ break;
180
+ }
181
+ }
182
+ browserGateway.broadcastToAll({ type: "terminal_removed", terminalId });
183
+ },
184
+ });
185
+
186
+ const terminalGateway = createTerminalGateway(terminalManager);
187
+
188
+ // Create editor manager for code-server instances
189
+ const editorDetection = detectCodeServerBinary(config.editor);
190
+ const editorManager = createEditorManager({
191
+ config: config.editor,
192
+ detection: editorDetection,
193
+ onStatusChange: (cwd, id, status) => {
194
+ browserGateway.broadcastToAll({ type: "editor_status", cwd, id, status });
195
+ },
196
+ });
197
+
198
+ const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes);
199
+
200
+ // Resolve package version once at startup
201
+ const __require = createRequire(import.meta.url);
202
+ let pkgVersion = "unknown";
203
+ try { pkgVersion = __require("../../package.json").version ?? "unknown"; } catch {}
204
+ const selfHostname = os.hostname();
205
+
206
+ // Send this server + discovered peers to new browser connections
207
+ browserGateway.onConnect = (ws) => {
208
+ const selfServer: DiscoveredServer = {
209
+ host: selfHostname,
210
+ port: config.port,
211
+ piPort: config.piPort,
212
+ version: pkgVersion,
213
+ pid: process.pid,
214
+ isLocal: true,
215
+ source: "mdns",
216
+ };
217
+ const all = [selfServer, ...Array.from(peerServers.values())];
218
+ browserGateway.sendToClient(ws, { type: "servers_discovered", servers: all });
219
+ };
220
+
221
+ // Wire up event forwarding from pi gateway to browser gateway
222
+ wireEvents({
223
+ sessionManager,
224
+ eventStore,
225
+ piGateway,
226
+ browserGateway,
227
+ sessionOrderManager,
228
+ pendingForkRegistry,
229
+ directoryService,
230
+ knownSessionIds,
231
+ pendingDashboardSpawns,
232
+ });
233
+
234
+ // Auto-shutdown idle timer
235
+ const idleTimer = createIdleTimer(config, piGateway);
236
+
237
+ const fastify = Fastify({
238
+ logger: false,
239
+ keepAliveTimeout: 30_000,
240
+ connectionTimeout: 10_000,
241
+ });
242
+
243
+ // CORS: allow localhost by default + configured origins
244
+ const corsAllowedOrigins = config.corsAllowedOrigins ?? [];
245
+ await fastify.register(cors, {
246
+ origin: (origin, cb) => {
247
+ // Same-origin (no Origin header) — always allow
248
+ if (!origin) return cb(null, true);
249
+ // Localhost / 127.0.0.1 / [::1] — any port
250
+ try {
251
+ const u = new URL(origin);
252
+ const host = u.hostname;
253
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
254
+ return cb(null, true);
255
+ }
256
+ } catch { /* ignore parse errors */ }
257
+ // Configured origins
258
+ if (corsAllowedOrigins.includes(origin)) return cb(null, true);
259
+ cb(new Error("CORS origin not allowed"), false);
260
+ },
261
+ credentials: true,
262
+ });
263
+
264
+ // Register auth plugin if configured (must be before routes)
265
+ if (config.authConfig) {
266
+ await registerAuthPlugin(fastify, {
267
+ authConfig: config.authConfig,
268
+ port: config.port,
269
+ resolvedTrustedNetworks: config.resolvedTrustedNetworks,
270
+ });
271
+ } else {
272
+ // Auth disabled — register isAuthenticated decorator so guard can always read it
273
+ fastify.decorateRequest("isAuthenticated", false);
274
+ // Still expose /auth/status so clients can detect this
275
+ fastify.get("/auth/status", async () => ({ authenticated: true, authEnabled: false }));
276
+ }
277
+
278
+ // Session control REST API (wraps WebSocket-only operations)
279
+ registerSessionApi(fastify, {
280
+ sessionManager,
281
+ piGateway,
282
+ browserGateway,
283
+ pendingForkRegistry,
284
+ pendingDashboardSpawns,
285
+ });
286
+
287
+ // Register route modules
288
+ // Create network guard from merged trusted networks
289
+ const networkGuard = createNetworkGuard(config.resolvedTrustedNetworks ?? []);
290
+
291
+ registerSessionRoutes(fastify, { sessionManager, eventStore, networkGuard });
292
+ registerGitRoutes(fastify, { networkGuard });
293
+ registerFileRoutes(fastify, { sessionManager, preferencesStore, networkGuard });
294
+ registerOpenSpecRoutes(fastify, { sessionManager, preferencesStore, directoryService, networkGuard });
295
+ registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard });
296
+ // Package management
297
+ const packageManagerWrapper = new PackageManagerWrapper();
298
+
299
+ // Forward progress events to all browser clients
300
+ packageManagerWrapper.setProgressListener((operationId, event) => {
301
+ browserGateway.broadcastToAll({ type: "package_progress", operationId, event } as any);
302
+ });
303
+
304
+ // On completion: broadcast to browsers
305
+ packageManagerWrapper.setCompleteListener((result) => {
306
+ browserGateway.broadcastToAll({
307
+ type: "package_operation_complete",
308
+ operationId: result.operationId,
309
+ action: result.action,
310
+ source: result.source,
311
+ scope: result.scope,
312
+ success: result.success,
313
+ error: result.error,
314
+ sessionsReloaded: (result as any).sessionsReloaded,
315
+ } as any);
316
+ });
317
+
318
+ // Reload all active sessions after a successful package operation
319
+ packageManagerWrapper.setReloadSessions(async () => {
320
+ const connectedIds = piGateway.getConnectedSessionIds();
321
+ let count = 0;
322
+ for (const sid of connectedIds) {
323
+ const session = sessionManager.get(sid);
324
+ if (session && session.status !== "ended") {
325
+ piGateway.sendToSession(sid, {
326
+ type: "send_prompt",
327
+ sessionId: sid,
328
+ text: "/reload",
329
+ });
330
+ count++;
331
+ }
332
+ }
333
+ return count;
334
+ });
335
+
336
+ registerPackageRoutes(fastify, { packageManagerWrapper });
337
+
338
+ // Editor (code-server) routes and proxy
339
+ registerEditorRoutes(fastify, editorManager, { networkGuard });
340
+ registerEditorProxy(fastify, editorManager);
341
+
342
+ registerProviderAuthRoutes(fastify, { piGateway });
343
+ registerProviderRoutes(fastify, { networkGuard });
344
+
345
+ // Serve static files / SPA fallback
346
+ // Search order: npm package → workspace sibling → legacy dist/client
347
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
348
+ const clientSearchPaths = [
349
+ // Installed as npm dependency
350
+ path.join(__dirname, "../../node_modules/@blackbelt-technology/pi-dashboard-web/dist"),
351
+ // Monorepo workspace sibling
352
+ path.join(__dirname, "../../client/dist"),
353
+ // Legacy path
354
+ path.join(__dirname, "../../dist/client"),
355
+ ];
356
+ const clientDir = clientSearchPaths.find(p => existsSync(path.join(p, "index.html"))) ?? "";
357
+ const hasProductionBuild = !!clientDir;
358
+ if (!hasProductionBuild) {
359
+ console.log("[dashboard] No client build found — running in API-only mode");
360
+ }
361
+
362
+ // Register static file serving for production build.
363
+ // Always enabled — in dev mode, Vite handles most requests via the
364
+ // not-found proxy, but asset files (JS/CSS with hashed names) must be
365
+ // served directly when Vite is not running (production fallback).
366
+ if (hasProductionBuild) {
367
+ await fastify.register(fastifyStatic, {
368
+ root: clientDir,
369
+ prefix: "/",
370
+ setHeaders: (res, filePath) => {
371
+ if (filePath.endsWith(".html")) {
372
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
373
+ }
374
+ },
375
+ });
376
+ }
377
+
378
+ if (config.dev) {
379
+ // Dev mode: proxy to Vite dev server, fall back to production build
380
+ const VITE_PORTS = [3000, 5173, 5174];
381
+ let vitePort = 0;
382
+
383
+ async function detectVitePort(): Promise<number> {
384
+ for (const port of VITE_PORTS) {
385
+ try {
386
+ const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) });
387
+ if (res.ok) return port;
388
+ } catch { /* not listening */ }
389
+ }
390
+ return 0;
391
+ }
392
+
393
+ vitePort = await detectVitePort();
394
+
395
+ fastify.setNotFoundHandler(async (request, reply) => {
396
+ // Try Vite proxy first
397
+ if (!vitePort) vitePort = await detectVitePort();
398
+ if (vitePort) {
399
+ try {
400
+ const viteUrl = `http://localhost:${vitePort}${request.url}`;
401
+ const res = await fetch(viteUrl);
402
+ const contentType = res.headers.get("content-type");
403
+ if (contentType) reply.header("Content-Type", contentType);
404
+ reply.code(res.status);
405
+ return reply.send(Buffer.from(await res.arrayBuffer()));
406
+ } catch {
407
+ vitePort = 0; // Vite stopped — re-probe next time
408
+ }
409
+ }
410
+ // Fallback: serve production build if available
411
+ if (hasProductionBuild) {
412
+ reply.header("Cache-Control", "no-cache, no-store, must-revalidate");
413
+ return reply.sendFile("index.html");
414
+ }
415
+ return reply.code(404).send({ error: "API-only mode: no client build available. Install @blackbelt-technology/pi-dashboard-web or run npm run build." });
416
+ });
417
+ } else if (hasProductionBuild) {
418
+ // Production mode: SPA fallback
419
+ fastify.setNotFoundHandler(async (_request, reply) => {
420
+ reply.header("Cache-Control", "no-cache, no-store, must-revalidate");
421
+ return reply.sendFile("index.html");
422
+ });
423
+ } else {
424
+ fastify.setNotFoundHandler(async (_request, reply) => {
425
+ return reply.code(500).send({ error: "No client build found. Run `npm run build` first." });
426
+ });
427
+ }
428
+
429
+ const server: DashboardServer = {
430
+ sessionManager,
431
+ eventStore,
432
+ browserGateway,
433
+
434
+ async start() {
435
+ // Clean up orphan headless processes from a previous server instance
436
+ browserGateway.headlessPidRegistry.cleanupOrphans();
437
+
438
+ piGateway.start(config.piPort);
439
+
440
+ fastify.server.on("upgrade", (request, socket, head) => {
441
+ // Access check for WebSocket upgrades
442
+ const remoteAddress = request.socket.remoteAddress || "";
443
+ const trusted = config.resolvedTrustedNetworks ?? [];
444
+ if (config.authConfig?.secret) {
445
+ if (!validateWsUpgrade(request.headers.cookie, remoteAddress, config.authConfig.secret, trusted)) {
446
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
447
+ socket.destroy();
448
+ return;
449
+ }
450
+ } else if (!isLoopback(remoteAddress) && (trusted.length === 0 || !isBypassedHost(remoteAddress, trusted))) {
451
+ // No auth configured — only allow loopback or trusted networks
452
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
453
+ socket.destroy();
454
+ return;
455
+ }
456
+
457
+ if (request.url === "/ws") {
458
+ browserGateway.wss.handleUpgrade(request, socket, head, (ws) => {
459
+ browserGateway.wss.emit("connection", ws, request);
460
+ });
461
+ } else if (request.url?.startsWith("/ws/terminal/")) {
462
+ terminalGateway.handleUpgrade(request, socket, head);
463
+ } else if (request.url?.startsWith("/editor/")) {
464
+ handleEditorUpgrade(editorManager, request, socket, head);
465
+ } else {
466
+ socket.destroy();
467
+ }
468
+ });
469
+
470
+ await fastify.listen({ port: config.port, host: "0.0.0.0" });
471
+ writePid(process.pid);
472
+ console.log(`Dashboard server running at http://localhost:${config.port}`);
473
+ console.log(`Pi gateway listening on port ${config.piPort}`);
474
+
475
+ // Advertise via mDNS
476
+ try {
477
+ advertiseDashboard(config.port, config.piPort);
478
+ console.log(`mDNS: advertising _pi-dashboard._tcp on port ${config.port}`);
479
+ } catch (err) {
480
+ console.warn(`mDNS advertisement failed (will continue without):`, err);
481
+ }
482
+
483
+ // Start continuous mDNS browser for peer discovery
484
+ try {
485
+ mdnsBrowser = createBrowser();
486
+ mdnsBrowser.on("server-up", (server: DiscoveredServer) => {
487
+ // Don't include ourselves
488
+ if (server.isLocal && server.port === config.port) return;
489
+ peerServers.set(`${server.host}:${server.port}`, server);
490
+ browserGateway.broadcast({ type: "servers_updated", servers: Array.from(peerServers.values()) });
491
+ });
492
+ mdnsBrowser.on("server-down", (server: DiscoveredServer) => {
493
+ peerServers.delete(`${server.host}:${server.port}`);
494
+ browserGateway.broadcast({ type: "servers_updated", servers: Array.from(peerServers.values()) });
495
+ });
496
+ } catch (err) {
497
+ console.warn(`mDNS browser failed (peer discovery disabled):`, err);
498
+ }
499
+
500
+ if (config.tunnel) {
501
+ const hasZrok = detectZrokBinary();
502
+ if (hasZrok) {
503
+ cleanupStaleZrok();
504
+ const tunnelUrl = await createTunnel(config.port, config.tunnelReservedToken);
505
+ if (tunnelUrl) {
506
+ console.log(`🌐 Tunnel: ${tunnelUrl}`);
507
+ }
508
+ }
509
+ }
510
+
511
+ // Discover sessions and start OpenSpec polling (async, non-blocking)
512
+ discoverAndBroadcastSessions({ sessionManager, browserGateway, directoryService });
513
+
514
+ idleTimer.start();
515
+ },
516
+
517
+ async stop() {
518
+ // Stop mDNS before closing
519
+ try {
520
+ if (mdnsBrowser) { mdnsBrowser.stop(); mdnsBrowser = null; }
521
+ stopAdvertising();
522
+ } catch { /* ignore mDNS cleanup errors */ }
523
+ removePid();
524
+ idleTimer.cancel();
525
+ directoryService.stopPolling();
526
+ browserGateway.shutdownHeadlessProcesses();
527
+ metaPersistence.flushAll();
528
+ metaPersistence.dispose();
529
+ pendingForkRegistry.dispose();
530
+ preferencesStore.flush();
531
+ preferencesStore.dispose();
532
+
533
+ await deleteTunnel();
534
+ piGateway.stop();
535
+ for (const client of browserGateway.wss.clients) {
536
+ client.terminate();
537
+ }
538
+ browserGateway.wss.close();
539
+ terminalGateway.close();
540
+ // Kill all active terminal PTY processes
541
+ for (const t of terminalManager.list()) {
542
+ try { terminalManager.kill(t.id); } catch {}
543
+ }
544
+ // Stop all code-server instances
545
+ editorManager.stopAll();
546
+ // Close any pending OAuth callback servers
547
+ try { const { closeAllCallbackServers } = await import("./oauth-callback-server.js"); await closeAllCallbackServers(); } catch {}
548
+ await fastify.close();
549
+ },
550
+ };
551
+
552
+ idleTimer.setStopFn(server.stop.bind(server));
553
+ return server;
554
+ }