import { existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { extension as authApikeyExt } from "@dispatch/auth-apikey"; import { extension as cacheWarmingExt } from "@dispatch/cache-warming"; import { extension as conversationStoreExt } from "@dispatch/conversation-store"; import { createCredentialStoreExtension } from "@dispatch/credential-store"; import { createExecBackendExtension } from "@dispatch/exec-backend"; import { extension as heartbeatExt } from "@dispatch/heartbeat"; import { createJournalSink } from "@dispatch/journal-sink"; import { type ConfigAccess, createBus, createHost, createLogger, type EventsEmitter, type Extension, type HostDeps, type LogDeps, type PermissionGate, type ScheduledJob, type SecretsAccess, type StorageNamespace, } from "@dispatch/kernel"; // LSP temporarily disabled — crashes (unhandled JSON parse, ENOENT on // transient .old_modules dirs) and a memory leak. Re-enable after fix. // import { extension as lspExt } from "@dispatch/lsp"; import { extension as mcpExt } from "@dispatch/mcp"; import { extension as messageQueueExt } from "@dispatch/message-queue"; import { extension as providerConcurrencyExt } from "@dispatch/provider-concurrency"; import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; import { extension as providerUmansExt } from "@dispatch/provider-umans"; import { type MemorySample, memorySampleAttributes, extension as sessionOrchestratorExt, sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; import { extension as skillsExt } from "@dispatch/skills"; import { extension as sshExt } from "@dispatch/ssh"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions"; import { createSurfaceRegistryExtension } from "@dispatch/surface-registry"; import { extension as systemPromptExt } from "@dispatch/system-prompt"; import { extension as throughputStoreExt } from "@dispatch/throughput-store"; import { extension as todoExt } from "@dispatch/todo"; import { extension as toolEditFileExt } from "@dispatch/tool-edit-file"; import { extension as toolReadFileExt } from "@dispatch/tool-read-file"; import { extension as toolShellExt } from "@dispatch/tool-shell"; import { extension as toolWebSearchExt } from "@dispatch/tool-web-search"; import { extension as toolWriteFileExt } from "@dispatch/tool-write-file"; import { extension as toolYoutubeTranscriptExt } from "@dispatch/tool-youtube-transcript"; import { createTransportHttpExtension } from "@dispatch/transport-http"; import { createTransportWsExtension } from "@dispatch/transport-ws"; import { extension as visionHandoffExt } from "@dispatch/vision-handoff"; import type { ChildHandle } from "./collector-supervisor.js"; import { createCollectorSupervisor } from "./collector-supervisor.js"; import { configMapToAccess, envToConfigMap } from "./config.js"; import { loadExternalExtensions } from "./load-external.js"; import { startMemoryTelemetry } from "./mem-telemetry.js"; function createEmptySecrets(): SecretsAccess { return { get: async () => null, set: async () => {}, delete: async () => {}, }; } function createAllowAllPermissions(): PermissionGate { return { check: async () => ({ allowed: true }), }; } function createNoopScheduler(): { readonly register: (job: ScheduledJob) => void } { return { register: () => {} }; } function createNoopEvents(): EventsEmitter { return { emit: () => {} }; } // Core extensions EXCEPT the credential-store, which is assembled in boot() so // its credential list can include any credentials backed by external providers // (e.g. a `claude` credential once the external Anthropic provider is loaded). const CORE_EXTENSIONS: readonly Extension[] = [ storageSqliteExt, conversationStoreExt, authApikeyExt, providerOpenaiCompatExt, providerUmansExt, providerConcurrencyExt, // exec-backend must precede the tool extensions that // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It // provides the ExecBackendResolver the tools resolve through; placing it // here keeps the activation DAG honest (it depends only on kernel). createExecBackendExtension(), toolEditFileExt, toolReadFileExt, toolShellExt, toolWriteFileExt, toolWebSearchExt, toolYoutubeTranscriptExt, throughputStoreExt, todoExt, messageQueueExt, mcpExt, sessionOrchestratorExt, skillsExt, systemPromptExt, cacheWarmingExt, // lspExt, // LSP temporarily disabled — see import above // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote // exec-backend factory + the ComputerService the HTTP routes delegate to. // Its lookups are lazy (tool-/request-time), but it is placed after // exec-backend and the tool extensions (alongside the other standard // tool-serving extensions) to keep the DAG honest — and before // transport-http, whose routes consume the ComputerService it provides. sshExt, // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the // HTTP routes delegate to. Placed before transport-http (which depends on // it) — mirrors how ssh precedes transport-http for the same reason. heartbeatExt, createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), createTransportWsExtension(), createLoadedExtensionsExtension(), ]; /** Parse the comma-separated list of external extension module specifiers. */ function parseExternalSpecifiers(env: Readonly>): string[] { return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "") .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); } async function boot(): Promise { const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson"; mkdirSync(dirname(journalPath), { recursive: true }); const logSink = createJournalSink({ path: journalPath }); const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() }; const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps); const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db"; // Only start the collector supervisor in dev mode (source files available). // Compiled binaries don't have the source tree, so the collector can't spawn. let supervisor: ReturnType | undefined; if (existsSync("packages/observability-collector/src/main.ts")) { supervisor = createCollectorSupervisor({ spawn: (cmd: string[]) => { const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); const handle: ChildHandle = { kill: (signal?: string) => proc.kill(signal as NodeJS.Signals), exited: proc.exited, }; return handle; }, journalPath, dbPath: traceDbPath, logger: logger.child({ extensionId: "collector-supervisor" }), }); supervisor.start(); } const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db"; mkdirSync(dirname(dbPath), { recursive: true }); const sqliteBackend = createSqliteStorage({ path: dbPath }); const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace); const configMap = envToConfigMap(process.env as Readonly>); const config: ConfigAccess = configMapToAccess(configMap); const deps: HostDeps = { logger, config, storageFactory, secrets: createEmptySecrets(), permissions: createAllowAllPermissions(), scheduler: createNoopScheduler(), bus: createBus(logger), events: createNoopEvents(), logSink, logDeps, }; // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. const externalSpecifiers = parseExternalSpecifiers( process.env as Readonly>, ); const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger); // Assemble the credential list. MVP keeps the hardcoded `opencode` credential // and adds a `claude` credential when an external Anthropic provider is loaded. const credentials = [{ name: "opencode", providerId: "openai-compat" }]; // The umans credential is always listed (it's the model-catalog index); the // provider itself only registers when UMANS_API_KEY is set, so listCatalog // gracefully skips it when the provider is absent. if (process.env.UMANS_API_KEY) { credentials.push({ name: "umans", providerId: "umans" }); logger.info(`Registered credential "umans" → umans provider`); } const hasAnthropic = externalExtensions.some((e) => e.manifest.contributes?.providers?.includes("anthropic"), ); if (hasAnthropic) { const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude"; credentials.push({ name: claudeName, providerId: "anthropic" }); logger.info(`Registered credential "${claudeName}" → anthropic provider`); } const extensions: Extension[] = [ ...CORE_EXTENSIONS, createCredentialStoreExtension({ credentials }), // vision-handoff activates AFTER credential-store (it resolves the // credential-store service at activate time to find vision-capable models). // Placed here, not in CORE_EXTENSIONS, so the service is available when it // activates. The session-orchestrator resolves its service LAZILY // (per-turn), so activation order between it and session-orchestrator // doesn't matter. visionHandoffExt, ...externalExtensions, ]; const host = createHost(extensions, deps); await host.activate(); const disabled = host.getDisabled(); if (disabled.length > 0) { for (const d of disabled) { logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`); } } // Periodic memory telemetry — leak-localization edge effect (AGENTS.md: // timers are edge effects owned by host-bin, the composition root, NOT the // kernel). Logs process.memoryUsage() every 60s tagged with the active- // conversation count, and every 5 min runs Bun.gc(true) + logs RSS // before/after to distinguish live retained objects from GC fragmentation. // The per-turn before/after sampling lives in session-orchestrator; this // owns the PERIODIC baseline. All effects are injected (no ambient state); // stop() is cleared on shutdown so timers never leak across a restart. let memoryTelemetry: { stop: () => void } | undefined; let activeConvCountFn: (() => number) | undefined; try { const orchestrator = host.getHostAPI().getService(sessionOrchestratorHandle); activeConvCountFn = () => orchestrator.getActiveConversationCount(); memoryTelemetry = startMemoryTelemetry({ logger: logger.child({ extensionId: "mem-telemetry" }), sampleMemory: (): MemorySample => { const m = process.memoryUsage(); return { rss: m.rss, heapUsed: m.heapUsed, heapTotal: m.heapTotal, external: m.external, arrayBuffers: m.arrayBuffers, }; }, gc: () => Bun.gc(true), getActiveConversationCount: () => orchestrator.getActiveConversationCount(), }); } catch (err) { logger.error("Memory telemetry not started (session-orchestrator unavailable)", { err, }); } let shuttingDown = false; const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; memoryTelemetry?.stop(); logger.info("Shutting down — deactivating extensions"); await host.deactivate(); logger.info("Draining collector"); await supervisor?.stop(); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); const memorySnapshot = () => { const m = process.memoryUsage(); return memorySampleAttributes({ rss: m.rss, heapUsed: m.heapUsed, heapTotal: m.heapTotal, external: m.external, arrayBuffers: m.arrayBuffers, }); }; process.on("unhandledRejection", (reason) => { logger.error("unhandledRejection", { err: reason, handler: "unhandledRejection", activeConversations: activeConvCountFn?.() ?? "unavailable", timestamp: new Date().toISOString(), ...memorySnapshot(), }); }); process.on("uncaughtException", (err) => { logger.error("uncaughtException", { err, handler: "uncaughtException", activeConversations: activeConvCountFn?.() ?? "unavailable", timestamp: new Date().toISOString(), ...memorySnapshot(), }); void shutdown(); }); logger.info("Dispatch booted"); console.info("Dispatch booted"); } boot().catch((err) => { console.error("Fatal boot error:", err); process.exit(1); });