@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.
- package/AGENTS.md +342 -0
- package/README.md +619 -0
- package/docs/architecture.md +646 -0
- package/package.json +92 -0
- package/packages/extension/package.json +33 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
- package/packages/extension/src/__tests__/connection.test.ts +344 -0
- package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
- package/packages/extension/src/__tests__/git-info.test.ts +112 -0
- package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
- package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
- package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
- package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
- package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
- package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
- package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
- package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
- package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
- package/packages/extension/src/ask-user-tool.ts +63 -0
- package/packages/extension/src/bridge-context.ts +64 -0
- package/packages/extension/src/bridge.ts +926 -0
- package/packages/extension/src/command-handler.ts +538 -0
- package/packages/extension/src/connection.ts +204 -0
- package/packages/extension/src/dev-build.ts +39 -0
- package/packages/extension/src/event-forwarder.ts +40 -0
- package/packages/extension/src/flow-event-wiring.ts +102 -0
- package/packages/extension/src/git-info.ts +65 -0
- package/packages/extension/src/git-link-builder.ts +112 -0
- package/packages/extension/src/model-tracker.ts +56 -0
- package/packages/extension/src/pi-env.d.ts +23 -0
- package/packages/extension/src/process-metrics.ts +70 -0
- package/packages/extension/src/process-scanner.ts +396 -0
- package/packages/extension/src/prompt-expander.ts +87 -0
- package/packages/extension/src/provider-register.ts +276 -0
- package/packages/extension/src/server-auto-start.ts +87 -0
- package/packages/extension/src/server-launcher.ts +82 -0
- package/packages/extension/src/server-probe.ts +33 -0
- package/packages/extension/src/session-sync.ts +154 -0
- package/packages/extension/src/source-detector.ts +26 -0
- package/packages/extension/src/ui-proxy.ts +269 -0
- package/packages/extension/tsconfig.json +11 -0
- package/packages/server/package.json +37 -0
- package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
- package/packages/server/src/__tests__/auth.test.ts +224 -0
- package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
- package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
- package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
- package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
- package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
- package/packages/server/src/__tests__/config-api.test.ts +104 -0
- package/packages/server/src/__tests__/cors.test.ts +48 -0
- package/packages/server/src/__tests__/directory-service.test.ts +240 -0
- package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
- package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
- package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
- package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
- package/packages/server/src/__tests__/extension-register.test.ts +61 -0
- package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
- package/packages/server/src/__tests__/git-operations.test.ts +251 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
- package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
- package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
- package/packages/server/src/__tests__/json-store.test.ts +70 -0
- package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
- package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
- package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
- package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
- package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
- package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
- package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
- package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
- package/packages/server/src/__tests__/package-routes.test.ts +172 -0
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
- package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
- package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
- package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
- package/packages/server/src/__tests__/process-manager.test.ts +184 -0
- package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
- package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
- package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
- package/packages/server/src/__tests__/server-pid.test.ts +89 -0
- package/packages/server/src/__tests__/session-api.test.ts +244 -0
- package/packages/server/src/__tests__/session-diff.test.ts +138 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
- package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
- package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
- package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
- package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
- package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
- package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
- package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
- package/packages/server/src/__tests__/tunnel.test.ts +206 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
- package/packages/server/src/auth-plugin.ts +302 -0
- package/packages/server/src/auth.ts +323 -0
- package/packages/server/src/browse.ts +55 -0
- package/packages/server/src/browser-gateway.ts +495 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
- package/packages/server/src/browser-handlers/handler-context.ts +45 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
- package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
- package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
- package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
- package/packages/server/src/cli.ts +347 -0
- package/packages/server/src/config-api.ts +130 -0
- package/packages/server/src/directory-service.ts +162 -0
- package/packages/server/src/editor-detection.ts +60 -0
- package/packages/server/src/editor-manager.ts +352 -0
- package/packages/server/src/editor-proxy.ts +134 -0
- package/packages/server/src/editor-registry.ts +108 -0
- package/packages/server/src/event-status-extraction.ts +131 -0
- package/packages/server/src/event-wiring.ts +589 -0
- package/packages/server/src/extension-register.ts +92 -0
- package/packages/server/src/git-operations.ts +200 -0
- package/packages/server/src/headless-pid-registry.ts +207 -0
- package/packages/server/src/idle-timer.ts +61 -0
- package/packages/server/src/json-store.ts +32 -0
- package/packages/server/src/localhost-guard.ts +117 -0
- package/packages/server/src/memory-event-store.ts +193 -0
- package/packages/server/src/memory-session-manager.ts +123 -0
- package/packages/server/src/meta-persistence.ts +64 -0
- package/packages/server/src/migrate-persistence.ts +195 -0
- package/packages/server/src/npm-search-proxy.ts +143 -0
- package/packages/server/src/oauth-callback-server.ts +177 -0
- package/packages/server/src/openspec-archive.ts +60 -0
- package/packages/server/src/package-manager-wrapper.ts +200 -0
- package/packages/server/src/pending-fork-registry.ts +53 -0
- package/packages/server/src/pending-load-manager.ts +110 -0
- package/packages/server/src/pending-resume-registry.ts +69 -0
- package/packages/server/src/pi-gateway.ts +419 -0
- package/packages/server/src/pi-resource-scanner.ts +369 -0
- package/packages/server/src/preferences-store.ts +116 -0
- package/packages/server/src/process-manager.ts +311 -0
- package/packages/server/src/provider-auth-handlers.ts +438 -0
- package/packages/server/src/provider-auth-storage.ts +200 -0
- package/packages/server/src/resolve-path.ts +12 -0
- package/packages/server/src/routes/editor-routes.ts +86 -0
- package/packages/server/src/routes/file-routes.ts +116 -0
- package/packages/server/src/routes/git-routes.ts +89 -0
- package/packages/server/src/routes/openspec-routes.ts +99 -0
- package/packages/server/src/routes/package-routes.ts +172 -0
- package/packages/server/src/routes/provider-auth-routes.ts +244 -0
- package/packages/server/src/routes/provider-routes.ts +101 -0
- package/packages/server/src/routes/route-deps.ts +23 -0
- package/packages/server/src/routes/session-routes.ts +91 -0
- package/packages/server/src/routes/system-routes.ts +271 -0
- package/packages/server/src/server-pid.ts +84 -0
- package/packages/server/src/server.ts +554 -0
- package/packages/server/src/session-api.ts +330 -0
- package/packages/server/src/session-bootstrap.ts +80 -0
- package/packages/server/src/session-diff.ts +178 -0
- package/packages/server/src/session-discovery.ts +134 -0
- package/packages/server/src/session-file-reader.ts +135 -0
- package/packages/server/src/session-order-manager.ts +73 -0
- package/packages/server/src/session-scanner.ts +233 -0
- package/packages/server/src/session-stats-reader.ts +99 -0
- package/packages/server/src/terminal-gateway.ts +51 -0
- package/packages/server/src/terminal-manager.ts +241 -0
- package/packages/server/src/tunnel.ts +329 -0
- package/packages/server/tsconfig.json +11 -0
- package/packages/shared/package.json +15 -0
- package/packages/shared/src/__tests__/config.test.ts +358 -0
- package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
- package/packages/shared/src/__tests__/protocol.test.ts +243 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
- package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
- package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
- package/packages/shared/src/archive-types.ts +11 -0
- package/packages/shared/src/browser-protocol.ts +534 -0
- package/packages/shared/src/config.ts +245 -0
- package/packages/shared/src/diff-types.ts +41 -0
- package/packages/shared/src/editor-types.ts +18 -0
- package/packages/shared/src/mdns-discovery.ts +248 -0
- package/packages/shared/src/openspec-activity-detector.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +96 -0
- package/packages/shared/src/protocol.ts +369 -0
- package/packages/shared/src/resolve-jiti.ts +43 -0
- package/packages/shared/src/rest-api.ts +255 -0
- package/packages/shared/src/server-identity.ts +51 -0
- package/packages/shared/src/session-meta.ts +86 -0
- package/packages/shared/src/state-replay.ts +174 -0
- package/packages/shared/src/stats-extractor.ts +54 -0
- package/packages/shared/src/terminal-types.ts +18 -0
- package/packages/shared/src/types.ts +351 -0
- 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
|
+
}
|