/** * Dashboard HTTP + WebSocket server. */ import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; import cors from "@fastify/cors"; import compress from "@fastify/compress"; import path from "node:path"; import { fileURLToPath } from "node:url"; import os from "node:os"; import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { createMemoryEventStore, type EventStore } from "./memory-event-store.js"; import { createMemorySessionManager, type SessionManager } from "./memory-session-manager.js"; import { createPiGateway, type PiGateway } from "./pi-gateway.js"; import { createBrowserGateway, type BrowserGateway } from "./browser-gateway.js"; import { pluginIntentCache } from "./plugin-intent-cache.js"; import { createPreferencesStore, type PreferencesStore } from "./preferences-store.js"; import { createMetaPersistence, type MetaPersistence } from "./meta-persistence.js"; import { createSessionOrderManager, type SessionOrderManager } from "./session-order-manager.js"; import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js"; import { createPendingClientCorrelations } from "./pending-client-correlations.js"; import { createPendingAttachRegistry } from "./pending-attach-registry.js"; import { createPendingResumeIntentRegistry } from "./pending-resume-intent-registry.js"; import { applyReattachPolicy } from "./reattach-placement.js"; // pending-load-manager removed — server loads sessions directly via DirectoryService import { createDirectoryService, type DirectoryService } from "./directory-service.js"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js"; import { writePid, removePid } from "./server-pid.js"; import { advertiseDashboard, stopAdvertising, createBrowser, type DashboardBrowser, type DiscoveredServer } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js"; import { wireEvents } from "./event-wiring.js"; import { createIdleTimer } from "./idle-timer.js"; import { discoverAndBroadcastSessions } from "./session-bootstrap.js"; import { scanAllSessions } from "./session-scanner.js"; import { needsMigration, runMigration } from "./migrate-persistence.js"; import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel, scavengeOrphanZrokProcesses, getTunnelUrl } from "./tunnel.js"; import { startTunnelWatchdog, stopTunnelWatchdog } from "./tunnel-watchdog.js"; import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js"; import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js"; import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js"; import type { AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js"; import { loadConfig, CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js"; import { registerSessionApi } from "./session-api.js"; import { registerManifestRoute } from "./routes/manifest-route.js"; import { registerSessionRoutes } from "./routes/session-routes.js"; import { registerGitRoutes } from "./routes/git-routes.js"; import { registerFileRoutes } from "./routes/file-routes.js"; import { registerOpenSpecRoutes } from "./routes/openspec-routes.js"; import { registerOpenSpecGroupRoutes } from "./routes/openspec-group-routes.js"; import { createOpenSpecGroupStore, joinGroupIdsToOpenSpecData } from "./openspec-group-store.js"; import { registerSystemRoutes } from "./routes/system-routes.js"; import { registerDoctorRoutes } from "./routes/doctor-routes.js"; import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js"; import { registerPackageRoutes } from "./routes/package-routes.js"; import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js"; import { registerPiCoreRoutes } from "./routes/pi-core-routes.js"; import { registerPiChangelogRoutes } from "./routes/pi-changelog-routes.js"; import { PiCoreChecker } from "./pi-core-checker.js"; import { PiCoreUpdater } from "./pi-core-updater.js"; import { registerToolRoutes } from "./routes/tool-routes.js"; import { registerJjRoutes } from "./routes/jj-routes.js"; import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js"; import { registerProviderRoutes } from "./routes/provider-routes.js"; import { PackageManagerWrapper } from "./package-manager-wrapper.js"; import { createEditorManager, type EditorManager } from "./editor-manager.js"; import { createEditorPidRegistry } from "./editor-pid-registry.js"; import { registerEditorRoutes } from "./routes/editor-routes.js"; import { registerKnownServersRoutes } from "./routes/known-servers-routes.js"; import { registerPluginConfigRoutes } from "./routes/plugin-config-routes.js"; import { registerPluginActivationRoutes } from "./routes/plugin-activation-routes.js"; import { createModelProxyAuthGate } from "./model-proxy/auth-gate.js"; import { registerModelProxyRoutes } from "./routes/model-proxy-routes.js"; import { registerModelProxyApiKeyRoutes } from "./routes/model-proxy-api-key-routes.js"; import { registerModelProxyRefreshRoutes } from "./routes/model-proxy-refresh-routes.js"; import { getModelRegistry, getStreamSimpleFn } from "./model-proxy/registry-singleton.js"; import { writeConfigPartial } from "./config-api.js"; import { loadServerEntries, discoverPlugins, getPluginStatusStore, refreshRequirementProbesFor } from "@blackbelt-technology/dashboard-plugin-runtime/server"; import { createServerPluginContext } from "@blackbelt-technology/dashboard-plugin-runtime/server"; import { getPluginConfig as getPluginConfigFromFile } from "@blackbelt-technology/pi-dashboard-shared/config.js"; import { registerAllPluginBridges, reconcilePluginBridgePackages, } from "@blackbelt-technology/pi-dashboard-shared/plugin-bridge-register.js"; import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js"; import { detectCodeServerBinary } from "./editor-detection.js"; export interface ServerConfig { port: number; piPort: number; dev: boolean; autoShutdown: boolean; shutdownIdleSeconds: number; tunnel: boolean; tunnelReservedToken?: string; tunnelWatchdog?: { enabled: boolean; intervalMs: number; failureThreshold: number; probeTimeoutMs: number; }; authConfig?: AuthConfig; /** Override WS ping interval for pi-gateway (ms). Default 60000. Set 0 to disable. */ pingInterval?: number; /** Memory limit overrides from config */ maxEventsPerSession?: number; maxStringFieldSize?: number; maxWsBufferBytes?: number; /** Editor (code-server) config */ editor: import("@blackbelt-technology/pi-dashboard-shared/config.js").EditorConfig; /** OpenSpec polling config (interval, concurrency, change detection, jitter) */ openspec?: import("@blackbelt-technology/pi-dashboard-shared/config.js").OpenSpecPollConfig; /** Reattach-placement policy applied when a bridge re-registers after * a dashboard restart. Defaults to `"always"`. * See change: reattach-move-to-front. */ reattachPlacement?: import("@blackbelt-technology/pi-dashboard-shared/config.js").ReattachPlacement; /** Merged trusted networks from config */ resolvedTrustedNetworks?: string[]; /** CORS allowed origins from config */ corsAllowedOrigins?: string[]; } export interface DashboardServer { start(): Promise; stop(): Promise; sessionManager: SessionManager; eventStore: EventStore; browserGateway: BrowserGateway; /** Resolved HTTP port after start() (useful for port:0 in tests). Returns null if not listening. */ httpPort(): number | null; /** Resolved pi gateway port after start(). Returns null if not listening. */ piPort(): number | null; } export async function createServer(config: ServerConfig): Promise { // Ensure bridge extension is registered in pi's global settings // (needed for bundled installs where pi can't discover it from package.json) // // __serverDir = /packages/server/src // baseDir MUST be / so findBundledExtension resolves // /packages/extension. Three levels up, not two. const __serverDir = path.dirname(fileURLToPath(import.meta.url)); const extPath = findBundledExtension(path.resolve(__serverDir, "..", "..", "..")); if (extPath) { registerBridgeExtension(extPath); console.log(`[dashboard] Bridge extension registered: ${extPath}`); } else { console.warn(`[dashboard] Bridge extension NOT found (searched from ${__serverDir}). ` + `Sessions will spawn but never connect to the gateway. ` + `Manually add the extension path to ~/.pi/agent/settings.json packages[] as a workaround.`); } // Run migration from sessions.json + state.json if needed if (needsMigration()) { const migResult = runMigration(); console.log(`[dashboard] Migration complete: ${migResult.sessionsWritten} sessions, ${migResult.hiddenApplied} hidden applied, ${migResult.hiddenOrphaned} orphaned, renamed: ${migResult.oldFilesRenamed.join(", ")}`); } const preferencesStore = createPreferencesStore(); const sessionManager = createMemorySessionManager(); const metaPersistence = createMetaPersistence(); const sessionOrderManager = createSessionOrderManager(preferencesStore); const pendingForkRegistry = createPendingForkRegistry(); // Maps spawnToken → originating browser requestId. Surfaced as // session_added.spawnRequestId so the client can auto-select / dismiss // its placeholder by exact correlation. See change: spawn-correlation-token. const pendingClientCorrelations = createPendingClientCorrelations(); // Restore sessions from per-session .meta.json files (scans ~/.pi/agent/sessions/) const scanResult = scanAllSessions(); for (const session of scanResult.sessions) { const restored = { ...session, dataUnavailable: true }; if (restored.status !== "ended") { restored.status = "ended"; restored.endedAt = restored.endedAt ?? Date.now(); } sessionManager.restore(restored); } if (scanResult.cacheUpdates > 0) { console.log(`[dashboard] Session scan: ${scanResult.sessions.length} sessions, ${scanResult.cacheUpdates} cache updates`); } // Save per-session .meta.json on any change sessionManager.onChange = (sessionId: string, ctx) => { const session = sessionManager.get(sessionId); if (!session?.sessionFile) return; metaPersistence.save(session.sessionFile, { source: session.source, name: session.name, attachedProposal: session.attachedProposal, hidden: session.hidden, cwd: session.cwd, status: session.status, startedAt: session.startedAt, endedAt: session.endedAt, model: session.model, thinkingLevel: session.thinkingLevel, tokensIn: session.tokensIn, tokensOut: session.tokensOut, cacheRead: session.cacheRead, cacheWrite: session.cacheWrite, cost: session.cost, contextTokens: session.contextTokens ?? undefined, contextWindow: session.contextWindow, firstMessage: session.firstMessage, // Persist unread bit so it survives server restart. // See change: session-card-unread-stripes. unread: session.unread, cachedAt: Date.now(), }); // When a session ends, drop its id from the persisted drag-reorder list // for that cwd. Drag-reorder is meaningful for live sessions only; ended // ones must fall to the bottom in their natural endedAt order (rendered // top-of-bucket on most-recent-first) rather than retaining a position // that interleaves them with active sessions. // See change: pin-and-search-sessions, top-of-tier-on-status-change. // Status-transition tracking: prune+broadcast runs ONCE per // transition to ended. Subsequent `update()` calls on an already- // ended session (e.g. heartbeat tail, click-induced state sync, // late events from the bridge) do NOT re-trigger the prune — // otherwise the card visibly jumps to the tail of the ended group // every time the user interacts with it. // See change: pin-and-search-sessions. const wasEnded = endedSessionIds.has(sessionId); const isEnded = session.status === "ended"; if (isEnded && !wasEnded) { // Just transitioned alive→ended. endedSessionIds.add(sessionId); const orderBefore = sessionOrderManager.getOrder(session.cwd) ?? []; sessionOrderManager.remove(session.cwd, sessionId); const orderAfter = sessionOrderManager.getOrder(session.cwd) ?? []; if (orderBefore.length !== orderAfter.length) { browserGateway.broadcastToAll({ type: "sessions_reordered", cwd: session.cwd, sessionIds: orderAfter, }); } } else if (!isEnded && wasEnded) { // Resume: ended→alive. Three real outcomes land here, distinguished // by the value `pendingResumeIntents.consume(...)` returns: // "front" — Resume button, REST resume, prompt-auto-resume. // User wants the card surfaced at the top of alive. // "keep" — Drag-to-resume. The dropped slot was already // persisted via `reorder_sessions`; do NOT clobber it. // null — Bridge auto-reattach (dashboard restarted, pi // process still alive, no user intent tagged). // Preserve the user's existing layout. // We always clear the transition tracker so a future alive→ended // for this session fires correctly. // See changes: preserve-session-order-on-reboot, // top-of-tier-on-status-change, // differentiate-resume-intent-by-trigger. endedSessionIds.delete(sessionId); const intent = pendingResumeIntents.consume(sessionId); if (intent === null) { // No user-driven resume intent. If this register carried // `registerReason: "reattach"`, apply the configured // `reattachPlacement` policy. Otherwise (legacy bridge or // genuine null reattach with `"preserve"` semantics) leave // order alone. // See change: reattach-move-to-front. if (ctx?.registerReason === "reattach") { applyReattachPolicy( sessionId, session.cwd, config.reattachPlacement ?? "always", { sessionManager, sessionOrderManager, browserGateway }, ctx.priorStatus, ); } return; } if (intent === "keep") { // Drag-to-resume — dropped slot wins; the earlier reorder_sessions // already broadcast. Do NOT mutate sessionOrder, do NOT broadcast. // Registry intent overrides any `registerReason: "reattach"`. return; } // intent === "front": move-to-front so the just-resumed card // surfaces at the top of the alive tier, even on repeated end → // resume cycles where the id might still be in the order. // Registry intent overrides any `registerReason: "reattach"`. sessionOrderManager.moveToFront(session.cwd, sessionId); const next = sessionOrderManager.getOrder(session.cwd) ?? []; browserGateway.broadcastToAll({ type: "sessions_reordered", cwd: session.cwd, sessionIds: next, }); } else if (!isEnded && !wasEnded && ctx?.registerReason === "reattach") { // Reattach of a session that was persisted as alive (the common // case after `pi-dashboard restart` while pi processes stay // alive). Neither alive→ended nor ended→alive transition fires; // we apply the reattach policy directly here. // // Defensive: a registry intent for an alive session should not // happen in practice (handleResumeSession only tags intents for // ended sessions), but per spec scenario "Registry intent wins // over reattach" we honor it if present and skip the policy. // See change: reattach-move-to-front. const intent = pendingResumeIntents.consume(sessionId); if (intent === "front") { sessionOrderManager.moveToFront(session.cwd, sessionId); const next = sessionOrderManager.getOrder(session.cwd) ?? []; browserGateway.broadcastToAll({ type: "sessions_reordered", cwd: session.cwd, sessionIds: next, }); } else if (intent === "keep") { // Honor dropped slot; do nothing. } else { applyReattachPolicy( sessionId, session.cwd, config.reattachPlacement ?? "always", { sessionManager, sessionOrderManager, browserGateway }, ctx.priorStatus, ); } } }; // Track which session ids we've seen as ended at least once, so the // onChange hook can detect actual alive→ended transitions vs. mere // re-emits of the ended state. const endedSessionIds = new Set( sessionManager.listAll().filter((s) => s.status === "ended").map((s) => s.id), ); // Startup reconciliation: persisted `sessionOrder` may contain ended // session ids from before the alive→ended prune was implemented. Strip // them now so the next render sees a consistent state where ended ids // never appear in the order pass. // See change: pin-and-search-sessions. for (const [cwd, ids] of Object.entries(sessionOrderManager.getAllOrders())) { const aliveIds = ids.filter((id) => { const s = sessionManager.get(id); // Keep ids we don't know about — they may belong to other cwds or // be live but not yet registered. Strip only the ones explicitly // marked ended. return !s || s.status !== "ended"; }); if (aliveIds.length !== ids.length) { sessionOrderManager.reorder(cwd, aliveIds); } } // Track cwds with pending dashboard-spawned sessions (for writing .meta.json). // Uses a counter per cwd to handle multiple spawns and avoid reconnects consuming entries. const pendingDashboardSpawns = new Map(); // Pending spawn-with-attach intents (cwd → FIFO queue of changeNames). // Consumed in event-wiring.ts on session_register. See change: // add-folder-task-checker-and-spawn-attach. const pendingAttachRegistry = createPendingAttachRegistry(); // Pending user-initiated resume intents (sessionId → timestamp). // Consumed by `sessionManager.onChange` in the ended→alive branch to // gate the sessionOrder mutation behind explicit user intent so that // bridge auto-reattach on dashboard reboot does not mutate the user's // drag-order. // See change: preserve-session-order-on-reboot. const pendingResumeIntents = createPendingResumeIntentRegistry(); // Track known session IDs so we can distinguish new sessions from reconnections. const knownSessionIds = new Set(); // Populate from persisted sessions for (const s of sessionManager.listAll()) { knownSessionIds.add(s.id); } // Create the OpenSpec change-grouping store BEFORE the directory-service so // the latter can join `groupId` into every `OpenSpecChange` it produces. // See change: add-openspec-change-grouping (task 4.2). const openspecGroupStore = createOpenSpecGroupStore(); const directoryService = createDirectoryService( preferencesStore, sessionManager, config.openspec, { enrichOpenSpecData: async (cwd, data) => { try { const file = await openspecGroupStore.read(cwd); return joinGroupIdsToOpenSpecData(data, file.assignments); } catch { // Bad file (e.g., unsupported schemaVersion) — fall back to unjoined. return data; } }, }, ); // mDNS peer discovery state let mdnsBrowser: DashboardBrowser | null = null; // Optional second-port Fastify instance for model proxy (/v1/*) let secondFastify: Awaited> | null = null; const peerServers = new Map(); const piGateway = createPiGateway(sessionManager, { ...(config.pingInterval !== undefined ? { pingInterval: config.pingInterval } : {}), }); // Create event store with pinning callback and configurable limits const eventStore = createMemoryEventStore( (sessionId) => piGateway.isSessionConnected(sessionId) || browserGateway.getSubscriberCount(sessionId) > 0, undefined, // maxCachedSessions (use default) config.maxEventsPerSession, config.maxStringFieldSize, ); // Create terminal manager with exit callback const terminalManager = createTerminalManager({ onExit: (terminalId) => { // Find and remove from session order const allOrders = sessionOrderManager.getAllOrders(); for (const [cwd, ids] of Object.entries(allOrders)) { if (ids.includes(terminalId)) { sessionOrderManager.remove(cwd, terminalId); break; } } browserGateway.broadcastToAll({ type: "terminal_removed", terminalId }); }, }); const terminalGateway = createTerminalGateway(terminalManager); // Create editor manager for code-server instances const editorDetection = detectCodeServerBinary(config.editor); const editorPidRegistry = createEditorPidRegistry(); const editorManager = createEditorManager({ config: config.editor, detection: editorDetection, pidRegistry: editorPidRegistry, onStatusChange: (cwd, id, status) => { browserGateway.broadcastToAll({ type: "editor_status", cwd, id, status }); }, }); const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes, pendingAttachRegistry, pendingResumeIntents, pendingClientCorrelations); // Resolve package version once at startup const __require = createRequire(import.meta.url); let pkgVersion = "unknown"; try { pkgVersion = __require("../../package.json").version ?? "unknown"; } catch {} const selfHostname = os.hostname(); // Send this server + discovered peers to new browser connections browserGateway.onConnect = (ws) => { const selfServer: DiscoveredServer = { host: selfHostname, port: config.port, piPort: config.piPort, version: pkgVersion, pid: process.pid, isLocal: true, source: "mdns", }; const all = [selfServer, ...Array.from(peerServers.values())]; browserGateway.sendToClient(ws, { type: "servers_discovered", servers: all }); }; // Wire up event forwarding from pi gateway to browser gateway wireEvents({ sessionManager, eventStore, piGateway, browserGateway, sessionOrderManager, pendingForkRegistry, directoryService, knownSessionIds, pendingDashboardSpawns, pendingAttachRegistry, viewedSessionTracker: browserGateway.viewedSessionTracker, pendingClientCorrelations, }); // Auto-shutdown idle timer // Active terminals keep the server alive even when no pi sessions are // attached. See change: fix-terminal-half-height-dual-mount. const idleTimer = createIdleTimer(config, piGateway, () => terminalManager.list().length > 0); const fastify = Fastify({ logger: false, keepAliveTimeout: 30_000, connectionTimeout: 10_000, }); // Compression: gzip/deflate for HTTP responses. Critical for large client // bundles (~3 MB JS) served over tunnels like zrok which abort big transfers. // Brotli is intentionally disabled — zrok's free public proxy has been // observed to truncate/stream-reset `content-encoding: br` responses under // parallel browser load (curl succeeds, Chrome reports ERR_ABORTED 500). // gzip is universally supported and round-trips cleanly through zrok. // threshold=1024 skips tiny responses; global=true compresses all routes. await fastify.register(compress, { global: true, threshold: 1024, encodings: ["gzip", "deflate"], }); // CORS: allow localhost, the active zrok tunnel URL, any *.share.zrok.io // host (so tunnel URL rotation doesn't break loads), and configured origins. // // Two critical correctness notes: // (1) Vite emits `