@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,926 @@
1
+ /**
2
+ * PI Dashboard Bridge Extension
3
+ *
4
+ * Global extension that connects to the dashboard server,
5
+ * forwards all pi events, and relays commands back.
6
+ */
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import { ConnectionManager } from "./connection.js";
9
+ import { detectSessionSource } from "./source-detector.js";
10
+ import { mapEventToProtocol } from "./event-forwarder.js";
11
+ import { createCommandHandler } from "./command-handler.js";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
17
+ import { runDevBuild } from "./dev-build.js";
18
+ import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
19
+ import { discoverDashboard } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
20
+ import { launchServer } from "./server-launcher.js";
21
+ import { autoStartServer } from "./server-auto-start.js";
22
+ import type { ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
23
+ import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
24
+
25
+ import { createUiProxy } from "./ui-proxy.js";
26
+ import { registerAskUserTool } from "./ask-user-tool.js";
27
+ import { activate as activateProviderRegister, onProviderChanged } from "./provider-register.js";
28
+ import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
29
+ import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
30
+ import { scanChildProcesses } from "./process-scanner.js";
31
+ import type { BridgeContext } from "./bridge-context.js";
32
+ import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from "./bridge-context.js";
33
+ import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
34
+ import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged } from "./model-tracker.js";
35
+ import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
36
+
37
+ const HEARTBEAT_INTERVAL = 15_000;
38
+ const GIT_POLL_INTERVAL = 30_000;
39
+ const PROCESS_SCAN_INTERVAL = 10_000;
40
+
41
+
42
+
43
+ // Use `process` (not `globalThis`) to survive jiti module cache invalidation
44
+ // AND to share state across isolated extension contexts (vm sandboxes).
45
+ const BRIDGE_KEY = "__pi_dashboard_bridge__";
46
+ interface BridgeState {
47
+ cleanup?: () => void;
48
+ sessionId?: string;
49
+ ctx?: any;
50
+ modelRegistry?: any;
51
+ hasUI?: boolean;
52
+ /** Monotonic generation counter — stale listeners bail out when mismatched */
53
+ generation?: number;
54
+ /** The pi instance that owns the bridge (used to detect subagent re-entry) */
55
+ pi?: ExtensionAPI;
56
+ /** All connection instances from any bridge incarnation (for cleanup) */
57
+ connections?: ConnectionManager[];
58
+ /** All interval timers from any bridge incarnation (for cleanup) */
59
+ timers?: ReturnType<typeof setInterval>[];
60
+ /** True when the agent is currently in a turn (between agent_start and agent_end) */
61
+ isAgentStreaming?: boolean;
62
+ }
63
+ function getBridgeState(): BridgeState {
64
+ if (!(process as any)[BRIDGE_KEY]) {
65
+ (process as any)[BRIDGE_KEY] = {};
66
+ }
67
+ return (process as any)[BRIDGE_KEY];
68
+ }
69
+
70
+ export default function (pi: ExtensionAPI) {
71
+ try {
72
+ // Activate provider management before bridge init so providers are
73
+ // registered before session_start fires and models_list is sent.
74
+ activateProviderRegister(pi);
75
+
76
+ initBridge(pi);
77
+ } catch (err) {
78
+ // Never crash the host pi agent — dashboard is non-essential
79
+ console.error("[dashboard] Bridge init failed:", err);
80
+ }
81
+ }
82
+
83
+
84
+
85
+
86
+
87
+ function initBridge(pi: ExtensionAPI) {
88
+ const prev = getBridgeState();
89
+
90
+ // If bridge is already active for a different pi instance (e.g. a subagent
91
+ // loading extensions in the same process), skip initialization to avoid
92
+ // invalidating the parent session's bridge connection and event forwarding.
93
+ if (prev.generation && prev.generation > 0 && prev.pi && prev.pi !== pi) {
94
+ return;
95
+ }
96
+
97
+ prev.cleanup?.();
98
+ prev.cleanup = undefined;
99
+
100
+ // Disconnect ALL orphaned connections from previous bridge incarnations
101
+ if (prev.connections) {
102
+ for (const conn of prev.connections) {
103
+ conn.disconnect();
104
+ }
105
+ }
106
+ prev.connections = [];
107
+ // Clear ALL orphaned timers
108
+ if (prev.timers) {
109
+ for (const t of prev.timers) {
110
+ clearInterval(t);
111
+ }
112
+ }
113
+ prev.timers = [];
114
+
115
+ // Bump generation so stale listeners from previous initBridge calls bail out
116
+ const generation = (prev.generation ?? 0) + 1;
117
+ prev.generation = generation;
118
+ prev.pi = pi;
119
+ /** Return true if this bridge instance is still the active one */
120
+ function isActive(): boolean {
121
+ return getBridgeState().generation === generation;
122
+ }
123
+
124
+ let sessionId: string = prev.sessionId ?? crypto.randomUUID();
125
+ let sessionReady = false; // true after session_start has run
126
+ let lastSessionFile: string | undefined;
127
+ let lastSessionDir: string | undefined;
128
+ let lastFirstMessage: string | undefined;
129
+ let pendingDefaultModel: string | null = null; // non-null if default model not yet applied (custom provider not ready)
130
+
131
+ /** Try to apply the default model from config. Returns the model string if not found (pending), null if applied or no default. */
132
+ function applyDefaultModel(): string | null {
133
+ const freshConfig = loadConfig();
134
+ if (!freshConfig.defaultModel || !cachedModelRegistry) return null;
135
+ const slashIdx = freshConfig.defaultModel.indexOf("/");
136
+ if (slashIdx <= 0) return null;
137
+ const provider = freshConfig.defaultModel.slice(0, slashIdx);
138
+ const modelId = freshConfig.defaultModel.slice(slashIdx + 1);
139
+ try {
140
+ const found = cachedModelRegistry.find(provider, modelId);
141
+ if (found) {
142
+ (pi as any).setModel(found).then(() => {
143
+ setTimeout(() => sendModelUpdateIfChanged(), 50);
144
+ }).catch(() => {});
145
+ return null; // applied
146
+ }
147
+ } catch { /* ignore */ }
148
+ return freshConfig.defaultModel; // not found yet — pending
149
+ }
150
+
151
+ /** Query pi-flows for available flows via synchronous event RPC */
152
+ function getFlowsList(): FlowInfo[] {
153
+ const probe: any = {};
154
+ try {
155
+ pi.events?.emit("flow:list-flows", probe);
156
+ } catch { /* ignore */ }
157
+ return (probe.flows as FlowInfo[] | undefined) ?? [];
158
+ }
159
+
160
+ /** Send flows_list message to the dashboard server */
161
+ function sendFlowsList() {
162
+ const flows = getFlowsList();
163
+ console.error(`[dashboard] sendFlowsList: ${flows.length} flows, sessionId=${sessionId.slice(0,8)}`);
164
+ connection.send({ type: "flows_list", sessionId, flows });
165
+ }
166
+
167
+
168
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
169
+ let gitPollTimer: ReturnType<typeof setInterval> | null = null;
170
+ let processScanTimer: ReturnType<typeof setInterval> | null = null;
171
+ let previousProcessPids: string = ""; // JSON-stringified PID set for diff
172
+ const trackedPgids = new Set<number>(); // PGIDs captured during bash tool calls
173
+ let lastGitBranch: string | undefined;
174
+ let lastGitPrNumber: number | undefined;
175
+ let lastSessionName: string | undefined;
176
+ let cachedHasUI: boolean | undefined = prev.hasUI;
177
+ let cachedModelRegistry: any | undefined = prev.modelRegistry;
178
+ let cachedCtx: any | undefined = prev.ctx;
179
+ let lastModel: string | undefined;
180
+ let lastThinkingLevel: string | undefined;
181
+ let uiProxy: ReturnType<typeof createUiProxy> | undefined;
182
+
183
+ /** Wrap a callback so errors log instead of crashing the host pi agent. */
184
+ function safe<T extends (...args: any[]) => any>(fn: T): T {
185
+ return ((...args: any[]) => {
186
+ try {
187
+ const result = fn(...args);
188
+ if (result && typeof result.catch === "function") {
189
+ return result.catch((err: unknown) => {
190
+ console.error("[dashboard]", err);
191
+ });
192
+ }
193
+ return result;
194
+ } catch (err) {
195
+ console.error("[dashboard]", err);
196
+ }
197
+ }) as T;
198
+ }
199
+
200
+ // Load config to determine WebSocket URL
201
+ ensureConfig();
202
+ const config = loadConfig();
203
+ const dashboardUrl = process.env.PI_DASHBOARD_URL ?? `ws://localhost:${config.piPort}`;
204
+
205
+ const connection = new ConnectionManager({
206
+ url: dashboardUrl,
207
+ onMessage: safe(async (data: unknown) => {
208
+ if (!isActive()) return; // Stale listener guard
209
+ const msg = data as ServerToExtensionMessage;
210
+ // Route UI responses to the proxy
211
+ if (msg.type === "extension_ui_response" && uiProxy) {
212
+ uiProxy.handleResponse(msg);
213
+ return;
214
+ }
215
+ // Reload auth credentials when dashboard notifies of changes
216
+ if (msg.type === "credentials_updated") {
217
+ try { cachedModelRegistry?.authStorage?.reload?.(); } catch { /* ignore */ }
218
+ return;
219
+ }
220
+ // Route flow management actions from dashboard buttons
221
+ if (msg.type === "flow_management" && pi.events) {
222
+ if (msg.action === "run") {
223
+ pi.events.emit("flow:run", { flowName: msg.flowName, task: msg.task || undefined });
224
+ } else if (msg.action === "new") {
225
+ pi.events.emit("flows:new-request", { description: msg.description || "" });
226
+ } else if (msg.action === "edit") {
227
+ const editFlows = getFlowsList() as Array<{ name: string; source?: string }>;
228
+ const editMatch = editFlows.find(f => f.name === msg.flowName);
229
+ const resolvedPath = editMatch?.source || "";
230
+ if (!resolvedPath) {
231
+ console.error(`[dashboard] flow_management edit: could not resolve path for "${msg.flowName}" (${editFlows.length} flows)`);
232
+ }
233
+ pi.events.emit("flows:edit-request", { flowName: msg.flowName || "", flowPath: resolvedPath, modificationRequest: msg.description || "" });
234
+ } else if (msg.action === "delete") {
235
+ // Dashboard already confirmed upfront — delete directly
236
+ pi.events.emit("flow:delete-request", { flowName: msg.flowName });
237
+ pi.events.emit("flow:notify", { message: `Flow "${msg.flowName}" deleted.`, level: "info" });
238
+ }
239
+ return;
240
+ }
241
+ // Route role management from dashboard
242
+ if (msg.type === "role_set" && pi.events) {
243
+ const data: any = { role: (msg as any).role, modelId: (msg as any).modelId };
244
+ pi.events.emit("flow:role-set", data);
245
+ if (data.success) {
246
+ const rolesData: any = {};
247
+ pi.events.emit("flow:role-get-all", rolesData);
248
+ connection.send({
249
+ type: "roles_list",
250
+ sessionId,
251
+ roles: rolesData.roles ?? {},
252
+ presets: rolesData.presets ?? [],
253
+ activePreset: rolesData.activePreset ?? null,
254
+ });
255
+ }
256
+ return;
257
+ }
258
+ if (msg.type === "role_preset_load" && pi.events) {
259
+ const data: any = { name: (msg as any).presetName };
260
+ pi.events.emit("flow:role-preset-load", data);
261
+ if (data.success) {
262
+ const rolesData: any = {};
263
+ pi.events.emit("flow:role-get-all", rolesData);
264
+ connection.send({
265
+ type: "roles_list",
266
+ sessionId,
267
+ roles: rolesData.roles ?? {},
268
+ presets: rolesData.presets ?? [],
269
+ activePreset: rolesData.activePreset ?? null,
270
+ });
271
+ }
272
+ return;
273
+ }
274
+ if (msg.type === "role_preset_save" && pi.events) {
275
+ const data: any = { name: (msg as any).presetName };
276
+ pi.events.emit("flow:role-preset-save", data);
277
+ if (data.success) {
278
+ const rolesData: any = {};
279
+ pi.events.emit("flow:role-get-all", rolesData);
280
+ connection.send({
281
+ type: "roles_list",
282
+ sessionId,
283
+ roles: rolesData.roles ?? {},
284
+ presets: rolesData.presets ?? [],
285
+ activePreset: rolesData.activePreset ?? null,
286
+ });
287
+ }
288
+ return;
289
+ }
290
+ if (msg.type === "role_preset_delete" && pi.events) {
291
+ const data: any = { name: (msg as any).presetName };
292
+ pi.events.emit("flow:role-preset-delete", data);
293
+ if (data.success) {
294
+ const rolesData: any = {};
295
+ pi.events.emit("flow:role-get-all", rolesData);
296
+ connection.send({
297
+ type: "roles_list",
298
+ sessionId,
299
+ roles: rolesData.roles ?? {},
300
+ presets: rolesData.presets ?? [],
301
+ activePreset: rolesData.activePreset ?? null,
302
+ });
303
+ }
304
+ return;
305
+ }
306
+ if (msg.type === "request_roles" && pi.events) {
307
+ const rolesData: any = {};
308
+ pi.events.emit("flow:role-get-all", rolesData);
309
+ connection.send({
310
+ type: "roles_list",
311
+ sessionId,
312
+ roles: rolesData.roles ?? {},
313
+ presets: rolesData.presets ?? [],
314
+ activePreset: rolesData.activePreset ?? null,
315
+ });
316
+ return;
317
+ }
318
+ // Route architect prompt responses back to flow-workspace via pi.events
319
+ if (msg.type === "architect_prompt_response" && pi.events) {
320
+ pi.events.emit("flow:prompt-response", {
321
+ id: msg.promptId,
322
+ answer: msg.answer,
323
+ cancelled: msg.cancelled,
324
+ });
325
+ // Cancel any pending ui-proxy dialogs so the TUI selector is dismissed.
326
+ // The architect prompt was also forwarded via the ui-proxy (through
327
+ // flow-tui’s prompt-request handler calling uiCtx.select()), but the
328
+ // dashboard client suppresses the duplicate extension_ui_request when
329
+ // an architect_prompt_request is pending. So the proxy’s dashPromise
330
+ // would never resolve, leaving the TUI dialog open forever.
331
+ uiProxy?.cancelAllPending();
332
+ return;
333
+ }
334
+ // Route flow control messages to pi-flows via pi.events
335
+ if (msg.type === "flow_control" && pi.events) {
336
+ if (msg.action === "abort") {
337
+ pi.events.emit("flow:abort", {});
338
+ // Also abort architect if running (mutually exclusive with flow execution;
339
+ // the irrelevant emit is a no-op due to guard checks on both listeners)
340
+ pi.events.emit("flow:architect-abort", {});
341
+ } else if (msg.action === "toggle_autonomous") {
342
+ pi.events.emit("flow:toggle-autonomous", {});
343
+ } else if (msg.action === "dismiss_summary") {
344
+ pi.events.emit("flow:summary-dismissed", {});
345
+ }
346
+ return;
347
+ }
348
+ const response = await commandHandler.handle(msg);
349
+ if (response) connection.send(response);
350
+ // Immediately send model/thinking update after handling set_thinking_level
351
+ if (msg.type === "set_thinking_level") {
352
+ // Small delay to let pi process the level change
353
+ setTimeout(() => sendModelUpdateIfChanged(), 50);
354
+ }
355
+ }),
356
+ onReconnect: safe(() => {
357
+ if (!isActive()) return; // Stale listener guard
358
+ sendStateSync();
359
+ replaySessionEntries();
360
+ connection.send({ type: "replay_complete", sessionId });
361
+ // If agent is mid-turn, send synthetic agent_start so server sets status to "streaming"
362
+ if (getBridgeState().isAgentStreaming) {
363
+ connection.send(mapEventToProtocol(sessionId, { type: "agent_start" }));
364
+ }
365
+ // Re-send pending interactive UI requests so the new server can track them
366
+ uiProxy?.resendPending();
367
+ }),
368
+ });
369
+
370
+ // Track connection so future bridge incarnations can disconnect it
371
+ getBridgeState().connections!.push(connection);
372
+
373
+ const commandHandler = createCommandHandler(pi, () => sessionId, {
374
+ getModelRegistry: () => cachedModelRegistry,
375
+ setThinkingLevel: (level: string) => (pi as any).setThinkingLevel?.(level),
376
+ getThinkingLevel: () => (pi as any).getThinkingLevel?.(),
377
+ setModel: async (provider: string, modelId: string) => {
378
+ const registry = cachedModelRegistry;
379
+ if (!registry) return;
380
+ const model = registry.find(provider, modelId);
381
+ if (!model) return;
382
+ try {
383
+ await (pi as any).setModel(model);
384
+ } catch {
385
+ return;
386
+ }
387
+ // model_select event updates cachedCtx; small delay lets it propagate
388
+ setTimeout(() => sendModelUpdateIfChanged(), 50);
389
+ },
390
+ shutdown: () => {
391
+ if (cachedCtx?.shutdown) {
392
+ cachedCtx.shutdown();
393
+ }
394
+ // Safety net: force exit after a short delay in case ctx.shutdown()
395
+ // doesn't terminate (e.g. in RPC mode headless sessions)
396
+ setTimeout(() => process.exit(0), 500);
397
+ },
398
+ abort: () => {
399
+ if (cachedCtx?.abort) {
400
+ cachedCtx.abort();
401
+ }
402
+ },
403
+ eventSink: (msg) => connection.send(msg),
404
+ compact: (opts) => {
405
+ if (cachedCtx?.compact) {
406
+ cachedCtx.compact(opts);
407
+ }
408
+ },
409
+ reload: () => {
410
+ const reloadFn = (globalThis as any)[RELOAD_KEY] as (() => Promise<void>) | undefined;
411
+ if (reloadFn) {
412
+ reloadFn().catch((err: any) => {
413
+ console.error("[dashboard] reload failed:", err);
414
+ });
415
+ } else {
416
+ console.error("[dashboard] reload not available — type /__dashboard_reload in pi TUI once to bootstrap");
417
+ }
418
+ },
419
+ spawnNew: () => {
420
+ connection.send({ type: "spawn_new_session", sessionId, cwd: process.cwd() });
421
+ },
422
+ sessionPrompt: (text) => {
423
+ // Route slash commands: management events, flow:run, then fallback
424
+ if (text.startsWith("/") && pi.events) {
425
+ const cmdText = text.slice(1);
426
+ const spaceIdx = cmdText.indexOf(" ");
427
+ const cmdName = spaceIdx === -1 ? cmdText : cmdText.slice(0, spaceIdx);
428
+ const cmdArgs = spaceIdx === -1 ? "" : cmdText.slice(spaceIdx + 1);
429
+
430
+ // Flow management commands from buttons use flow_management message type.
431
+ // Typed /flows:new, /flows:edit, /flows:delete in chat input fall through
432
+ // to the slash command handler below, which invokes pi's command system
433
+ // via pi.sendUserMessage (with ui-proxy handling ctx.ui calls).
434
+
435
+ // Check if it's a user-defined flow via flow:list-flows
436
+ const flowsList = getFlowsList();
437
+ if (flowsList.some(f => f.name === cmdName)) {
438
+ pi.events.emit("flow:run", { flowName: cmdName, task: cmdArgs.trim() || undefined });
439
+ return;
440
+ }
441
+ }
442
+ // Fallback: send as user message (template-expanded).
443
+ // Uses deliverAs:followUp so it queues properly when agent is streaming.
444
+ const expanded = expandPromptTemplateFromDisk(text, process.cwd());
445
+ (pi.sendUserMessage as any)(expanded, { deliverAs: "followUp" });
446
+ },
447
+ });
448
+
449
+ // Reload support: extension events only provide ExtensionContext (no reload).
450
+ // ExtensionCommandContext (with reload()) is only available in command handlers.
451
+ // We register __dashboard_reload command; invoking /__dashboard_reload from pi TUI
452
+ // captures ctx.reload(). After first capture, dashboard-triggered reloads work.
453
+ // The captured fn is stored in globalThis to survive module reloads.
454
+ const RELOAD_KEY = "__pi_dashboard_reload_fn__";
455
+
456
+ pi.registerCommand("__dashboard_reload", {
457
+ handler: async (_args: string, ctx: any) => {
458
+ if (ctx?.reload) {
459
+ (globalThis as any)[RELOAD_KEY] = () => ctx.reload();
460
+ await ctx.reload();
461
+ }
462
+ },
463
+ });
464
+
465
+ /** Sync local variables into BridgeContext for extracted module calls */
466
+ function syncBc(): BridgeContext {
467
+ return {
468
+ pi, connection, sessionId,
469
+ cachedCtx, cachedModelRegistry, cachedHasUI,
470
+ lastModel, lastThinkingLevel,
471
+ lastSessionFile, lastSessionDir, lastFirstMessage,
472
+ lastGitBranch, lastGitPrNumber, lastSessionName,
473
+ };
474
+ }
475
+ /** Sync BridgeContext mutations back to local variables */
476
+ function applyBc(bc: BridgeContext): void {
477
+ sessionId = bc.sessionId;
478
+ cachedCtx = bc.cachedCtx;
479
+ cachedModelRegistry = bc.cachedModelRegistry;
480
+ cachedHasUI = bc.cachedHasUI;
481
+ lastModel = bc.lastModel;
482
+ lastThinkingLevel = bc.lastThinkingLevel;
483
+ lastSessionFile = bc.lastSessionFile;
484
+ lastSessionDir = bc.lastSessionDir;
485
+ lastFirstMessage = bc.lastFirstMessage;
486
+ lastGitBranch = bc.lastGitBranch;
487
+ lastGitPrNumber = bc.lastGitPrNumber;
488
+ lastSessionName = bc.lastSessionName;
489
+ }
490
+
491
+ // Local wrappers that sync bc around extracted module calls
492
+ function sendStateSync() { const bc = syncBc(); _sendStateSync(bc, getFlowsList); applyBc(bc); }
493
+ function replaySessionEntries() { _replaySessionEntries(syncBc()); }
494
+ function sendModelUpdateIfChanged() { const bc = syncBc(); _sendModelUpdateIfChanged(bc); applyBc(bc); }
495
+ function sendSessionNameIfChanged() { const bc = syncBc(); _sendSessionNameIfChanged(bc); applyBc(bc); }
496
+ function sendGitInfoIfChanged(cwd: string) { const bc = syncBc(); _sendGitInfoIfChanged(bc, cwd); applyBc(bc); }
497
+
498
+ // Forward all pi core events to the dashboard.
499
+ // Events with special enrichment logic:
500
+ const enrichedEventTypes = [
501
+ "agent_start",
502
+ "agent_end",
503
+ "turn_start",
504
+ "turn_end",
505
+ "message_start",
506
+ "message_update",
507
+ "message_end",
508
+ "tool_execution_start",
509
+ "tool_execution_update",
510
+ "tool_execution_end",
511
+ "session_compact",
512
+ "model_select",
513
+ ] as const;
514
+ // Pass-through events: forwarded as-is with no special handling.
515
+ // Unrecognized types render as expandable JSON cards in the dashboard.
516
+ const passThroughEventTypes = [
517
+ "tool_call",
518
+ "tool_result",
519
+ "user_bash",
520
+ "input",
521
+ "before_agent_start",
522
+ "resources_discover",
523
+ "session_before_switch",
524
+ "session_before_fork",
525
+ "session_before_compact",
526
+ "session_before_tree",
527
+ "session_tree",
528
+ ] as const;
529
+ // Excluded from subscription (not forwarded):
530
+ // - `context`: carries full message arrays (very large)
531
+ // - `before_provider_request`: carries raw API payloads (very large)
532
+ // - `session_start`: dedicated handler → session_register protocol message
533
+ // - `session_switch`: dedicated handler → session_register protocol message
534
+ // - `session_fork`: dedicated handler → session_register protocol message
535
+ // - `session_shutdown`: dedicated handler → disconnect/cleanup
536
+
537
+ // Unified EventBus rename map for the emit intercept (flow + subagent events)
538
+ const EVENT_BUS_MAP: Record<string, string> = { ...FLOW_EVENT_MAP, ...SUBAGENT_EVENT_MAP };
539
+
540
+ for (const eventType of enrichedEventTypes) {
541
+ pi.on(eventType as any, safe(async (event: any, ctx: any) => {
542
+ // Bail out if a newer bridge instance has taken over
543
+ if (!isActive()) return;
544
+ // Always keep latest context for abort/shutdown
545
+ cachedCtx = ctx;
546
+ // Don't send events before session_start has established the correct session ID
547
+ if (!sessionReady) return;
548
+ // Track agent streaming state (survives reconnect/reload)
549
+ if (eventType === "agent_start") getBridgeState().isAgentStreaming = true;
550
+ if (eventType === "agent_end") getBridgeState().isAgentStreaming = false;
551
+ // For model_select, enrich the event data with thinkingLevel
552
+ if (eventType === "model_select") {
553
+ const enriched = { ...event, thinkingLevel: (pi as any).getThinkingLevel?.() };
554
+ const msg = mapEventToProtocol(sessionId, enriched);
555
+ connection.send(msg);
556
+ return;
557
+ }
558
+
559
+ // For turn_end, enrich with contextUsage (pi-only API) so server can extract stats
560
+ if (eventType === "turn_end") {
561
+ const contextUsage = ctx.getContextUsage?.();
562
+ if (contextUsage) {
563
+ const enriched = { ...event, contextUsage };
564
+ const msg = mapEventToProtocol(sessionId, enriched);
565
+ connection.send(msg);
566
+ return;
567
+ }
568
+ }
569
+
570
+ // For message_start and message_end, enrich with entryId (current leaf)
571
+ if (eventType === "message_start" || eventType === "message_end") {
572
+ const entryId = ctx.sessionManager?.getLeafId?.();
573
+ if (entryId) {
574
+ const enriched = { ...event, entryId };
575
+ const msg = mapEventToProtocol(sessionId, enriched);
576
+ connection.send(msg);
577
+ return;
578
+ }
579
+ }
580
+
581
+ const msg = mapEventToProtocol(sessionId, event);
582
+ connection.send(msg);
583
+ }));
584
+ }
585
+
586
+ // Pass-through events: forward with no enrichment
587
+ for (const eventType of passThroughEventTypes) {
588
+ pi.on(eventType as any, safe(async (event: any, ctx: any) => {
589
+ if (!isActive()) return;
590
+ cachedCtx = ctx;
591
+ if (!sessionReady) return;
592
+ const msg = mapEventToProtocol(sessionId, event);
593
+ connection.send(msg);
594
+ }));
595
+ }
596
+
597
+ // EventBus catch-all: intercept pi.events.emit to forward all EventBus
598
+ // traffic (flow events, subagent events, custom extension events).
599
+ // Known channels get renamed via EVENT_BUS_MAP; unknown channels use the
600
+ // channel name directly as the eventType.
601
+ let origEventsEmit: ((channel: string, data: unknown) => void) | undefined;
602
+ if (pi.events) {
603
+ origEventsEmit = pi.events.emit.bind(pi.events);
604
+ pi.events.emit = (channel: string, data: unknown) => {
605
+ if (sessionReady && isActive()) {
606
+ try {
607
+ const eventType = EVENT_BUS_MAP[channel] ?? channel;
608
+ const eventData = (data && typeof data === "object" ? data : {}) as Record<string, unknown>;
609
+ connection.send({
610
+ type: "event_forward",
611
+ sessionId,
612
+ event: { eventType, timestamp: Date.now(), data: eventData },
613
+ });
614
+ } catch { /* forwarding failure must never break the original emit */ }
615
+ }
616
+ origEventsEmit!(channel, data);
617
+ };
618
+ }
619
+
620
+ pi.on("session_start", safe(async (_event: any, ctx: any) => {
621
+
622
+ // Bail out if a newer bridge instance has taken over
623
+ if (!isActive()) return;
624
+ const newSessionId = ctx.sessionManager.getSessionId();
625
+
626
+ cachedHasUI = ctx.hasUI;
627
+ cachedCtx = ctx;
628
+ sessionId = newSessionId;
629
+
630
+ // Register ask_user at runtime (not at load time) to avoid static
631
+ // tool-name conflicts with other extensions like pi-flows.
632
+ registerAskUserTool(pi);
633
+
634
+ // Extract session file/dir early — needed for source detection and UI proxy
635
+ const sessionFile = ctx.sessionManager.getSessionFile?.() ?? undefined;
636
+ const sessionDir = ctx.sessionManager.getSessionDir?.() ?? undefined;
637
+ lastSessionFile = sessionFile;
638
+ lastSessionDir = sessionDir;
639
+
640
+ // Set up UI proxy to forward dialogs to dashboard.
641
+ // For dashboard-spawned sessions (tmux or headless), skip the TUI race —
642
+ // the dashboard is the primary UI, and the TUI dialog in an unattended
643
+ // tmux window would auto-resolve/flood.
644
+ const dashboardSpawned = detectSessionSource(cachedHasUI, sessionFile) === "dashboard";
645
+ uiProxy = createUiProxy({
646
+ ui: ctx.ui as any,
647
+ hasUI: ctx.hasUI && !dashboardSpawned,
648
+ getSessionId: () => sessionId,
649
+ send: (msg: any) => connection.send(msg),
650
+ });
651
+ // Replace ctx.ui methods with proxied versions.
652
+ // The ui-proxy has a recursion guard (inProxy flag) so even if ctx.ui
653
+ // is already patched from a previous /reload, the TUI race path won't
654
+ // recurse — it falls back to dashboard-only on re-entry.
655
+ // Replace ctx.ui methods with proxied versions.
656
+ // The ui-proxy has a recursion guard (inProxy flag) so even if ctx.ui
657
+ // is already patched from a previous /reload, the TUI race path won't
658
+ // recurse — it falls back to dashboard-only on re-entry.
659
+ (ctx.ui as any).confirm = uiProxy.wrappedUi.confirm;
660
+ (ctx.ui as any).select = uiProxy.wrappedUi.select;
661
+ (ctx.ui as any).input = uiProxy.wrappedUi.input;
662
+ (ctx.ui as any).editor = uiProxy.wrappedUi.editor;
663
+ (ctx.ui as any).notify = uiProxy.wrappedUi.notify;
664
+
665
+ // Connect first, then auto-start if needed.
666
+ // session_register must be buffered before any event_forward messages.
667
+ connection.connect();
668
+
669
+ // Extract first message (sessionFile/sessionDir already extracted above)
670
+ const firstMessage = extractFirstMessage(ctx);
671
+ lastFirstMessage = firstMessage;
672
+
673
+ // Register session with initial model/thinkingLevel
674
+ lastSessionName = pi.getSessionName() ?? "";
675
+ const initialModel = getCurrentModelString(syncBc());
676
+ const initialThinkingLevel = (pi as any).getThinkingLevel?.() ?? undefined;
677
+ lastModel = initialModel;
678
+ lastThinkingLevel = initialThinkingLevel;
679
+
680
+ // Include eventCount so server can skip event wipe on reconnect
681
+ let eventCount: number | undefined;
682
+ try {
683
+ const entries = ctx.sessionManager?.getBranch?.();
684
+ if (entries) eventCount = entries.length;
685
+ } catch { /* ignore */ }
686
+
687
+ connection.send({
688
+ type: "session_register",
689
+ sessionId,
690
+ cwd: ctx.cwd,
691
+ name: lastSessionName || undefined,
692
+ source: detectSessionSource(cachedHasUI, sessionFile),
693
+ model: initialModel,
694
+ thinkingLevel: initialThinkingLevel,
695
+ sessionFile,
696
+ sessionDir,
697
+ firstMessage,
698
+ eventCount,
699
+ });
700
+
701
+ // Allow event forwarding now that session_register is buffered
702
+ sessionReady = true;
703
+
704
+ // Replay full session history so the dashboard has all messages
705
+ replaySessionEntries();
706
+ connection.send({ type: "replay_complete", sessionId });
707
+ // If agent is mid-turn (e.g. reload during streaming), send synthetic agent_start
708
+ if (getBridgeState().isAgentStreaming) {
709
+ connection.send(mapEventToProtocol(sessionId, { type: "agent_start" }));
710
+ }
711
+
712
+ // Send initial commands list
713
+ const commands = filterHiddenCommands(pi.getCommands());
714
+ connection.send({
715
+ type: "commands_list",
716
+ sessionId,
717
+ commands,
718
+ });
719
+
720
+ // Send initial flows list
721
+ sendFlowsList();
722
+
723
+ // Send available models
724
+ cachedModelRegistry = (ctx as any).modelRegistry;
725
+ if (cachedModelRegistry) {
726
+ try {
727
+ const models = cachedModelRegistry.getAvailable().map((m: any) => ({
728
+ provider: m.provider,
729
+ id: m.id,
730
+ }));
731
+ connection.send({ type: "models_list", sessionId, models });
732
+ } catch { /* modelRegistry not available */ }
733
+ }
734
+
735
+ // Apply default model on new sessions only (not reload/resume/fork)
736
+ if (_event?.reason === "startup" && cachedModelRegistry) {
737
+ pendingDefaultModel = applyDefaultModel();
738
+ }
739
+
740
+ // Send initial roles
741
+ if (pi.events) {
742
+ const rolesData: any = {};
743
+ pi.events.emit("flow:role-get-all", rolesData);
744
+ if (rolesData.roles) {
745
+ connection.send({
746
+ type: "roles_list",
747
+ sessionId,
748
+ roles: rolesData.roles ?? {},
749
+ presets: rolesData.presets ?? [],
750
+ activePreset: rolesData.activePreset ?? null,
751
+ });
752
+ }
753
+ }
754
+
755
+ // Discover or auto-start server (non-blocking — connection will reconnect)
756
+ autoStartServer(config, {
757
+ discoverDashboard,
758
+ isDashboardRunning,
759
+ launchServer,
760
+ notify: (msg, level) => ctx.ui.notify(msg, level),
761
+ }).then((result) => {
762
+ if (result.server && result.server.piPort !== config.piPort) {
763
+ // Server found on a different piPort than configured — update connection URL
764
+ connection.updateUrl(`ws://${result.server.host === 'localhost' ? 'localhost' : result.server.host}:${result.server.piPort}`);
765
+ }
766
+ }).catch(() => {});
767
+
768
+ // Send initial git info
769
+ sendGitInfoIfChanged(ctx.cwd);
770
+
771
+ // Start metrics monitor and heartbeat
772
+ startMetricsMonitor();
773
+ heartbeatTimer = setInterval(() => {
774
+ if (!isActive()) return;
775
+ connection.send({
776
+ type: "session_heartbeat",
777
+ sessionId,
778
+ metrics: collectMetrics(),
779
+ });
780
+ }, HEARTBEAT_INTERVAL);
781
+ getBridgeState().timers!.push(heartbeatTimer);
782
+
783
+ // Start git info + name/model polling
784
+ gitPollTimer = setInterval(() => {
785
+ if (!isActive()) return;
786
+ sendGitInfoIfChanged(ctx.cwd);
787
+ sendSessionNameIfChanged();
788
+ sendModelUpdateIfChanged();
789
+ }, GIT_POLL_INTERVAL);
790
+ getBridgeState().timers!.push(gitPollTimer);
791
+
792
+ // Start process scanner (detect stalled child processes)
793
+ // Captures new child PGIDs during active bash calls, then checks tracked PGIDs
794
+ processScanTimer = setInterval(() => {
795
+ if (!isActive()) return;
796
+ const processes = scanChildProcesses(process.pid, trackedPgids);
797
+ const currentPids = JSON.stringify(processes.map((p) => p.pid).sort());
798
+ if (currentPids !== previousProcessPids) {
799
+ previousProcessPids = currentPids;
800
+ connection.send({
801
+ type: "process_list",
802
+ sessionId,
803
+ processes: processes.map((p) => ({ pid: p.pid, pgid: p.pgid, command: p.command, elapsedMs: p.elapsedMs })),
804
+ });
805
+ }
806
+ }, PROCESS_SCAN_INTERVAL);
807
+ getBridgeState().timers!.push(processScanTimer);
808
+
809
+ // Register flow event listeners (pi-flows emits these via pi.events)
810
+ registerFlowEventListeners(syncBc(), () => sessionReady, getFlowsList);
811
+ }));
812
+
813
+ // Shared handler for session_switch and session_fork
814
+ function handleSessionChange(ctx: any) {
815
+ const bc = syncBc();
816
+ _handleSessionChange(bc, ctx, getFlowsList);
817
+ applyBc(bc);
818
+
819
+ // Restart polling timers
820
+ if (gitPollTimer) clearInterval(gitPollTimer);
821
+ gitPollTimer = setInterval(() => {
822
+ sendGitInfoIfChanged(ctx.cwd);
823
+ }, GIT_POLL_INTERVAL);
824
+ }
825
+
826
+ pi.on("session_switch" as any, safe(async (_event: any, ctx: any) => {
827
+ if (!isActive()) return;
828
+ cachedCtx = ctx;
829
+ handleSessionChange(ctx);
830
+ }));
831
+
832
+ pi.on("session_fork" as any, safe(async (_event: any, ctx: any) => {
833
+ if (!isActive()) return;
834
+ cachedCtx = ctx;
835
+ handleSessionChange(ctx);
836
+ }));
837
+
838
+ pi.on("turn_end", safe(async (event: any, ctx: any) => {
839
+ if (!isActive()) return;
840
+ cachedCtx = ctx;
841
+ if (!sessionReady) return;
842
+
843
+ // Send firstMessage update after first turn if not previously sent
844
+ if (!lastFirstMessage) {
845
+ const firstMsg = extractFirstMessage(ctx);
846
+ if (firstMsg) {
847
+ lastFirstMessage = firstMsg;
848
+ connection.send({
849
+ type: "first_message_update",
850
+ sessionId,
851
+ firstMessage: firstMsg,
852
+ });
853
+ }
854
+ }
855
+
856
+ }));
857
+
858
+ pi.on("session_shutdown", safe(async () => {
859
+ if (!isActive()) return;
860
+ getBridgeState().isAgentStreaming = false;
861
+ stopMetricsMonitor();
862
+ if (heartbeatTimer) {
863
+ clearInterval(heartbeatTimer);
864
+ heartbeatTimer = null;
865
+ }
866
+ if (gitPollTimer) {
867
+ clearInterval(gitPollTimer);
868
+ gitPollTimer = null;
869
+ }
870
+ connection.send({
871
+ type: "session_unregister",
872
+ sessionId,
873
+ });
874
+
875
+ // Give time for the unregister to send
876
+ await new Promise((resolve) => setTimeout(resolve, 100));
877
+ connection.disconnect();
878
+ }));
879
+
880
+ // Re-send models list when custom providers finish async discovery
881
+ onProviderChanged(() => {
882
+ if (!isActive()) return;
883
+ if (cachedModelRegistry && sessionReady) {
884
+ try {
885
+ const models = cachedModelRegistry.getAvailable().map((m: any) => ({
886
+ provider: m.provider,
887
+ id: m.id,
888
+ }));
889
+ connection.send({ type: "models_list", sessionId, models });
890
+ } catch { /* ignore */ }
891
+
892
+ // Retry pending default model — custom provider may now have its models
893
+ if (pendingDefaultModel) {
894
+ pendingDefaultModel = applyDefaultModel();
895
+ }
896
+ }
897
+ });
898
+
899
+ // Register cleanup for /reload — saves state to globalThis and tears down resources
900
+ const state = getBridgeState();
901
+ state.cleanup = () => {
902
+ const s = getBridgeState();
903
+ s.sessionId = sessionId;
904
+ s.ctx = cachedCtx;
905
+ s.modelRegistry = cachedModelRegistry;
906
+ s.hasUI = cachedHasUI;
907
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
908
+ if (gitPollTimer) { clearInterval(gitPollTimer); gitPollTimer = null; }
909
+
910
+ // Dev build & restart: rebuild client and stop server before reload
911
+ if (config.devBuildOnReload) {
912
+ const __filename = fileURLToPath(import.meta.url);
913
+ const __dirname = path.dirname(__filename);
914
+ const packageRoot = path.resolve(__dirname, "..", "..");
915
+ runDevBuild({ packageRoot, serverPort: config.port });
916
+ }
917
+
918
+ // Restore original pi.events.emit (EventBus catch-all cleanup)
919
+ if (origEventsEmit && pi.events) {
920
+ pi.events.emit = origEventsEmit;
921
+ }
922
+ connection.disconnect();
923
+ };
924
+
925
+ // Reload is handled by session_start which fires on /reload too
926
+ }