@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,330 @@
1
+ /**
2
+ * REST API wrappers for session control operations.
3
+ * These expose WebSocket-only operations as HTTP endpoints
4
+ * for use by skills, scripts, and external tooling.
5
+ */
6
+ import type { FastifyInstance } from "fastify";
7
+ import type { SessionManager } from "./memory-session-manager.js";
8
+ import type { PiGateway } from "./pi-gateway.js";
9
+ import type { BrowserGateway } from "./browser-gateway.js";
10
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
+ import { spawnPiSession } from "./process-manager.js";
12
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
+ import type { PendingForkRegistry } from "./pending-fork-registry.js";
14
+
15
+ export interface SessionApiDeps {
16
+ sessionManager: SessionManager;
17
+ piGateway: PiGateway;
18
+ browserGateway: BrowserGateway;
19
+ pendingForkRegistry?: PendingForkRegistry;
20
+ pendingDashboardSpawns?: Map<string, number>;
21
+ }
22
+
23
+ type IdParams = { Params: { id: string } };
24
+
25
+ /** Helper: validate session exists, return it or send error response */
26
+ function getSessionOrFail(sessionManager: SessionManager, id: string): { session: any } | { error: ApiResponse } {
27
+ const session = sessionManager.get(id);
28
+ if (!session) return { error: { success: false, error: "session not found" } };
29
+ return { session };
30
+ }
31
+
32
+ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
33
+ const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns } = deps;
34
+
35
+ // POST /api/session/:id/prompt
36
+ fastify.post<IdParams & { Body: { text?: string; images?: any[] } }>(
37
+ "/api/session/:id/prompt",
38
+ async (request, reply) => {
39
+ const { id } = request.params;
40
+ const { text, images } = request.body ?? {};
41
+ if (!text) {
42
+ reply.code(400);
43
+ return { success: false, error: "text is required" } satisfies ApiResponse;
44
+ }
45
+ const result = getSessionOrFail(sessionManager, id);
46
+ if ("error" in result) {
47
+ reply.code(404);
48
+ return result.error;
49
+ }
50
+ const sent = piGateway.sendToSession(id, {
51
+ type: "send_prompt",
52
+ sessionId: id,
53
+ text,
54
+ images,
55
+ });
56
+ if (!sent) {
57
+ reply.code(502);
58
+ return { success: false, error: "no bridge connection for session" } satisfies ApiResponse;
59
+ }
60
+ return { success: true } satisfies ApiResponse;
61
+ },
62
+ );
63
+
64
+ // POST /api/session/:id/abort
65
+ fastify.post<IdParams>(
66
+ "/api/session/:id/abort",
67
+ async (request, reply) => {
68
+ const { id } = request.params;
69
+ const result = getSessionOrFail(sessionManager, id);
70
+ if ("error" in result) {
71
+ reply.code(404);
72
+ return result.error;
73
+ }
74
+ piGateway.sendToSession(id, { type: "abort", sessionId: id });
75
+ return { success: true } satisfies ApiResponse;
76
+ },
77
+ );
78
+
79
+ // POST /api/session/:id/shutdown
80
+ fastify.post<IdParams>(
81
+ "/api/session/:id/shutdown",
82
+ async (request, reply) => {
83
+ const { id } = request.params;
84
+ const result = getSessionOrFail(sessionManager, id);
85
+ if ("error" in result) {
86
+ reply.code(404);
87
+ return result.error;
88
+ }
89
+ piGateway.sendToSession(id, { type: "shutdown", sessionId: id });
90
+ browserGateway.headlessPidRegistry.killBySessionId(id);
91
+ sessionManager.unregister(id);
92
+ browserGateway.broadcastSessionRemoved(id);
93
+ return { success: true } satisfies ApiResponse;
94
+ },
95
+ );
96
+
97
+ // POST /api/session/:id/rename
98
+ fastify.post<IdParams & { Body: { name?: string } }>(
99
+ "/api/session/:id/rename",
100
+ async (request, reply) => {
101
+ const { id } = request.params;
102
+ const { name } = request.body ?? {};
103
+ if (name === undefined) {
104
+ reply.code(400);
105
+ return { success: false, error: "name is required" } satisfies ApiResponse;
106
+ }
107
+ const result = getSessionOrFail(sessionManager, id);
108
+ if ("error" in result) {
109
+ reply.code(404);
110
+ return result.error;
111
+ }
112
+ const updates = { name: name || undefined };
113
+ sessionManager.update(id, updates);
114
+ browserGateway.broadcastSessionUpdated(id, updates);
115
+ piGateway.sendToSession(id, { type: "rename_session", sessionId: id, name });
116
+ return { success: true } satisfies ApiResponse;
117
+ },
118
+ );
119
+
120
+ // POST /api/session/:id/hide
121
+ fastify.post<IdParams>(
122
+ "/api/session/:id/hide",
123
+ async (request, reply) => {
124
+ const { id } = request.params;
125
+ const result = getSessionOrFail(sessionManager, id);
126
+ if ("error" in result) {
127
+ reply.code(404);
128
+ return result.error;
129
+ }
130
+ const updates = { hidden: true };
131
+ sessionManager.update(id, updates);
132
+ browserGateway.broadcastSessionUpdated(id, updates);
133
+ return { success: true } satisfies ApiResponse;
134
+ },
135
+ );
136
+
137
+ // POST /api/session/:id/unhide
138
+ fastify.post<IdParams>(
139
+ "/api/session/:id/unhide",
140
+ async (request, reply) => {
141
+ const { id } = request.params;
142
+ const result = getSessionOrFail(sessionManager, id);
143
+ if ("error" in result) {
144
+ reply.code(404);
145
+ return result.error;
146
+ }
147
+ const updates = { hidden: false };
148
+ sessionManager.update(id, updates);
149
+ browserGateway.broadcastSessionUpdated(id, updates);
150
+ return { success: true } satisfies ApiResponse;
151
+ },
152
+ );
153
+
154
+ // POST /api/session/spawn
155
+ fastify.post<{ Body: { cwd?: string } }>(
156
+ "/api/session/spawn",
157
+ async (request, reply) => {
158
+ const { cwd } = request.body ?? {};
159
+ if (!cwd) {
160
+ reply.code(400);
161
+ return { success: false, error: "cwd is required" } satisfies ApiResponse;
162
+ }
163
+ const config = loadConfig();
164
+ const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
165
+ if (spawnResult.process && spawnResult.pid) {
166
+ browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
167
+ }
168
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
169
+ pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
170
+ }
171
+ if (!spawnResult.success) {
172
+ reply.code(500);
173
+ return { success: false, error: spawnResult.message } satisfies ApiResponse;
174
+ }
175
+ return { success: true, data: { message: spawnResult.message } } satisfies ApiResponse;
176
+ },
177
+ );
178
+
179
+ // POST /api/session/:id/resume
180
+ fastify.post<IdParams & { Body: { mode?: string } }>(
181
+ "/api/session/:id/resume",
182
+ async (request, reply) => {
183
+ const { id } = request.params;
184
+ const { mode } = request.body ?? {};
185
+ if (mode !== "continue" && mode !== "fork") {
186
+ reply.code(400);
187
+ return { success: false, error: "mode must be 'continue' or 'fork'" } satisfies ApiResponse;
188
+ }
189
+ const result = getSessionOrFail(sessionManager, id);
190
+ if ("error" in result) {
191
+ reply.code(404);
192
+ return result.error;
193
+ }
194
+ const session = result.session;
195
+ if (!session.sessionFile) {
196
+ reply.code(400);
197
+ return { success: false, error: "session file is unknown" } satisfies ApiResponse;
198
+ }
199
+ if (mode === "continue" && session.status !== "ended") {
200
+ reply.code(409);
201
+ return { success: false, error: "session is already active" } satisfies ApiResponse;
202
+ }
203
+ if (session.resuming) {
204
+ reply.code(409);
205
+ return { success: false, error: "session is already being resumed" } satisfies ApiResponse;
206
+ }
207
+ if (mode === "fork" && pendingForkRegistry) {
208
+ pendingForkRegistry.recordFork(session.cwd, id);
209
+ }
210
+ const config = loadConfig();
211
+ const spawnResult = await spawnPiSession(session.cwd, {
212
+ sessionFile: session.sessionFile,
213
+ mode,
214
+ strategy: config.spawnStrategy,
215
+ });
216
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
217
+ pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
218
+ }
219
+ if (!spawnResult.success) {
220
+ reply.code(500);
221
+ return { success: false, error: spawnResult.message } satisfies ApiResponse;
222
+ }
223
+ return { success: true, data: { message: spawnResult.message } } satisfies ApiResponse;
224
+ },
225
+ );
226
+
227
+ // POST /api/session/:id/flow-control
228
+ fastify.post<IdParams & { Body: { action?: string } }>(
229
+ "/api/session/:id/flow-control",
230
+ async (request, reply) => {
231
+ const { id } = request.params;
232
+ const { action } = request.body ?? {};
233
+ if (action !== "abort" && action !== "toggle_autonomous") {
234
+ reply.code(400);
235
+ return { success: false, error: "action must be 'abort' or 'toggle_autonomous'" } satisfies ApiResponse;
236
+ }
237
+ const result = getSessionOrFail(sessionManager, id);
238
+ if ("error" in result) {
239
+ reply.code(404);
240
+ return result.error;
241
+ }
242
+ piGateway.sendToSession(id, { type: "flow_control", sessionId: id, action });
243
+ return { success: true } satisfies ApiResponse;
244
+ },
245
+ );
246
+
247
+ // POST /api/session/:id/model
248
+ fastify.post<IdParams & { Body: { provider?: string; modelId?: string } }>(
249
+ "/api/session/:id/model",
250
+ async (request, reply) => {
251
+ const { id } = request.params;
252
+ const { provider, modelId } = request.body ?? {};
253
+ if (!provider || !modelId) {
254
+ reply.code(400);
255
+ return { success: false, error: "provider and modelId are required" } satisfies ApiResponse;
256
+ }
257
+ const result = getSessionOrFail(sessionManager, id);
258
+ if ("error" in result) {
259
+ reply.code(404);
260
+ return result.error;
261
+ }
262
+ piGateway.sendToSession(id, { type: "set_model", sessionId: id, provider, modelId });
263
+ return { success: true } satisfies ApiResponse;
264
+ },
265
+ );
266
+
267
+ // POST /api/session/:id/thinking-level
268
+ fastify.post<IdParams & { Body: { level?: string } }>(
269
+ "/api/session/:id/thinking-level",
270
+ async (request, reply) => {
271
+ const { id } = request.params;
272
+ const { level } = request.body ?? {};
273
+ if (!level) {
274
+ reply.code(400);
275
+ return { success: false, error: "level is required" } satisfies ApiResponse;
276
+ }
277
+ const result = getSessionOrFail(sessionManager, id);
278
+ if ("error" in result) {
279
+ reply.code(404);
280
+ return result.error;
281
+ }
282
+ piGateway.sendToSession(id, { type: "set_thinking_level", sessionId: id, level });
283
+ return { success: true } satisfies ApiResponse;
284
+ },
285
+ );
286
+
287
+ // POST /api/session/:id/attach-proposal
288
+ fastify.post<IdParams & { Body: { changeName?: string } }>(
289
+ "/api/session/:id/attach-proposal",
290
+ async (request, reply) => {
291
+ const { id } = request.params;
292
+ const { changeName } = request.body ?? {};
293
+ if (!changeName) {
294
+ reply.code(400);
295
+ return { success: false, error: "changeName is required" } satisfies ApiResponse;
296
+ }
297
+ const result = getSessionOrFail(sessionManager, id);
298
+ if ("error" in result) {
299
+ reply.code(404);
300
+ return result.error;
301
+ }
302
+ const updates: Record<string, unknown> = { attachedProposal: changeName };
303
+ const session = result.session;
304
+ if (!session.name?.trim()) {
305
+ updates.name = changeName;
306
+ piGateway.sendToSession(id, { type: "rename_session", sessionId: id, name: changeName });
307
+ }
308
+ sessionManager.update(id, updates);
309
+ browserGateway.broadcastSessionUpdated(id, updates);
310
+ return { success: true } satisfies ApiResponse;
311
+ },
312
+ );
313
+
314
+ // POST /api/session/:id/detach-proposal
315
+ fastify.post<IdParams>(
316
+ "/api/session/:id/detach-proposal",
317
+ async (request, reply) => {
318
+ const { id } = request.params;
319
+ const result = getSessionOrFail(sessionManager, id);
320
+ if ("error" in result) {
321
+ reply.code(404);
322
+ return result.error;
323
+ }
324
+ const updates = { attachedProposal: null, openspecPhase: null, openspecChange: null };
325
+ sessionManager.update(id, updates);
326
+ browserGateway.broadcastSessionUpdated(id, updates);
327
+ return { success: true } satisfies ApiResponse;
328
+ },
329
+ );
330
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Session bootstrap: discovers sessions from known directories and starts OpenSpec polling.
3
+ * Called during server startup (async, non-blocking).
4
+ */
5
+ import type { SessionManager } from "./memory-session-manager.js";
6
+ import type { BrowserGateway } from "./browser-gateway.js";
7
+ import type { DirectoryService } from "./directory-service.js";
8
+ import { extractSessionStats } from "./session-stats-reader.js";
9
+
10
+ export interface SessionBootstrapDeps {
11
+ sessionManager: SessionManager;
12
+ browserGateway: BrowserGateway;
13
+ directoryService: DirectoryService;
14
+ }
15
+
16
+ /**
17
+ * Discover sessions from all known directories and broadcast them.
18
+ * Runs async and does not block server startup.
19
+ */
20
+ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps): Promise<void> {
21
+ const { sessionManager, browserGateway, directoryService } = deps;
22
+
23
+ try {
24
+ const dirs = directoryService.knownDirectories();
25
+ for (const cwd of dirs) {
26
+ const discovered = directoryService.discoverSessions(cwd);
27
+ for (const hist of discovered) {
28
+ if (!sessionManager.get(hist.id)) {
29
+ let contextTokens: number | undefined;
30
+ let contextWindow: number | undefined;
31
+ let model: string | undefined;
32
+ if (hist.sessionFile) {
33
+ try {
34
+ const stats = extractSessionStats(hist.sessionFile);
35
+ if (stats) {
36
+ contextTokens = stats.lastTotalTokens;
37
+ contextWindow = stats.contextWindow;
38
+ model = stats.model;
39
+ }
40
+ } catch { /* ignore */ }
41
+ }
42
+ sessionManager.restore({
43
+ id: hist.id,
44
+ cwd: hist.cwd,
45
+ name: hist.name,
46
+ source: "tui",
47
+ status: "ended",
48
+ startedAt: hist.startedAt,
49
+ sessionFile: hist.sessionFile,
50
+ sessionDir: hist.sessionDir,
51
+ firstMessage: hist.firstMessage,
52
+ hidden: true,
53
+ dataUnavailable: true,
54
+ model,
55
+ contextTokens,
56
+ contextWindow,
57
+ });
58
+ const session = sessionManager.get(hist.id);
59
+ if (session) browserGateway.broadcastSessionAdded(session);
60
+ }
61
+ }
62
+ }
63
+ } catch (err) {
64
+ console.error("[dashboard] Session discovery failed:", err);
65
+ }
66
+
67
+ // Start OpenSpec polling, broadcast changes to browsers
68
+ directoryService.startPolling((cwd, data) => {
69
+ browserGateway.broadcastToAll({
70
+ type: "openspec_update",
71
+ cwd,
72
+ data,
73
+ } as any);
74
+ });
75
+
76
+ // Initial OpenSpec poll for all known directories
77
+ await Promise.all(
78
+ directoryService.knownDirectories().map((cwd) => directoryService.refreshOpenSpec(cwd)),
79
+ );
80
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Session diff extraction — scans session events for file changes
3
+ * and optionally enriches with git diffs.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { resolve, relative, isAbsolute } from "node:path";
7
+ import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
8
+ import type { FileChangeEvent, FileDiffEntry, EditOperation } from "@blackbelt-technology/pi-dashboard-shared/diff-types.js";
9
+ import { isGitRepo } from "./git-operations.js";
10
+
11
+ const GIT_TIMEOUT = 15_000;
12
+ const MAX_MESSAGE_LENGTH = 120;
13
+
14
+ const WRITE_EDIT_TOOLS = new Set(["write", "edit"]);
15
+
16
+ /**
17
+ * Extract file change events from session events.
18
+ * Scans tool_execution_start events for Write/Edit tools,
19
+ * groups by file path, and includes preceding assistant message as context.
20
+ */
21
+ export function extractFileChanges(events: DashboardEvent[], cwd: string): FileDiffEntry[] {
22
+ const fileMap = new Map<string, FileChangeEvent[]>();
23
+ let lastAssistantMessage: string | undefined;
24
+
25
+ for (const event of events) {
26
+ // Track most recent assistant message for context
27
+ if (event.eventType === "message_end") {
28
+ const msg = event.data.message as any;
29
+ if (msg?.role === "assistant") {
30
+ const content = Array.isArray(msg.content)
31
+ ? msg.content
32
+ .filter((c: any) => c?.type === "text")
33
+ .map((c: any) => c.text)
34
+ .join("")
35
+ : typeof msg.content === "string" ? msg.content : "";
36
+ if (content) {
37
+ lastAssistantMessage = content.length > MAX_MESSAGE_LENGTH
38
+ ? content.slice(0, MAX_MESSAGE_LENGTH) + "..."
39
+ : content;
40
+ }
41
+ }
42
+ }
43
+
44
+ if (event.eventType !== "tool_execution_start") continue;
45
+
46
+ const toolName = (event.data.toolName as string || "").toLowerCase();
47
+ if (!WRITE_EDIT_TOOLS.has(toolName)) continue;
48
+
49
+ const args = event.data.args as Record<string, unknown> | undefined;
50
+ if (!args) continue;
51
+
52
+ const rawPath = (args.path || args.file_path) as string | undefined;
53
+ if (!rawPath) continue;
54
+
55
+ // Resolve and filter paths outside cwd
56
+ const filePath = normalizePath(rawPath, cwd);
57
+ if (!filePath) continue;
58
+
59
+ const changeEvent: FileChangeEvent = {
60
+ type: toolName === "write" ? "write" : "edit",
61
+ timestamp: event.timestamp,
62
+ message: lastAssistantMessage,
63
+ };
64
+
65
+ if (toolName === "write") {
66
+ changeEvent.content = args.content as string | undefined;
67
+ } else {
68
+ changeEvent.edits = args.edits as EditOperation[] | undefined;
69
+ }
70
+
71
+ const existing = fileMap.get(filePath);
72
+ if (existing) {
73
+ existing.push(changeEvent);
74
+ } else {
75
+ fileMap.set(filePath, [changeEvent]);
76
+ }
77
+ }
78
+
79
+ // Build result, sorted by path, changes sorted by timestamp
80
+ const result: FileDiffEntry[] = [];
81
+ for (const [path, changes] of fileMap) {
82
+ changes.sort((a, b) => a.timestamp - b.timestamp);
83
+ result.push({ path, changes });
84
+ }
85
+ result.sort((a, b) => a.path.localeCompare(b.path));
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Normalize a file path relative to cwd.
92
+ * Returns null if the path is outside cwd.
93
+ */
94
+ function normalizePath(rawPath: string, cwd: string): string | null {
95
+ let absPath: string;
96
+ if (isAbsolute(rawPath)) {
97
+ absPath = rawPath;
98
+ } else {
99
+ absPath = resolve(cwd, rawPath);
100
+ }
101
+
102
+ // Check if the resolved path is inside cwd
103
+ const rel = relative(cwd, absPath);
104
+ if (rel.startsWith("..") || isAbsolute(rel)) {
105
+ return null;
106
+ }
107
+
108
+ return rel;
109
+ }
110
+
111
+ /**
112
+ * Enrich file entries with git diff output.
113
+ * Runs `git diff HEAD -- <path>` for each file when in a git repo.
114
+ * Returns gracefully on any git errors.
115
+ */
116
+ export function enrichWithGitDiff(
117
+ cwd: string,
118
+ files: FileDiffEntry[],
119
+ ): { enrichedFiles: FileDiffEntry[]; isGitRepo: boolean } {
120
+ let gitAvailable = false;
121
+ try {
122
+ gitAvailable = isGitRepo(cwd);
123
+ } catch {
124
+ return { enrichedFiles: files, isGitRepo: false };
125
+ }
126
+
127
+ if (!gitAvailable) {
128
+ return { enrichedFiles: files, isGitRepo: false };
129
+ }
130
+
131
+ const enriched = files.map((file) => {
132
+ try {
133
+ const diff = execSync(`git diff HEAD -- ${JSON.stringify(file.path)}`, {
134
+ cwd,
135
+ encoding: "utf-8",
136
+ stdio: ["pipe", "pipe", "pipe"],
137
+ timeout: GIT_TIMEOUT,
138
+ }).trim();
139
+
140
+ if (diff) {
141
+ return { ...file, gitDiff: diff };
142
+ }
143
+
144
+ // No diff from HEAD — try untracked (new file)
145
+ const status = execSync(`git status --porcelain -- ${JSON.stringify(file.path)}`, {
146
+ cwd,
147
+ encoding: "utf-8",
148
+ stdio: ["pipe", "pipe", "pipe"],
149
+ timeout: GIT_TIMEOUT,
150
+ }).trim();
151
+
152
+ if (status.startsWith("??") || status.startsWith("A")) {
153
+ // Untracked or newly added — generate synthetic diff
154
+ const content = execSync(`cat ${JSON.stringify(resolve(cwd, file.path))}`, {
155
+ encoding: "utf-8",
156
+ stdio: ["pipe", "pipe", "pipe"],
157
+ timeout: GIT_TIMEOUT,
158
+ });
159
+ const lines = content.split("\n");
160
+ const diffLines = [
161
+ `diff --git a/${file.path} b/${file.path}`,
162
+ "new file mode 100644",
163
+ `--- /dev/null`,
164
+ `+++ b/${file.path}`,
165
+ `@@ -0,0 +1,${lines.length} @@`,
166
+ ...lines.map((l) => `+${l}`),
167
+ ];
168
+ return { ...file, gitDiff: diffLines.join("\n") };
169
+ }
170
+
171
+ return file;
172
+ } catch {
173
+ return file;
174
+ }
175
+ });
176
+
177
+ return { enrichedFiles: enriched, isGitRepo: true };
178
+ }