@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,538 @@
1
+ /**
2
+ * Handles server→extension messages by dispatching to pi API.
3
+ */
4
+ import { spawnSync } from "node:child_process";
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type {
7
+ ServerToExtensionMessage,
8
+ ExtensionToServerMessage,
9
+ } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
10
+ import { killProcessByPgid } from "./process-scanner.js";
11
+ import type { FileEntry, PiSessionInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
12
+ import { filterHiddenCommands } from "./bridge-context.js";
13
+
14
+ /** Escape regex special characters for fd pattern */
15
+ function escapeRegex(value: string): string {
16
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ }
18
+
19
+ /** Search files using fd */
20
+ function searchFiles(cwd: string, query: string): FileEntry[] {
21
+ const args = [
22
+ "--base-directory", cwd,
23
+ "--max-results", "20",
24
+ "--type", "f",
25
+ "--type", "d",
26
+ "--full-path",
27
+ "--hidden",
28
+ "--exclude", ".git",
29
+ ];
30
+
31
+ if (query) {
32
+ args.push(escapeRegex(query));
33
+ }
34
+
35
+ try {
36
+ const result = spawnSync("fd", args, {
37
+ encoding: "utf-8",
38
+ stdio: ["pipe", "pipe", "pipe"],
39
+ timeout: 5000,
40
+ });
41
+
42
+ if (result.status !== 0 || !result.stdout) {
43
+ return [];
44
+ }
45
+
46
+ return result.stdout.trim().split("\n").filter(Boolean).map((line) => {
47
+ const normalized = line.replace(/\\/g, "/");
48
+ const isDirectory = normalized.endsWith("/");
49
+ return { path: normalized, isDirectory };
50
+ });
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /** Parsed result from parseSendPrompt */
57
+ export type ParsedPrompt =
58
+ | { type: "bash"; command: string; excludeFromContext: boolean }
59
+ | { type: "compact"; customInstructions: string | undefined }
60
+ | { type: "model"; provider: string; modelId: string }
61
+ | { type: "shutdown" }
62
+ | { type: "reload" }
63
+ | { type: "new" }
64
+ | { type: "mgmt"; event: string; data: Record<string, unknown> }
65
+ | { type: "slash"; text: string }
66
+ | { type: "passthrough"; text: string };
67
+
68
+ /** pi-flows management commands with known event mappings.
69
+ * These are dispatched via pi.events instead of flow:run.
70
+ * Flow management commands (flows:new, flows:edit, flows:delete) are
71
+ * handled in bridge.ts sessionPrompt callback which passes cachedCtx
72
+ * as fallback context for headless sessions. */
73
+ const MANAGEMENT_COMMAND_EVENTS: Record<string, {
74
+ event: string;
75
+ dataFn: (args: string) => Record<string, unknown>;
76
+ }> = {};
77
+
78
+ /** Parse input text to detect pi internal command prefixes */
79
+ export function parseSendPrompt(text: string): ParsedPrompt {
80
+ // 1. Check !! (must check before !)
81
+ if (text.startsWith("!!")) {
82
+ const command = text.slice(2).trim();
83
+ if (!command) return { type: "passthrough", text };
84
+ return { type: "bash", command, excludeFromContext: true };
85
+ }
86
+
87
+ // 2. Check !
88
+ if (text.startsWith("!")) {
89
+ const command = text.slice(1).trim();
90
+ if (!command) return { type: "passthrough", text };
91
+ return { type: "bash", command, excludeFromContext: false };
92
+ }
93
+
94
+ // 3. Check /compact
95
+ if (text === "/compact" || text.startsWith("/compact ")) {
96
+ const args = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
97
+ return { type: "compact", customInstructions: args || undefined };
98
+ }
99
+
100
+ // 4. Check /quit and /exit
101
+ if (text === "/quit" || text === "/exit") {
102
+ return { type: "shutdown" };
103
+ }
104
+
105
+ // 4b. Check /reload
106
+ if (text === "/reload") {
107
+ return { type: "reload" };
108
+ }
109
+
110
+ // 4c. Check /new
111
+ if (text === "/new") {
112
+ return { type: "new" };
113
+ }
114
+
115
+ // 4d. Check /model <provider/id>
116
+ if (text.startsWith("/model ")) {
117
+ const modelStr = text.slice(7).trim();
118
+ const slashIdx = modelStr.indexOf("/");
119
+ if (slashIdx > 0) {
120
+ return { type: "model", provider: modelStr.slice(0, slashIdx), modelId: modelStr.slice(slashIdx + 1) };
121
+ }
122
+ }
123
+
124
+ // 5. Check management commands (/flows:new, etc.) with known event mappings
125
+ if (text.startsWith("/") && !text.includes("\n")) {
126
+ const cmdText = text.slice(1);
127
+ const spaceIdx = cmdText.indexOf(" ");
128
+ const cmdName = spaceIdx === -1 ? cmdText : cmdText.slice(0, spaceIdx);
129
+ const cmdArgs = spaceIdx === -1 ? "" : cmdText.slice(spaceIdx + 1);
130
+ const mgmt = MANAGEMENT_COMMAND_EVENTS[cmdName];
131
+ if (mgmt) {
132
+ return { type: "mgmt", event: mgmt.event, data: mgmt.dataFn(cmdArgs) };
133
+ }
134
+ }
135
+
136
+ // 6. Check / prefix (generic slash command)
137
+ if (text.startsWith("/") && !text.includes("\n")) {
138
+ return { type: "slash", text };
139
+ }
140
+
141
+ // 5. Passthrough
142
+ return { type: "passthrough", text };
143
+ }
144
+
145
+ const BASH_TIMEOUT = 30_000;
146
+
147
+ export interface CommandHandler {
148
+ handle(msg: ServerToExtensionMessage): ExtensionToServerMessage | undefined | Promise<ExtensionToServerMessage | undefined>;
149
+ }
150
+
151
+ export function createCommandHandler(
152
+ pi: ExtensionAPI,
153
+ sessionIdOrGetter: string | (() => string),
154
+ options?: {
155
+ getModelRegistry?: () => any;
156
+ setThinkingLevel?: (level: string) => void;
157
+ getThinkingLevel?: () => string | undefined;
158
+ shutdown?: () => void;
159
+ abort?: () => void;
160
+ getCwd?: () => string;
161
+ /** Callback to send events (e.g., bash_output, command_feedback) back to server */
162
+ eventSink?: (msg: ExtensionToServerMessage) => void;
163
+ /** Trigger context compaction */
164
+ compact?: (options: { customInstructions?: string }) => void;
165
+ /** Trigger session reload (extensions, settings, skills, etc.) */
166
+ reload?: () => void;
167
+ /** Spawn a new session in the same cwd */
168
+ spawnNew?: () => void;
169
+ /** Switch model via pi.setModel() */
170
+ setModel?: (provider: string, modelId: string) => Promise<void>;
171
+ /** Route slash commands through session.prompt() */
172
+ sessionPrompt?: (text: string) => void;
173
+ },
174
+ ): CommandHandler {
175
+ const getSessionId = typeof sessionIdOrGetter === "function" ? sessionIdOrGetter : () => sessionIdOrGetter;
176
+ return {
177
+ async handle(msg: ServerToExtensionMessage): Promise<ExtensionToServerMessage | undefined> {
178
+ const sessionId = getSessionId();
179
+
180
+ // Ignore messages for other sessions (skip session-less messages like heartbeat_ack)
181
+ if (msg.sessionId !== undefined && msg.sessionId !== sessionId) {
182
+ console.error(`[dashboard] Ignoring message type=${msg.type} for session ${msg.sessionId}, current session is ${sessionId}`);
183
+ return undefined;
184
+ }
185
+
186
+ switch (msg.type) {
187
+ case "send_prompt": {
188
+ const parsed = parseSendPrompt(msg.text);
189
+
190
+ // Route based on parsed command type
191
+ if (parsed.type === "bash") {
192
+ await handleBashCommand(pi, sessionId, parsed.command, parsed.excludeFromContext, options?.eventSink);
193
+ return undefined;
194
+ }
195
+
196
+ if (parsed.type === "compact") {
197
+ await handleCompactCommand(sessionId, parsed.customInstructions, options?.compact, options?.eventSink);
198
+ return undefined;
199
+ }
200
+
201
+ if (parsed.type === "shutdown") {
202
+ if (options?.shutdown) {
203
+ options.shutdown();
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ if (parsed.type === "reload") {
209
+ if (options?.reload) {
210
+ options.reload();
211
+ }
212
+ options?.eventSink?.({
213
+ type: "event_forward",
214
+ sessionId,
215
+ event: {
216
+ eventType: "command_feedback",
217
+ timestamp: Date.now(),
218
+ data: { command: "/reload", status: "completed" },
219
+ },
220
+ });
221
+ return undefined;
222
+ }
223
+
224
+ if (parsed.type === "new") {
225
+ if (options?.spawnNew) {
226
+ options.spawnNew();
227
+ }
228
+ options?.eventSink?.({
229
+ type: "event_forward",
230
+ sessionId,
231
+ event: {
232
+ eventType: "command_feedback",
233
+ timestamp: Date.now(),
234
+ data: { command: "/new", status: "completed" },
235
+ },
236
+ });
237
+ return undefined;
238
+ }
239
+
240
+ if (parsed.type === "model") {
241
+ if (options?.setModel) {
242
+ await options.setModel(parsed.provider, parsed.modelId);
243
+ }
244
+ options?.eventSink?.({
245
+ type: "event_forward",
246
+ sessionId,
247
+ event: {
248
+ eventType: "command_feedback",
249
+ timestamp: Date.now(),
250
+ data: { command: `/model ${parsed.provider}/${parsed.modelId}`, status: "completed" },
251
+ },
252
+ });
253
+ return undefined;
254
+ }
255
+
256
+ if (parsed.type === "mgmt") {
257
+ // Dispatch management command via pi.events (e.g. flows:new-request)
258
+ if ((pi as any).events) {
259
+ (pi as any).events.emit(parsed.event, parsed.data);
260
+ }
261
+ options?.eventSink?.({
262
+ type: "event_forward",
263
+ sessionId,
264
+ event: {
265
+ eventType: "command_feedback",
266
+ timestamp: Date.now(),
267
+ data: { command: parsed.event, status: "completed" },
268
+ },
269
+ });
270
+ return undefined;
271
+ }
272
+
273
+ if (parsed.type === "slash") {
274
+ if (options?.sessionPrompt) {
275
+ options.sessionPrompt(parsed.text);
276
+ } else {
277
+ pi.sendUserMessage(parsed.text);
278
+ }
279
+ options?.eventSink?.({
280
+ type: "event_forward",
281
+ sessionId,
282
+ event: {
283
+ eventType: "command_feedback",
284
+ timestamp: Date.now(),
285
+ data: { command: parsed.text, status: "completed" },
286
+ },
287
+ });
288
+ return undefined;
289
+ }
290
+
291
+ // Passthrough: send as regular user message (with image handling)
292
+ sendUserMessageWithImages(pi, msg.text, msg.images);
293
+ return undefined;
294
+ }
295
+
296
+ case "abort":
297
+ if (options?.abort) {
298
+ options.abort();
299
+ }
300
+ return undefined;
301
+
302
+ case "request_commands": {
303
+ const commands = filterHiddenCommands(pi.getCommands());
304
+ // Also send flows list alongside commands
305
+ if (options?.eventSink) {
306
+ const probe: any = {};
307
+ try { pi.events?.emit("flow:list-flows", probe); } catch { /* ignore */ }
308
+ options.eventSink({ type: "flows_list", sessionId, flows: probe.flows ?? [] });
309
+ }
310
+ return {
311
+ type: "commands_list",
312
+ sessionId,
313
+ commands,
314
+ };
315
+ }
316
+
317
+ case "list_files": {
318
+ const files = searchFiles(process.cwd(), msg.query);
319
+ return {
320
+ type: "files_list",
321
+ sessionId,
322
+ query: msg.query,
323
+ files,
324
+ };
325
+ }
326
+
327
+ // openspec_refresh removed — server handles directly via DirectoryService
328
+
329
+ case "rename_session":
330
+ pi.setSessionName(msg.name);
331
+ return {
332
+ type: "session_name_update",
333
+ sessionId,
334
+ name: msg.name,
335
+ };
336
+
337
+ case "request_models": {
338
+ const registry = options?.getModelRegistry?.();
339
+ if (registry) {
340
+ try {
341
+ registry.refresh();
342
+ const models = registry.getAvailable().map((m: any) => ({
343
+ provider: m.provider,
344
+ id: m.id,
345
+ }));
346
+ return { type: "models_list", sessionId, models };
347
+ } catch { /* ignore */ }
348
+ }
349
+ return { type: "models_list", sessionId, models: [] };
350
+ }
351
+
352
+ case "set_thinking_level":
353
+ if (options?.setThinkingLevel) {
354
+ options.setThinkingLevel(msg.level);
355
+ }
356
+ return undefined;
357
+
358
+ case "set_model":
359
+ if (options?.setModel) {
360
+ await options.setModel(msg.provider, msg.modelId);
361
+ }
362
+ return undefined;
363
+
364
+ case "kill_process": {
365
+ const pgid = (msg as { pgid: number }).pgid;
366
+ if (pgid) {
367
+ const killed = killProcessByPgid(pgid);
368
+ console.error(`[dashboard] kill_process pgid=${pgid} result=${killed}`);
369
+ }
370
+ return undefined;
371
+ }
372
+
373
+ case "shutdown":
374
+ if (options?.shutdown) {
375
+ options.shutdown();
376
+ }
377
+ return undefined;
378
+
379
+ case "request_state_sync":
380
+ // State sync is handled by the bridge on reconnect
381
+ return undefined;
382
+
383
+ case "request_flows_refresh": {
384
+ // Re-query pi-flows and send updated list
385
+ if (options?.eventSink) {
386
+ const probe: any = {};
387
+ try { pi.events?.emit("flow:list-flows", probe); } catch { /* ignore */ }
388
+ options.eventSink({ type: "flows_list", sessionId, flows: probe.flows ?? [] });
389
+ }
390
+ return undefined;
391
+ }
392
+
393
+ case "list_sessions": {
394
+ try {
395
+ // Dynamic import to avoid hard dependency at module load
396
+ const { SessionManager } = await import("@mariozechner/pi-coding-agent") as any;
397
+ const cwd = msg.cwd || options?.getCwd?.() || process.cwd();
398
+ const sessionInfos = await SessionManager.list(cwd);
399
+ const sessions: PiSessionInfo[] = (sessionInfos || []).map((s: any) => ({
400
+ id: s.id,
401
+ path: s.path,
402
+ cwd: s.cwd,
403
+ name: s.name,
404
+ parentSessionPath: s.parentSessionPath,
405
+ created: s.created instanceof Date ? s.created.toISOString() : String(s.created),
406
+ modified: s.modified instanceof Date ? s.modified.toISOString() : String(s.modified),
407
+ messageCount: s.messageCount ?? 0,
408
+ firstMessage: s.firstMessage,
409
+ }));
410
+ return { type: "sessions_list", sessionId, cwd, sessions };
411
+ } catch {
412
+ return { type: "sessions_list", sessionId, cwd: msg.cwd || process.cwd(), sessions: [] };
413
+ }
414
+ }
415
+
416
+ default:
417
+ return undefined;
418
+ }
419
+ },
420
+ };
421
+ }
422
+
423
+ /** Send a user message with optional image validation.
424
+ * Uses deliverAs: "followUp" so messages queue properly when the agent is streaming. */
425
+ function sendUserMessageWithImages(
426
+ pi: ExtensionAPI,
427
+ text: string,
428
+ images?: Array<{ type: string; data: string; mimeType: string }>,
429
+ ): void {
430
+ const sendOptions = { deliverAs: "followUp" as const };
431
+ if (images && images.length > 0) {
432
+ const validMimeTypes = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
433
+ const validImages = images.filter((img) => {
434
+ if (!img || typeof img !== "object") {
435
+ console.error("[dashboard] Dropping non-object image entry");
436
+ return false;
437
+ }
438
+ if (!img.mimeType || typeof img.mimeType !== "string" || !validMimeTypes.has(img.mimeType)) {
439
+ console.error(`[dashboard] Dropping image with invalid mimeType: "${img.mimeType}" (type: ${typeof img.mimeType})`);
440
+ return false;
441
+ }
442
+ if (!img.data || typeof img.data !== "string") {
443
+ console.error(`[dashboard] Dropping image with invalid data (type: ${typeof img.data}, length: ${img.data?.length ?? 0})`);
444
+ return false;
445
+ }
446
+ return true;
447
+ });
448
+ if (validImages.length > 0) {
449
+ const content = [
450
+ { type: "text" as const, text },
451
+ ...validImages.map((img) => ({
452
+ type: "image" as const,
453
+ data: img.data,
454
+ mimeType: img.mimeType,
455
+ })),
456
+ ];
457
+ console.error(`[dashboard] Sending message with ${validImages.length} image(s), mimeTypes: ${validImages.map(i => i.mimeType).join(", ")}`);
458
+ (pi.sendUserMessage as any)(content, sendOptions);
459
+ } else {
460
+ (pi.sendUserMessage as any)(text, sendOptions);
461
+ }
462
+ } else {
463
+ (pi.sendUserMessage as any)(text, sendOptions);
464
+ }
465
+ }
466
+
467
+ /** Execute a bash command and forward results */
468
+ async function handleBashCommand(
469
+ pi: ExtensionAPI,
470
+ sessionId: string,
471
+ command: string,
472
+ excludeFromContext: boolean,
473
+ eventSink?: (msg: ExtensionToServerMessage) => void,
474
+ ): Promise<void> {
475
+ let output = "";
476
+ let exitCode = 0;
477
+ try {
478
+ const result = await pi.exec("sh", ["-c", command], { timeout: BASH_TIMEOUT });
479
+ output = (result.stdout || "") + (result.stderr || "");
480
+ exitCode = result.exitCode ?? 0;
481
+ } catch (err: any) {
482
+ output = err?.message ?? "Command execution failed";
483
+ exitCode = 1;
484
+ }
485
+
486
+ // Forward bash output event
487
+ eventSink?.({
488
+ type: "event_forward",
489
+ sessionId,
490
+ event: {
491
+ eventType: "bash_output",
492
+ timestamp: Date.now(),
493
+ data: { command, output, exitCode, excludeFromContext },
494
+ },
495
+ });
496
+
497
+ // For ! (not !!), also send to LLM
498
+ if (!excludeFromContext) {
499
+ const message = `$ ${command}\n${output}`;
500
+ pi.sendUserMessage(message);
501
+ }
502
+ }
503
+
504
+ /** Handle /compact command */
505
+ async function handleCompactCommand(
506
+ sessionId: string,
507
+ customInstructions: string | undefined,
508
+ compact?: (options: { customInstructions?: string }) => void,
509
+ eventSink?: (msg: ExtensionToServerMessage) => void,
510
+ ): Promise<void> {
511
+ eventSink?.({
512
+ type: "event_forward",
513
+ sessionId,
514
+ event: {
515
+ eventType: "command_feedback",
516
+ timestamp: Date.now(),
517
+ data: { command: "/compact", status: "started" },
518
+ },
519
+ });
520
+
521
+ try {
522
+ if (compact) {
523
+ compact({ customInstructions });
524
+ }
525
+ } catch (err: any) {
526
+ eventSink?.({
527
+ type: "event_forward",
528
+ sessionId,
529
+ event: {
530
+ eventType: "command_feedback",
531
+ timestamp: Date.now(),
532
+ data: { command: "/compact", status: "error", message: err?.message ?? "Compaction failed" },
533
+ },
534
+ });
535
+ }
536
+ }
537
+
538
+ // handleLoadSessionEvents removed — server loads sessions directly via DirectoryService