@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,589 @@
1
+ /**
2
+ * Event wiring: connects pi gateway events to browser gateway and session management.
3
+ * Extracted from server.ts for clarity.
4
+ */
5
+ import type { SessionManager } from "./memory-session-manager.js";
6
+ import type { EventStore } from "./memory-event-store.js";
7
+ import type { PiGateway } from "./pi-gateway.js";
8
+ import type { BrowserGateway } from "./browser-gateway.js";
9
+ import type { SessionOrderManager } from "./session-order-manager.js";
10
+ import type { PendingForkRegistry } from "./pending-fork-registry.js";
11
+ import type { DirectoryService } from "./directory-service.js";
12
+ import { extractSessionUpdates } from "./event-status-extraction.js";
13
+ import { spawnPiSession } from "./process-manager.js";
14
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
15
+ import { writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
16
+ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
17
+ import { detectOpenSpecActivity } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
18
+ import { extractTurnStats } from "@blackbelt-technology/pi-dashboard-shared/stats-extractor.js";
19
+
20
+ export interface EventWiringDeps {
21
+ sessionManager: SessionManager;
22
+ eventStore: EventStore;
23
+ piGateway: PiGateway;
24
+ browserGateway: BrowserGateway;
25
+ sessionOrderManager: SessionOrderManager;
26
+ pendingForkRegistry: PendingForkRegistry;
27
+ directoryService: DirectoryService;
28
+ knownSessionIds: Set<string>;
29
+ pendingDashboardSpawns: Map<string, number>;
30
+ }
31
+
32
+ /**
33
+ * Wire up all event forwarding from pi gateway to browser gateway.
34
+ * Sets piGateway.onEvent and sessionManager.onUnregister.
35
+ */
36
+ export function wireEvents(deps: EventWiringDeps): void {
37
+ const {
38
+ sessionManager,
39
+ eventStore,
40
+ piGateway,
41
+ browserGateway,
42
+ sessionOrderManager,
43
+ pendingForkRegistry,
44
+ directoryService,
45
+ knownSessionIds,
46
+ pendingDashboardSpawns,
47
+ } = deps;
48
+
49
+ // Broadcast placeholder session to browsers when auto-created from early events
50
+ piGateway.onSessionCreated = (sessionId) => {
51
+ const session = sessionManager.get(sessionId);
52
+ if (session) {
53
+ browserGateway.broadcastSessionAdded(session);
54
+ }
55
+ };
56
+
57
+ // Broadcast session ended to browsers when sessions are unregistered
58
+ sessionManager.onUnregister = (sessionId) => {
59
+ const session = sessionManager.get(sessionId);
60
+ if (session) {
61
+ browserGateway.broadcastSessionUpdated(sessionId, {
62
+ status: "ended",
63
+ endedAt: session.endedAt,
64
+ currentTool: null,
65
+ });
66
+ }
67
+ };
68
+
69
+ // Track sessions replaying history — suppress status broadcasts to avoid card flicker
70
+ const replayingSessions = new Set<string>();
71
+ // Sessions whose replay should be discarded (canSkipWipe was true — events already in store)
72
+ const skipReplayInsert = new Set<string>();
73
+ // Debounce flows refresh to prevent infinite loop between sessions in same cwd
74
+ const recentFlowsRefresh = new Set<string>();
75
+
76
+ piGateway.onEvent = (sessionId, msg) => {
77
+ if (msg.type === "event_forward") {
78
+ // When canSkipWipe was true, the event store already has all events —
79
+ // don't insert replayed events again (would cause exponential duplication)
80
+ if (replayingSessions.has(sessionId) && skipReplayInsert.has(sessionId)) {
81
+ // Still process status updates so session state stays accurate
82
+ const updates = extractSessionUpdates(msg.event);
83
+ if (updates) {
84
+ if (updates.flowAgentsDone === -1) {
85
+ const session = sessionManager.get(sessionId);
86
+ updates.flowAgentsDone = (session?.flowAgentsDone ?? 0) + 1;
87
+ }
88
+ sessionManager.update(sessionId, updates);
89
+ }
90
+ // Skip insert + broadcast — events are already in store
91
+ // Still need to continue to the rest of the handler for openspec/stats
92
+ // but those are only for non-replay events, so we can return early
93
+ return;
94
+ }
95
+ const seq = eventStore.insertEvent(sessionId, msg.event);
96
+ // Skip broadcasting during replay — browser gets events via subscribe replay
97
+ if (!replayingSessions.has(sessionId)) {
98
+ const storedEvent = eventStore.getEvent(sessionId, seq) ?? msg.event;
99
+ browserGateway.broadcastEvent(sessionId, seq, storedEvent);
100
+ }
101
+
102
+ const updates = extractSessionUpdates(msg.event);
103
+ if (updates) {
104
+ if (updates.flowAgentsDone === -1) {
105
+ const session = sessionManager.get(sessionId);
106
+ updates.flowAgentsDone = (session?.flowAgentsDone ?? 0) + 1;
107
+ }
108
+ sessionManager.update(sessionId, updates);
109
+ // During replay, accumulate in sessionManager but don't broadcast
110
+ // to avoid rapid status flickers on the session card
111
+ if (!replayingSessions.has(sessionId)) {
112
+ browserGateway.broadcastSessionUpdated(sessionId, updates);
113
+ }
114
+ }
115
+
116
+ // Server-side OpenSpec activity detection from forwarded events
117
+ // Skip during replay — replayed events from a forked session would set stale phase/change
118
+ if (msg.event.eventType === "tool_execution_start" && !replayingSessions.has(sessionId)) {
119
+ const detected = detectOpenSpecActivity(
120
+ msg.event.data.toolName as string,
121
+ msg.event.data.args as Record<string, unknown> | undefined,
122
+ );
123
+ if (detected) {
124
+ const session = sessionManager.get(sessionId);
125
+ const activityUpdates: Partial<DashboardSession> = {};
126
+ let changed = false;
127
+ if (detected.phase && detected.phase !== session?.openspecPhase) {
128
+ activityUpdates.openspecPhase = detected.phase;
129
+ changed = true;
130
+ }
131
+ if (detected.changeName && detected.changeName !== session?.openspecChange) {
132
+ activityUpdates.openspecChange = detected.changeName;
133
+ changed = true;
134
+ }
135
+ if (changed) {
136
+ sessionManager.update(sessionId, activityUpdates);
137
+ const updatedSession = sessionManager.get(sessionId);
138
+ // Auto-attach proposal when changeName is detected (phase is optional —
139
+ // skills loaded via prompt templates don't emit a SKILL.md read event)
140
+ const attachUpdates: Partial<DashboardSession> = {};
141
+ if (updatedSession?.openspecChange && !updatedSession.attachedProposal) {
142
+ attachUpdates.attachedProposal = updatedSession.openspecChange;
143
+ if (!updatedSession.name?.trim()) {
144
+ attachUpdates.name = updatedSession.openspecChange;
145
+ piGateway.sendToSession(sessionId, {
146
+ type: "rename_session",
147
+ sessionId,
148
+ name: updatedSession.openspecChange,
149
+ });
150
+ }
151
+ sessionManager.update(sessionId, attachUpdates);
152
+ }
153
+ if (!replayingSessions.has(sessionId)) {
154
+ browserGateway.broadcastSessionUpdated(sessionId, {
155
+ ...activityUpdates,
156
+ ...attachUpdates,
157
+ });
158
+ }
159
+ }
160
+ }
161
+ }
162
+ if (msg.event.eventType === "agent_end" && !replayingSessions.has(sessionId)) {
163
+ const session = sessionManager.get(sessionId);
164
+ if (session?.openspecPhase || session?.openspecChange) {
165
+ const clearUpdates: Partial<DashboardSession> = {
166
+ openspecPhase: null as any,
167
+ openspecChange: null as any,
168
+ };
169
+ sessionManager.update(sessionId, clearUpdates);
170
+ browserGateway.broadcastSessionUpdated(sessionId, clearUpdates);
171
+ }
172
+ }
173
+
174
+ // Server-side stats extraction from forwarded turn_end events
175
+ if (msg.event.eventType === "turn_end") {
176
+ const ctxUsage = msg.event.data.contextUsage as { tokens: number | null; contextWindow: number } | undefined;
177
+ const stats = extractTurnStats(msg.event.data, ctxUsage);
178
+ if (stats) {
179
+ const session = sessionManager.get(sessionId);
180
+ const statsUpdates: Partial<DashboardSession> = {
181
+ tokensIn: (session?.tokensIn ?? 0) + stats.tokensIn,
182
+ tokensOut: (session?.tokensOut ?? 0) + stats.tokensOut,
183
+ cacheRead: (session?.cacheRead ?? 0) + (stats.turnUsage?.cacheRead ?? 0),
184
+ cacheWrite: (session?.cacheWrite ?? 0) + (stats.turnUsage?.cacheWrite ?? 0),
185
+ cost: (session?.cost ?? 0) + stats.cost,
186
+ };
187
+ if (stats.contextUsage) {
188
+ statsUpdates.contextTokens = stats.contextUsage.tokens;
189
+ statsUpdates.contextWindow = stats.contextUsage.contextWindow;
190
+ }
191
+ sessionManager.update(sessionId, statsUpdates);
192
+
193
+ // Synthesize a stats_update event for client replay compatibility
194
+ const statsEvent = {
195
+ eventType: "stats_update",
196
+ timestamp: Date.now(),
197
+ data: {
198
+ tokensIn: stats.tokensIn,
199
+ tokensOut: stats.tokensOut,
200
+ cost: stats.cost,
201
+ turnUsage: stats.turnUsage,
202
+ contextUsage: stats.contextUsage,
203
+ },
204
+ };
205
+ const statsSeq = eventStore.insertEvent(sessionId, statsEvent);
206
+ if (!replayingSessions.has(sessionId)) {
207
+ browserGateway.broadcastEvent(sessionId, statsSeq, statsEvent);
208
+ browserGateway.broadcastSessionUpdated(sessionId, statsUpdates);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ if (msg.type === "replay_complete") {
215
+ const wasSkipped = skipReplayInsert.has(sessionId);
216
+ replayingSessions.delete(sessionId);
217
+ skipReplayInsert.delete(sessionId);
218
+ // Clear any stale OpenSpec activity state that may have leaked
219
+ // (e.g. from events forwarded before the replay flag was set)
220
+ const preSession = sessionManager.get(sessionId);
221
+ if (preSession?.openspecPhase || preSession?.openspecChange) {
222
+ sessionManager.update(sessionId, {
223
+ openspecPhase: null as any,
224
+ openspecChange: null as any,
225
+ });
226
+ }
227
+ // Broadcast the final accumulated status after replay
228
+ const session = sessionManager.get(sessionId);
229
+ if (session) {
230
+ browserGateway.broadcastSessionUpdated(sessionId, {
231
+ status: session.status,
232
+ currentTool: session.currentTool ?? null,
233
+ openspecPhase: null,
234
+ openspecChange: null,
235
+ });
236
+ }
237
+ // Send replayed events to browser subscribers.
238
+ // During replay, event_forward messages were stored but not broadcast.
239
+ // Subscribers who received session_state_reset need the events to rebuild chat.
240
+ // Skip when canSkipWipe was true — browser already has the events.
241
+ if (!wasSkipped) {
242
+ const storedEvents = eventStore.getEvents(sessionId, 1);
243
+ if (storedEvents.length > 0) {
244
+ browserGateway.sendToSubscribers(sessionId, {
245
+ type: "event_replay",
246
+ sessionId,
247
+ events: storedEvents.map((e) => ({ seq: e.seq, event: e.event })),
248
+ isLast: true,
249
+ } as any);
250
+ }
251
+ }
252
+ }
253
+
254
+ if (msg.type === "session_register") {
255
+ replayingSessions.add(sessionId);
256
+ // Safety timeout: clear replay flag after 5s if replay_complete never arrives
257
+ setTimeout(() => {
258
+ if (replayingSessions.delete(sessionId)) {
259
+ const wasSkipped = skipReplayInsert.delete(sessionId);
260
+ const session = sessionManager.get(sessionId);
261
+ if (session) {
262
+ browserGateway.broadcastSessionUpdated(sessionId, {
263
+ status: session.status,
264
+ currentTool: session.currentTool ?? null,
265
+ });
266
+ }
267
+ // Send any accumulated events to browser subscribers
268
+ if (!wasSkipped) {
269
+ const fallbackEvents = eventStore.getEvents(sessionId, 1);
270
+ if (fallbackEvents.length > 0) {
271
+ browserGateway.sendToSubscribers(sessionId, {
272
+ type: "event_replay",
273
+ sessionId,
274
+ events: fallbackEvents.map((e) => ({ seq: e.seq, event: e.event })),
275
+ isLast: true,
276
+ } as any);
277
+ }
278
+ }
279
+ }
280
+ }, 5_000);
281
+ // Skip wipe if bridge provides eventCount matching the last known entry count.
282
+ // This avoids full replay cascade when bridge simply reconnects.
283
+ // Compare entry counts (apples to apples) — not entries vs stored events.
284
+ const session = sessionManager.get(sessionId);
285
+ const lastEntryCount = session?.lastEntryCount;
286
+ const canSkipWipe = msg.eventCount !== undefined && lastEntryCount !== undefined && msg.eventCount === lastEntryCount && eventStore.hasEvents(sessionId);
287
+ // Store the bridge's entry count for future reconnect comparisons
288
+ if (msg.eventCount !== undefined) {
289
+ sessionManager.update(sessionId, { lastEntryCount: msg.eventCount });
290
+ }
291
+ if (!canSkipWipe) {
292
+ eventStore.deleteEventsForSession(sessionId);
293
+ browserGateway.broadcastSessionStateReset(sessionId);
294
+ } else {
295
+ // Mark this session so replayed events are not re-inserted into the store
296
+ skipReplayInsert.add(sessionId);
297
+ }
298
+ sessionManager.update(sessionId, { hidden: false, dataUnavailable: false });
299
+
300
+ if (msg.sessionFile) {
301
+ for (const other of sessionManager.listAll()) {
302
+ if (other.id !== sessionId && other.sessionFile === msg.sessionFile) {
303
+ sessionManager.update(other.id, { sessionFile: undefined });
304
+ browserGateway.broadcastSessionUpdated(other.id, { sessionFile: null });
305
+ }
306
+ }
307
+ }
308
+
309
+ // Dedup: clean up ghost sessions in the same cwd that were auto-created
310
+ // by duplicate bridge connections (e.g. extension loaded twice).
311
+ // A ghost is active, has no sessionFile, no events, is not connected
312
+ // to the pi-gateway, and was created very recently.
313
+ const now = Date.now();
314
+ for (const other of sessionManager.listAll()) {
315
+ if (
316
+ other.id !== sessionId &&
317
+ other.cwd === msg.cwd &&
318
+ other.status !== "ended" &&
319
+ !other.sessionFile &&
320
+ !piGateway.isSessionConnected(other.id) &&
321
+ !eventStore.hasEvents(other.id) &&
322
+ Math.abs(now - other.startedAt) < 30_000
323
+ ) {
324
+ console.error(`[event-wiring] Cleaning up ghost session ${other.id} (dup of ${sessionId} in ${msg.cwd})`);
325
+ sessionManager.unregister(other.id);
326
+ browserGateway.broadcastSessionRemoved(other.id);
327
+ }
328
+ }
329
+
330
+ browserGateway.headlessPidRegistry.linkSession(sessionId, msg.cwd);
331
+
332
+ const isNewSession = !knownSessionIds.has(sessionId);
333
+ knownSessionIds.add(sessionId);
334
+ const pendingCount = pendingDashboardSpawns.get(msg.cwd) ?? 0;
335
+ if (pendingCount > 0 && isNewSession) {
336
+ if (pendingCount <= 1) pendingDashboardSpawns.delete(msg.cwd);
337
+ else pendingDashboardSpawns.set(msg.cwd, pendingCount - 1);
338
+ sessionManager.update(sessionId, { source: "dashboard" });
339
+ browserGateway.broadcastSessionUpdated(sessionId, { source: "dashboard" });
340
+ if (msg.sessionFile) {
341
+ try {
342
+ writeSessionMeta(msg.sessionFile, { source: "dashboard" });
343
+ } catch { /* best-effort */ }
344
+ }
345
+ }
346
+
347
+ const forkParent = pendingForkRegistry.consumeFork(msg.cwd);
348
+ sessionOrderManager.insert(msg.cwd, sessionId);
349
+
350
+ if (forkParent) {
351
+ const session = sessionManager.get(sessionId);
352
+ if (session && !session.attachedProposal) {
353
+ // Use the actual parent session's proposal, not any random ended session
354
+ const parent = sessionManager.get(forkParent);
355
+ if (parent?.attachedProposal) {
356
+ sessionManager.update(sessionId, { attachedProposal: parent.attachedProposal });
357
+ }
358
+ }
359
+ }
360
+
361
+ const validIds = new Set(sessionManager.listAll().filter((s) => s.cwd === msg.cwd).map((s) => s.id));
362
+ const order = sessionOrderManager.getOrder(msg.cwd, validIds);
363
+ browserGateway.broadcastToAll({ type: "sessions_reordered", cwd: msg.cwd, sessionIds: order });
364
+
365
+ const updatedSession = sessionManager.get(sessionId);
366
+ if (updatedSession) {
367
+ browserGateway.broadcastSessionAdded(updatedSession);
368
+ }
369
+
370
+ const isNewCwd = !sessionManager.listAll().some(
371
+ (s) => s.id !== sessionId && s.cwd === msg.cwd,
372
+ );
373
+ if (isNewCwd) {
374
+ directoryService.onDirectoryAdded(msg.cwd).then(({ sessions, openspecData }) => {
375
+ for (const hist of sessions) {
376
+ if (!sessionManager.get(hist.id)) {
377
+ sessionManager.register({
378
+ id: hist.id,
379
+ cwd: hist.cwd,
380
+ name: hist.name,
381
+ source: "tui",
382
+ sessionFile: hist.sessionFile,
383
+ sessionDir: hist.sessionDir,
384
+ firstMessage: hist.firstMessage,
385
+ startedAt: hist.startedAt,
386
+ });
387
+ sessionManager.unregister(hist.id);
388
+ sessionManager.update(hist.id, { hidden: true });
389
+ const s = sessionManager.get(hist.id);
390
+ if (s) browserGateway.broadcastSessionAdded(s);
391
+ }
392
+ }
393
+ browserGateway.broadcastToAll({
394
+ type: "openspec_update",
395
+ cwd: msg.cwd,
396
+ data: openspecData,
397
+ } as any);
398
+ }).catch(() => {});
399
+ }
400
+
401
+ const pendingResume = browserGateway.pendingResumeRegistry.consume(msg.cwd);
402
+ if (pendingResume) {
403
+ piGateway.sendToSession(sessionId, {
404
+ type: "send_prompt",
405
+ sessionId,
406
+ text: pendingResume.text,
407
+ images: pendingResume.images,
408
+ });
409
+ sessionManager.update(sessionId, { resuming: false });
410
+ browserGateway.broadcastSessionUpdated(sessionId, { resuming: false });
411
+ }
412
+ }
413
+
414
+ if (msg.type === "first_message_update") {
415
+ sessionManager.update(sessionId, { firstMessage: msg.firstMessage });
416
+ browserGateway.broadcastSessionUpdated(sessionId, { firstMessage: msg.firstMessage });
417
+ }
418
+
419
+ if (msg.type === "session_unregister") {
420
+ browserGateway.broadcastSessionRemoved(sessionId);
421
+ }
422
+
423
+ if (msg.type === "commands_list") {
424
+ browserGateway.sendToSubscribers(sessionId, {
425
+ type: "commands_list",
426
+ sessionId,
427
+ commands: msg.commands,
428
+ });
429
+ }
430
+
431
+ if (msg.type === "flows_list") {
432
+ browserGateway.sendToSubscribers(sessionId, {
433
+ type: "flows_list",
434
+ sessionId,
435
+ flows: msg.flows,
436
+ });
437
+
438
+ // Tell other connected sessions in the same cwd to rediscover flows
439
+ // (debounced to avoid infinite loop: A→refresh B→B sends flows→refresh A→...)
440
+ if (!recentFlowsRefresh.has(sessionId)) {
441
+ recentFlowsRefresh.add(sessionId);
442
+ setTimeout(() => recentFlowsRefresh.delete(sessionId), 5_000);
443
+ const session = sessionManager.get(sessionId);
444
+ if (session) {
445
+ for (const sid of piGateway.getConnectedSessionIds()) {
446
+ if (sid === sessionId || recentFlowsRefresh.has(sid)) continue;
447
+ const other = sessionManager.get(sid);
448
+ if (other && other.cwd === session.cwd) {
449
+ piGateway.sendToSession(sid, { type: "request_flows_refresh", sessionId: sid });
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ if (msg.type === "git_info_update") {
457
+ const gitUpdates = {
458
+ gitBranch: msg.gitBranch,
459
+ gitBranchUrl: msg.gitBranchUrl,
460
+ gitPrNumber: msg.gitPrNumber,
461
+ gitPrUrl: msg.gitPrUrl,
462
+ };
463
+ sessionManager.update(sessionId, gitUpdates);
464
+ browserGateway.broadcastSessionUpdated(sessionId, gitUpdates);
465
+ }
466
+
467
+ if (msg.type === "files_list") {
468
+ browserGateway.sendToSubscribers(sessionId, {
469
+ type: "files_list",
470
+ sessionId,
471
+ query: msg.query,
472
+ files: msg.files,
473
+ });
474
+ }
475
+
476
+ if (msg.type === "models_list") {
477
+ // Broadcast to all browsers (not just subscribers) so model selector
478
+ // is available even before the user opens the session
479
+ browserGateway.broadcastToAll({
480
+ type: "models_list",
481
+ sessionId,
482
+ models: msg.models,
483
+ } as any);
484
+ }
485
+
486
+ if (msg.type === "roles_list") {
487
+ browserGateway.broadcastToAll({
488
+ type: "roles_list",
489
+ sessionId,
490
+ roles: (msg as any).roles,
491
+ presets: (msg as any).presets,
492
+ activePreset: (msg as any).activePreset,
493
+ } as any);
494
+ }
495
+
496
+ if (msg.type === "model_update") {
497
+ const modelUpdates: Partial<DashboardSession> = {
498
+ model: msg.model,
499
+ };
500
+ if (msg.thinkingLevel !== undefined) {
501
+ modelUpdates.thinkingLevel = msg.thinkingLevel;
502
+ }
503
+ sessionManager.update(sessionId, modelUpdates);
504
+ browserGateway.broadcastSessionUpdated(sessionId, modelUpdates);
505
+ }
506
+
507
+ if (msg.type === "extension_ui_request") {
508
+ const tracked = browserGateway.trackUiRequest(sessionId, msg.requestId, msg.method, msg.params);
509
+ if (tracked !== false) {
510
+ browserGateway.sendToSubscribers(sessionId, {
511
+ type: "extension_ui_request",
512
+ sessionId,
513
+ requestId: msg.requestId,
514
+ method: msg.method,
515
+ params: msg.params,
516
+ });
517
+ }
518
+ }
519
+
520
+ if (msg.type === "extension_ui_dismiss") {
521
+ browserGateway.sendToSubscribers(sessionId, {
522
+ type: "ui_dismiss",
523
+ sessionId,
524
+ requestId: msg.requestId,
525
+ });
526
+ }
527
+
528
+ if (msg.type === "session_name_update") {
529
+ const nameUpdates = { name: msg.name || undefined };
530
+ sessionManager.update(sessionId, nameUpdates);
531
+ browserGateway.broadcastSessionUpdated(sessionId, nameUpdates);
532
+ }
533
+
534
+ if (msg.type === "spawn_new_session") {
535
+ spawnPiSession(msg.cwd, { strategy: loadConfig().spawnStrategy }).then((result) => {
536
+ if (result.process && result.pid) {
537
+ browserGateway.headlessPidRegistry.register(result.pid, msg.cwd, result.process);
538
+ }
539
+ browserGateway.broadcastToAll({
540
+ type: "spawn_result",
541
+ cwd: msg.cwd,
542
+ success: result.success,
543
+ message: result.message,
544
+ } as any);
545
+ }).catch(() => { /* ignore spawn errors */ });
546
+ }
547
+
548
+ if (msg.type === "sessions_list") {
549
+ for (const piSession of msg.sessions) {
550
+ const existing = sessionManager.get(piSession.id);
551
+ if (!existing) {
552
+ sessionManager.register({
553
+ id: piSession.id,
554
+ cwd: piSession.cwd,
555
+ name: piSession.name,
556
+ source: "unknown",
557
+ sessionFile: piSession.path,
558
+ sessionDir: piSession.cwd,
559
+ firstMessage: piSession.firstMessage,
560
+ });
561
+ sessionManager.unregister(piSession.id);
562
+ } else if (existing.sessionFile !== piSession.path) {
563
+ sessionManager.update(piSession.id, {
564
+ sessionFile: piSession.path,
565
+ sessionDir: piSession.cwd,
566
+ });
567
+ }
568
+ }
569
+ browserGateway.broadcastToAll({
570
+ type: "sessions_list",
571
+ sessionId,
572
+ cwd: msg.cwd,
573
+ sessions: msg.sessions,
574
+ });
575
+ }
576
+
577
+ // Forward process list from bridge to subscribed browsers
578
+ if (msg.type === "process_list") {
579
+ // Store on session so new subscribers get current processes
580
+ sessionManager.update(sessionId, { processes: msg.processes });
581
+ browserGateway.sendToSubscribers(sessionId, {
582
+ type: "process_list_update",
583
+ sessionId,
584
+ processes: msg.processes,
585
+ });
586
+ }
587
+
588
+ };
589
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Ensures the pi-dashboard bridge extension is registered in pi's global settings
3
+ * so all pi sessions (headless or interactive) can discover and load it.
4
+ *
5
+ * On bundled installs (Electron DEB/DMG), the extension lives inside the server
6
+ * bundle at packages/extension/. This module detects the bundled extension path
7
+ * and adds it to ~/.pi/agent/settings.json if not already present.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ /** Locate the bundled extension package directory, if it exists. */
16
+ function findBundledExtension(): string | null {
17
+ // From packages/server/src/ → ../extension/
18
+ const candidate = path.resolve(__dirname, "..", "..", "extension");
19
+ if (
20
+ fs.existsSync(candidate) &&
21
+ fs.existsSync(path.join(candidate, "package.json"))
22
+ ) {
23
+ return candidate;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Read ~/.pi/agent/settings.json (returns {} if missing/invalid). */
29
+ function readSettings(settingsPath: string): Record<string, unknown> {
30
+ try {
31
+ if (!fs.existsSync(settingsPath)) return {};
32
+ const raw = fs.readFileSync(settingsPath, "utf-8").trim();
33
+ if (!raw) return {};
34
+ return JSON.parse(raw);
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ /** Write settings back to disk atomically. */
41
+ function writeSettings(settingsPath: string, data: Record<string, unknown>): void {
42
+ const dir = path.dirname(settingsPath);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ const tmp = settingsPath + ".tmp";
45
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
46
+ fs.renameSync(tmp, settingsPath);
47
+ }
48
+
49
+ /**
50
+ * Ensure the bridge extension is registered in pi's global settings.
51
+ * No-op if:
52
+ * - No bundled extension found (development mode uses package.json pi field)
53
+ * - Extension path already present in settings
54
+ */
55
+ export function ensureBridgeExtensionRegistered(): void {
56
+ const extPath = findBundledExtension();
57
+ if (!extPath) return; // Not bundled — development mode
58
+
59
+ const settingsPath = path.join(
60
+ process.env.HOME || process.env.USERPROFILE || "",
61
+ ".pi",
62
+ "agent",
63
+ "settings.json",
64
+ );
65
+
66
+ const settings = readSettings(settingsPath);
67
+ const packages = Array.isArray(settings.packages) ? settings.packages as string[] : [];
68
+
69
+ // Check if already registered (exact path match)
70
+ if (packages.includes(extPath)) return;
71
+
72
+ // Remove any stale dashboard extension paths (different install location)
73
+ const cleaned = packages.filter((p) => {
74
+ if (typeof p !== "string") return true;
75
+ // Keep non-local-path entries (npm:, git:, etc.)
76
+ // Local paths start with / (Unix) or X:\ (Windows)
77
+ const isLocalPath = p.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(p);
78
+ if (!isLocalPath) return true;
79
+ // Remove stale dashboard extension paths
80
+ return !p.includes("pi-dashboard");
81
+ });
82
+
83
+ cleaned.push(extPath);
84
+ settings.packages = cleaned;
85
+
86
+ try {
87
+ writeSettings(settingsPath, settings);
88
+ console.log(`[dashboard] Registered bridge extension in pi settings: ${extPath}`);
89
+ } catch (err) {
90
+ console.error("[dashboard] Failed to register bridge extension:", err);
91
+ }
92
+ }