// MUST be first — patches Bun.spawn to redirect python.exe → pythonw.exe on // Windows so the eval-py kernel doesn't pop a console window. See the file's // own docblock for the full rationale and shape. import "./silence-python.ts"; // MUST be before any SDK session is created — patches FileSessionStorage's // rename to retry transient Windows EPERM/EBUSY/EACCES (the SDK rewrites the // session JSONL atomically on /rename and a single transient code from AV / // Search Indexer otherwise kills the session). No-op on non-Windows. import "./bridge/fs-windows-rename-fix.ts"; import { loadManagedEnvIntoProcess } from "./env-store.ts"; loadManagedEnvIntoProcess(); import type { Server, ServerWebSocket } from "bun"; import * as path from "node:path"; import { InProcessAgentBridge } from "./bridge/in-process.ts"; import { RoutinesRunner } from "./routines-runner.ts"; import { closeDb, openDb } from "./db/index.ts"; import { loadConfig } from "./config.ts"; import { logger } from "./log.ts"; import { resolveBunExecutable } from "./runtime-bun.ts"; import { primeUpdateCheckOnBoot } from "./update-check.ts"; import { buildRouter } from "./routes.ts"; import { WsHub, type ConnectionData } from "./ws.ts"; import { MarketplaceService } from "./marketplace-service.ts"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { SkillsService } from "./skills-service.ts"; import { startSkillsWatcher } from "./skills-watcher.ts"; import { KbService, resolveKbRoot } from "./kb-service.ts"; import { startKbWatcher } from "./kb-watcher.ts"; import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls"; import { KbProtocolHandler } from "./kb-protocol.ts"; import { installStarterSkills } from "./starter-skills.ts"; import { installStarterExtensions } from "./starter-extensions.ts"; import { buildDefaultBridgeSupervisor } from "./bridge-supervisor.ts"; import { BrowserNotificationChannel, notificationService, } from "./notifications/index.ts"; import type { RestartServerResponse } from "@omp-deck/protocol"; const log = logger("server"); async function main(): Promise { const config = loadConfig(); log.info(`omp-deck server starting`, { host: config.host, port: config.port, defaultCwd: config.defaultCwd, webDist: config.webDist, devMode: config.devMode, }); // Tell the maintenance-gate extension (~/.omp/agent/extensions/maintenance-gate) // that every session this server spawns IS a deck-managed org root, regardless // of session cwd. Without this, the extension stays inactive in deck sessions // because cwd rarely has the flat-file org markers (inbox/, tasks/, knowledge/) // that the upstream detector looks for. Routine agent subprocesses inherit // this env via Bun.spawn defaults, so a single set here covers both surfaces. // // Honors OMP_DECK_MAINTENANCE_GATE_DISABLED (set via Settings → Orientation): // when truthy we don't set the org root, so even an unaltered installed copy // of the extension stays inactive. The extension itself also checks the flag. const gateDisabledRaw = (process.env.OMP_DECK_MAINTENANCE_GATE_DISABLED ?? "").trim().toLowerCase(); const gateDisabled = ["1", "true", "yes", "on"].includes(gateDisabledRaw); if (!process.env.OMP_DECK_ORG_ROOT && !gateDisabled) { process.env.OMP_DECK_ORG_ROOT = resolveKbRoot(); } // Register the deck's `kb://` URI handler on the SDK's process-global // router so `read kb://system/foo.md` resolves the same way the user's // configured KB root (OMP_DECK_KB_ROOT or ~/kb) is served over REST. // MUST run before the first `createAgentSession` — the router is a // process singleton consulted by the `read` tool on every call. InternalUrlRouter.instance().register(new KbProtocolHandler()); openDb({ path: config.dbPath }); // Initialize the SDK's global `theme` so tools that reference symbols // (e.g. ask -> getDoneOptionLabel -> `theme.status.success`) don't throw // "undefined is not an object (evaluating 'theme.status')" when invoked // from the deck. `dark` is a built-in theme JSON so no filesystem touch. // Without this the `ask` tool fails at the first `askSingleQuestion` // call, even though the deck UI doesn't render any SDK glyphs. try { const darkTheme = await getThemeByName("dark"); if (darkTheme) setThemeInstance(darkTheme); } catch (err) { log.warn(`SDK theme init failed; ask tool labels may not render`, err); } // Sync bundled starter skills into ~/.omp/agent/skills/ before the watcher // spins up. Idempotent — never overwrites a user-edited target — so this // is safe on every boot. Disable with OMP_DECK_INSTALL_STARTER_SKILLS=0. await installStarterSkills(); await installStarterExtensions(); // Register the default browser notification channel. It broadcasts a // `notification` ServerFrame to every connected web client. Future channels // (telegram, email, push) self-register here without engine changes. notificationService.register(new BrowserNotificationChannel()); const bridge = new InProcessAgentBridge({ idleTimeoutMs: config.idleTimeoutMs, autoStartCommand: config.autoStartCommand, }); const routinesRunner = new RoutinesRunner(); routinesRunner.start(); let server: Server; const supervisor = buildDefaultBridgeSupervisor(); const marketplaceService = new MarketplaceService(); const skillsService = new SkillsService(config, marketplaceService); const kbService = new KbService({ root: resolveKbRoot() }); const router = buildRouter( bridge, config, routinesRunner, supervisor, marketplaceService, skillsService, kbService, { restartServer: () => scheduleRestart(server) }, ); const skillsWatcherDispose = startSkillsWatcher(config); const kbWatcherDispose = startKbWatcher(kbService); const ws = new WsHub(bridge); server = Bun.serve({ hostname: config.host, port: config.port, fetch(req, srv) { const url = new URL(req.url); if (url.pathname === "/ws") { const data = ws.createConnectionData(); const upgraded = srv.upgrade(req, { data }); if (upgraded) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } if (url.pathname.startsWith("/api/")) { const trimmed = new URL(req.url); trimmed.pathname = url.pathname.slice(4) || "/"; return router.fetch(new Request(trimmed.toString(), req)); } // Pasted-image uploads. The uploads route returns URLs rooted at // `/uploads/...` so they work for both browser and agent- // written markdown. Stream the file straight off disk; reject path // traversal the same way the SPA static handler does. if (url.pathname.startsWith("/uploads/")) { return serveUpload(req, config.uploadsRoot); } // Serve built web assets if a dist directory is available; otherwise // fall back to the landing stub. Vite dev server proxies through us // for /api and /ws, so its own routes never reach this branch. if (config.webDist) { return serveStatic(req, config.webDist); } if (url.pathname === "/" || url.pathname === "/index.html") { return new Response(LANDING_HTML, { headers: { "content-type": "text/html; charset=utf-8" }, }); } return new Response("not found", { status: 404 }); }, websocket: { open(socket: ServerWebSocket) { ws.onOpen(socket); }, async message(socket: ServerWebSocket, raw) { await ws.onMessage(socket, raw as string | Buffer); }, close(socket: ServerWebSocket) { ws.onClose(socket); }, perMessageDeflate: false, }, }); log.info(`listening on http://${server.hostname}:${server.port}`); // Background: prime the npm registry update check cache so the // /api/version route returns a real answer on first call. Fire-and- // forget; failures are logged and ignored. primeUpdateCheckOnBoot(); let shuttingDown = false; async function safeShutdown(reason: string): Promise { if (shuttingDown) return; shuttingDown = true; log.info(`shutdown via ${reason}`); try { routinesRunner.dispose(); } catch (err) { log.error(`runner dispose threw`, err); } try { skillsWatcherDispose(); } catch (err) { log.error(`skills watcher dispose threw`, err); } try { kbWatcherDispose(); } catch (err) { log.error(`kb watcher dispose threw`, err); } try { await supervisor.shutdown(); } catch (err) { log.error(`bridge supervisor shutdown threw`, err); } try { await bridge.dispose(); } catch (err) { log.error(`bridge dispose threw`, err); } try { closeDb(); } catch (err) { log.error(`db close threw`, err); } } process.once("SIGINT", () => { void safeShutdown("SIGINT").then(() => { server.stop(true); process.exit(0); }); }); process.once("SIGTERM", () => { void safeShutdown("SIGTERM").then(() => { server.stop(true); process.exit(0); }); }); // Last-resort safety net for non-signal exit paths (Bun crash, unhandled // rejection escalated to abort, etc). beforeExit fires when the loop drains. process.on("beforeExit", () => { void safeShutdown("beforeExit"); }); } function scheduleRestart(server: Server): RestartServerResponse { // `process.execPath` directly here would suffer the same staleness issue // as the bridge supervisor (issue #6 — user's bun moves between deck // start and restart). Route through the shared resolver which falls back // to a PATH lookup if the captured execPath is gone. const cmd = [resolveBunExecutable(), ...process.argv.slice(1)]; const cwd = process.cwd(); setTimeout(() => { log.info(`restart requested`, { cmd }); try { server.stop(true); } catch (err) { log.warn(`server stop before restart failed`, err); } const env = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), ); const child = Bun.spawn({ cmd, cwd, env, stdin: "ignore", stdout: "inherit", stderr: "inherit", detached: true, }); child.unref(); setTimeout(() => process.exit(0), 80); }, 100); return { ok: true, message: "Restart scheduled" }; } const LANDING_HTML = ` omp-deck server

omp-deck server

Backend is running. The browser UI is served by the @omp-deck/web Vite dev server (typically http://127.0.0.1:5173 in dev), or the built static assets in production.

API base: /api    WebSocket: /ws

`; main().catch((err) => { log.error(`fatal`, err); process.exit(1); }); async function serveStatic(req: Request, root: string): Promise { const url = new URL(req.url); let rel = decodeURIComponent(url.pathname.replace(/^\/+/, "")); if (rel === "" || rel === "index.html") rel = "index.html"; // Reject path traversal. if (rel.includes("..")) return new Response("forbidden", { status: 403 }); const full = path.join(root, rel); const resolved = path.resolve(full); const rootResolved = path.resolve(root); if (!resolved.startsWith(rootResolved)) { return new Response("forbidden", { status: 403 }); } const direct = Bun.file(resolved); if (await direct.exists()) { return new Response(direct); } // SPA fallback — serve index.html so client-side routing works. const index = Bun.file(path.join(rootResolved, "index.html")); if (await index.exists()) { return new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } }); } return new Response("not found", { status: 404 }); } /** * Serve a file from the uploads root. URL prefix `/uploads/` strips before * resolving, so `/uploads/2026/05/abc.png` maps to `/2026/05/abc.png`. * No SPA fallback — a 404 here means the URL is bad. * * Path-traversal protection mirrors `serveStatic`: any `..` in the relative * path is rejected outright, and the resolved absolute path must remain * inside `uploadsRoot`. */ async function serveUpload(req: Request, root: string): Promise { const url = new URL(req.url); const rel = decodeURIComponent(url.pathname.replace(/^\/+uploads\/+/, "")); if (!rel || rel.includes("..")) return new Response("forbidden", { status: 403 }); const resolved = path.resolve(path.join(root, rel)); const rootResolved = path.resolve(root); if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) { return new Response("forbidden", { status: 403 }); } const file = Bun.file(resolved); if (await file.exists()) { // Long-lived caching is safe because the on-disk filename is content- // addressed (sha256 prefix). If the bytes change, the URL changes. return new Response(file, { headers: { "cache-control": "public, max-age=31536000, immutable" }, }); } return new Response("not found", { status: 404 }); }