/**
 * Home23 — Agent Harness Entry Point
 *
 * Wires the TypeScript channel/scheduler/sibling modules to the
 * COSMO engine via HTTP (dashboard API) and WebSocket (realtime events).
 *
 * Usage:
 *   HOME23_AGENT=test-agent node dist/home.js
 */

import { readFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs';
import { execSync, exec } from 'node:child_process';
import { promisify } from 'node:util';

// Async exec for cron shell-jobs. execSync blocks the harness's main event
// loop for the entire duration of the script — Telegram polls timeout, SSE
// streams stall, /health stops responding. Each shell-cron firing was
// causing visible chat drops. Async exec runs in a worker subprocess and
// resolves via callback, leaving the event loop free.
const execAsync = promisify(exec);
import { resolve, join } from 'node:path';
import { createHash } from 'node:crypto';
import { loadConfig } from './config.js';
import { CompactionManager } from './agent/compaction.js';
import { TelegramAdapter } from './channels/telegram.js';
import { DiscordAdapter } from './channels/discord.js';
import { IMessageAdapter } from './channels/imessage.js';
import { WebhookServer } from './channels/webhooks.js';
import { SessionRouter, type IncomingMessage, type OutgoingResponse, type ChannelAdapter } from './channels/router.js';
import { CronScheduler, type CronJob, type JobResult } from './scheduler/cron.js';
import { DeliveryManager } from './scheduler/delivery.js';
import { SiblingProtocol } from './sibling/protocol.js';
import { BridgeChat } from './sibling/bridge-chat.js';
import { AgentLoop } from './agent/loop.js';
import { ContextManager } from './agent/context.js';
import { ConversationHistory } from './agent/history.js';
import { createToolRegistry } from './agent/tools/index.js';
import type { ToolContext, SubAgentTracker } from './agent/types.js';
import { resolveBrainRoute } from './agent/brain-route-resolver.js';
import { TTSService } from './observability/tts.js';
import { BrowserController } from './browser/cdp.js';
import type { HomeConfig } from './types.js';
import { CommandHandler, type CommandContext } from './commands/handler.js';
import { createEvobrewChatHandler, createHealthHandler, createStopHandler } from './routes/evobrew-bridge.js';
import {
  createTurnStartHandler,
  createModelsHandler,
  createTurnStreamHandler,
  createTurnStopHandler,
  createPendingTurnsHandler,
} from './routes/chat-turn.js';
import { EngineEventListener } from './engine-events.js';
import { DeviceRegistry } from './push/device-registry.js';
import { ApnsClient } from './push/apns-client.js';
import { ApnsPusher } from './push/apns-pusher.js';
import { createRegisterDeviceHandler, createUnregisterDeviceHandler, createListDevicesHandler } from './routes/device.js';
import { createChatHistoryHandler, createChatListHandler } from './routes/chat-history.js';
import { syncSharedSkillsRegistry } from './skills/runtime.js';
import { PromoterWorker } from './workers/promoter.js';
import { createWorkerRouter } from './workers/connector.js';

// ─── Constants ──────────────────────────────────────────────

const AGENT_NAME = process.env.HOME23_AGENT ?? 'test-agent';
const HOME23_ROOT = resolve(import.meta.dirname, '..');
const PROJECT_ROOT = HOME23_ROOT;
const INSTANCE_DIR = join(HOME23_ROOT, 'instances', AGENT_NAME);
const WORKSPACE_PATH = join(INSTANCE_DIR, 'workspace');
const BRAIN_DIR = join(INSTANCE_DIR, 'brain');
const CONVERSATIONS_DIR = join(INSTANCE_DIR, 'conversations');
const SESSIONS_DIR = join(CONVERSATIONS_DIR, 'sessions');
const LOGS_DIR = join(INSTANCE_DIR, 'logs');
const RUNTIME_DIR = CONVERSATIONS_DIR; // backwards compat for modules that use RUNTIME_DIR
const HOME_PORT = parseInt(process.env.HOME_PORT ?? '4610', 10);
const CACHE_DIAGNOSTICS_ENABLED = /^(1|true|yes|on)$/i.test(process.env.CACHE_DIAGNOSTICS ?? '');

function hashText(text: string): string {
  return createHash('sha256').update(text).digest('hex').slice(0, 16);
}

function serializePromptBlocks(prompt: string | Array<{ text: string }>): string {
  return typeof prompt === 'string'
    ? prompt
    : prompt.map(block => block.text).join('\n\n');
}

function writeCacheDiagnostic(runtimeDir: string, event: Record<string, unknown>): void {
  const diagnosticsDir = join(runtimeDir, 'cache-diagnostics');
  mkdirSync(diagnosticsDir, { recursive: true });
  const date = new Date().toISOString().slice(0, 10);
  const path = join(diagnosticsDir, `${date}.jsonl`);
  appendFileSync(path, `${JSON.stringify(event)}\n`, 'utf-8');
}

// ─── Engine HTTP Client ─────────────────────────────────────

let ENGINE_BASE = 'http://localhost:5002'; // set after config load

async function queryEngine(
  text: string,
  mode: string = 'normal',
  opts?: { model?: string; timeoutMs?: number },
): Promise<{ answer: string; model?: string; durationMs: number }> {
  const url = `${ENGINE_BASE}/api/query`;
  const body = JSON.stringify({
    query: text,
    mode,
    backendOverride: 'openai',
    ...(opts?.model ? { model: opts.model } : {}),
  });

  const startMs = Date.now();
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body,
    signal: AbortSignal.timeout(opts?.timeoutMs ?? 300_000),
  });

  if (!res.ok) {
    const errText = await res.text().catch(() => '');
    throw new Error(`Engine query failed: HTTP ${res.status} — ${errText}`);
  }

  const data = await res.json() as Record<string, unknown>;
  const answer = (data.answer ?? data.response ?? data.text ?? '') as string;
  const durationMs = Date.now() - startMs;

  console.log(`[engine] Query took ${durationMs}ms, answer length=${answer.length}, model=${data.model ?? 'unknown'}`);
  console.log(`[engine] Answer preview: "${answer.slice(0, 150)}"`);

  return { answer, model: data.model as string | undefined, durationMs };
}

// ─── Main ───────────────────────────────────────────────────

async function main(): Promise<void> {
  // ── Load config ──
  const config = loadConfig(AGENT_NAME);
  console.log(`[home] Config loaded for agent: ${AGENT_NAME}`);

  // ── Resolve ports and ENGINE_BASE from config ──
  const DASHBOARD_PORT = config.ports?.dashboard ?? 5002;
  const ENGINE_WS_PORT = config.ports?.engine ?? 5001;
  ENGINE_BASE = `http://localhost:${DASHBOARD_PORT}`;

  console.log('');
  console.log('═══════════════════════════════════════════════════');
  console.log(`  Home23 — ${config.agent?.displayName ?? AGENT_NAME}`);
  console.log('═══════════════════════════════════════════════════');
  console.log(`  Dashboard:    ${ENGINE_BASE}`);
  console.log(`  Engine WS:    ws://localhost:${ENGINE_WS_PORT}`);
  console.log(`  Instance:     ${INSTANCE_DIR}`);
  console.log('');

  // Ensure directories exist
  mkdirSync(CONVERSATIONS_DIR, { recursive: true });
  mkdirSync(SESSIONS_DIR, { recursive: true });

  const sharedSkillsRegistry = await syncSharedSkillsRegistry(PROJECT_ROOT).catch((err) => {
    console.warn(`[home] Shared skills registry sync failed: ${err instanceof Error ? err.message : String(err)}`);
    return null;
  });
  if (sharedSkillsRegistry) {
    console.log('[home] Shared skills registry synced');
  }

  // ── Context Manager (identity + system prompt) ──
  const workspacePath = WORKSPACE_PATH;
  const bootPath = join(workspacePath, 'BOOT.md');
  const identityFiles = [...config.chat.identityFiles];
  if (existsSync(bootPath) && !identityFiles.includes('BOOT.md')) {
    identityFiles.push('BOOT.md');
    console.log('[home] BOOT.md detected — adding to startup identity load');
  }

  const contextManager = new ContextManager({
    workspacePath,
    identityFiles,
    identityLayers: config.chat.identityLayers,
    heartbeatRefreshMs: config.chat.heartbeatRefreshMs,
    enginePort: DASHBOARD_PORT,
    ownerName: config.agent?.owner?.name,
    ownerTelegramId: config.agent?.owner?.telegramId,
  });

  // ── Conversation History ──
  const history = new ConversationHistory(CONVERSATIONS_DIR, config.chat.historyBudget ?? 400_000, AGENT_NAME);

  // ── Tool Registry ──
  const providersCfg = config.providers as Record<string, { apiKey?: string }> | undefined;
  const braveApiKey = providersCfg?.brave?.apiKey || process.env.BRAVE_API_KEY || process.env.BRAVE_SEARCH_API_KEY;
  const searxngUrl = (config.search as { searxngUrl?: string } | undefined)?.searxngUrl || process.env.SEARXNG_URL;
  const registry = createToolRegistry({ web: { braveApiKey, searxngUrl } });
  console.log(`[home] Tool registry: ${registry.size} tools (brave=${braveApiKey ? 'yes' : 'no'}, searxng=${searxngUrl || 'default'})`);

  // ── Temp dir for media ──
  const tempDir = join(RUNTIME_DIR, 'tmp');
  mkdirSync(tempDir, { recursive: true });

  // ── TTS Service (lazy) ──
  // If TTS is configured for a known provider but the apiKey is empty,
  // hydrate it from the corresponding providers.<name>.apiKey block.
  const ttsCfg = { ...config.tts };
  if (ttsCfg.enabled && !ttsCfg.apiKey && ttsCfg.provider) {
    const providers = config.providers as Record<string, { apiKey?: string }> | undefined;
    const key = providers?.[ttsCfg.provider]?.apiKey;
    if (key) ttsCfg.apiKey = key;
  }
  const ttsService = ttsCfg.enabled && ttsCfg.apiKey ? new TTSService(ttsCfg) : null;
  if (ttsCfg.enabled && !ttsService) {
    console.warn(`[home] TTS enabled but no apiKey resolved for provider=${ttsCfg.provider}`);
  } else if (ttsService) {
    console.log(`[home] TTS ready — provider=${ttsCfg.provider}, voice=${ttsCfg.voiceId}, auto=${ttsCfg.auto}`);
  }

  // ── Browser Controller (lazy) ──
  const browser = config.browser.enabled ? new BrowserController(config.browser) : null;

  // ── Sub-agent tracker ──
  const subAgentTracker: SubAgentTracker = { active: 0, maxConcurrent: config.agent?.maxSubAgents ?? 3, queue: [] };

  // Model aliases — loaded from config
  const MODEL_ALIASES: Record<string, { provider: string; model: string }> = config.models?.aliases ?? {};

  // ── Telegram adapter ref (captured during adapter creation) ──
  let telegramAdapterRef: TelegramAdapter | null = null;

  // ── Resolve cosmo23 brainRoute at startup ──
  const agentName = process.env.HOME23_AGENT || 'unknown';
  const cosmo23Port = Number(process.env.COSMO23_PORT || 43210);
  const cosmo23BaseUrl = `http://localhost:${cosmo23Port}`;
  const brainRoute = await resolveBrainRoute(agentName, cosmo23BaseUrl);
  if (brainRoute) {
    console.log(`[home] brainRoute resolved: ${brainRoute}`);
  } else {
    console.warn(`[home] brainRoute NOT resolved for ${agentName} — brain_query tools will return is_error. Check: curl ${cosmo23BaseUrl}/api/brains`);
  }

  // ── Tool Context (pre-wired, agent loop + scheduler added below) ──
  const toolContext: ToolContext = {
    scheduler: null,
    ttsService,
    browser,
    projectRoot: PROJECT_ROOT,
    enginePort: DASHBOARD_PORT,
    agentName,
    cosmo23BaseUrl,
    brainRoute,
    workspacePath,
    tempDir,
    contextManager,
    subAgentTracker,
    chatId: '',
    telegramAdapter: null,   // wired after adapter creation
    runAgentLoop: null,       // wired after agent creation
  };

  // ── Model from config.yaml (single source of truth) ──
  const startupModel = config.chat.defaultModel ?? config.chat.model ?? 'kimi-k2.6';
  const startupProvider = config.chat.defaultProvider ?? config.chat.provider ?? 'ollama-cloud';
  console.log(`[home] Model: ${startupModel} (${startupProvider}) — from config.yaml`);

  // ── Auth tokens ──
  function resolveApiKey(provider: string): string {
    const providers = config.providers as Record<string, { apiKey?: string; baseUrl?: string }> | undefined;
    if (provider === 'anthropic') return providers?.anthropic?.apiKey ?? process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.ANTHROPIC_API_KEY ?? '';
    if (provider === 'minimax') return providers?.minimax?.apiKey ?? process.env.MINIMAX_API_KEY ?? '';
    if (provider === 'openai') return providers?.openai?.apiKey ?? process.env.OPENAI_API_KEY ?? '';
    if (provider === 'xai') return providers?.xai?.apiKey ?? process.env.XAI_API_KEY ?? '';
    if (provider === 'ollama-cloud') return providers?.['ollama-cloud']?.apiKey ?? process.env.OLLAMA_CLOUD_API_KEY ?? '';
    return '';
  }
  function resolveBaseUrl(provider: string): string | undefined {
    const providers = config.providers as Record<string, { baseUrl?: string }> | undefined;
    return providers?.[provider]?.baseUrl;
  }
  const authToken = resolveApiKey(startupProvider);
  const anthropicToken = resolveApiKey('anthropic');
  const startupBaseURL = resolveBaseUrl(startupProvider);
  const compactionToken = anthropicToken || (startupProvider === 'minimax' ? authToken : '');
  const compactionBaseURL = !anthropicToken && startupProvider === 'minimax' ? startupBaseURL : undefined;
  console.log(`[home] Provider: ${startupProvider}, auth: ${authToken ? authToken.slice(0, 15) + '...' : 'MISSING'}`);

  // ── Agent Loop ──
  // Create Anthropic client for shared use (agent + compaction)
  const isOAuth = anthropicToken.startsWith('sk-ant-oat');
  const anthropicClient = isOAuth
    ? new (await import('@anthropic-ai/sdk')).default({
        authToken: compactionToken,
        ...(compactionBaseURL ? { baseURL: compactionBaseURL } : {}),
        defaultHeaders: {
          'anthropic-dangerous-direct-browser-access': 'true',
          'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,extended-cache-ttl-2025-04-11',
        },
        dangerouslyAllowBrowser: true,
      })
    : new (await import('@anthropic-ai/sdk')).default({
        apiKey: compactionToken || 'placeholder',
        ...(compactionBaseURL ? { baseURL: compactionBaseURL } : {}),
      });

  // ── Compaction Manager ──
  const compaction = new CompactionManager({
    client: anthropicClient,
    history,
    memory: null as unknown as import('./agent/memory.js').MemoryManager, // wired after agent creation
  });

  const agent = new AgentLoop({
    apiKey: authToken,
    baseURL: startupBaseURL,
    model: startupModel,
    provider: startupProvider,
    // Note: providerMap set via setProviderMap below, after construction.
    maxTokens: 8192,
    temperature: config.chat.temperature,
    registry,
    contextManager,
    history,
    toolContext,
    workspacePath,
    compaction,
    sessionGapMs: config.chat.sessionGapMs,
    situationalAwareness: config.situationalAwareness,
    cacheDiagnostics: CACHE_DIAGNOSTICS_ENABLED
      ? {
          enabled: true,
          runtimeDir: RUNTIME_DIR,
          logger: (event) => writeCacheDiagnostic(RUNTIME_DIR, event),
        }
      : undefined,
  });

  // Wire compaction's memory reference now that agent exists
  (compaction as unknown as { memory: import('./agent/memory.js').MemoryManager }).memory = agent.getMemory();

  // Wire sub-agent runner
  toolContext.runAgentLoop = async (_systemPrompt, userMessage, _tools, ctx) => {
    return agent.run(ctx.chatId, userMessage);
  };

  // Give AgentLoop the provider map so runtime setModel can rebuild the client
  // with the correct apiKey + baseURL when switching between anthropic-SDK providers.
  agent.setProviderMap({
    anthropic: { apiKey: resolveApiKey('anthropic'), baseURL: resolveBaseUrl('anthropic') },
    minimax: { apiKey: resolveApiKey('minimax'), baseURL: resolveBaseUrl('minimax') },
    openai: { apiKey: resolveApiKey('openai'), baseURL: resolveBaseUrl('openai') },
    xai: { apiKey: resolveApiKey('xai'), baseURL: resolveBaseUrl('xai') },
    'ollama-cloud': { apiKey: resolveApiKey('ollama-cloud'), baseURL: resolveBaseUrl('ollama-cloud') },
  });

  // ── Command Handler ──
  const commandCtx: CommandContext = {
    agent,
    history,
    contextManager,
    scheduler: null, // wired after scheduler creation
    toolContext,
    projectRoot: PROJECT_ROOT,
    enginePort: DASHBOARD_PORT,
    runtimeDir: RUNTIME_DIR,
    workspacePath,
    modelAliases: MODEL_ALIASES,
    compaction,
  };
  const commandHandler = new CommandHandler(commandCtx);

  if (CACHE_DIAGNOSTICS_ENABLED) {
    try {
      const promptSource = contextManager.getPromptSourceInfo();
      const systemPrompt = contextManager.getSystemPrompt();
      const toolNames = registry.getAnthropicTools().map(tool => tool.name);
      writeCacheDiagnostic(RUNTIME_DIR, {
        type: 'startup',
        timestamp: new Date().toISOString(),
        provider: startupProvider,
        model: startupModel,
        systemPromptHash: hashText(systemPrompt),
        systemPromptLength: systemPrompt.length,
        loadedIdentityFiles: promptSource.loadedFiles
          .filter(file => file.included)
          .map(file => ({ label: file.label, filePath: file.filePath })),
        toolNames,
        toolsHash: hashText(JSON.stringify(toolNames)),
      });
    } catch (err) {
      console.warn('[cache-diagnostics] Failed to write startup diagnostics:', err);
    }
  }

  // ── Message handler ──
  const messageHandler = async (message: IncomingMessage): Promise<OutgoingResponse> => {
    const text = message.text.trim();

    // Slash commands — handled pre-AgentLoop, no LLM.
    // This includes /stop, which fires instantly even while the agent is busy.
    const cmdResult = await commandHandler.handle(text, message.chatId, message.channel);
    if (cmdResult) return cmdResult;

    // Safety net: if somehow a message reaches here while agent is busy
    // (should not happen with queueDuringRun, but defensive)
    if (agent.isRunning(message.chatId)) {
      return {
        text: "I'm still working on something. Send /stop to interrupt me.",
        channel: message.channel,
        chatId: message.chatId,
      };
    }

    // Track active run so router holds incoming messages during processing
    const routerKey = `${message.channel}:${message.chatId}`;
    router.markRunActive(routerKey);

    try {
      const result = await agent.run(message.chatId, text, message.media);
      return {
        text: result.text,
        channel: message.channel,
        chatId: message.chatId,
        media: result.media,
      };
    } finally {
      router.markRunComplete(routerKey);
      // Process any messages that arrived during the run
      await router.drainPending(routerKey);
    }
  };

  // ── Create SessionRouter ──
  const router = new SessionRouter(config.sessions, messageHandler, SESSIONS_DIR);

  // ── Bound message handler for adapters ──
  const routerHandler = (msg: IncomingMessage): Promise<void> => router.handleMessage(msg);

  // ── Create channel adapters ──
  // Collected in a shared map so both the router and DeliveryManager
  // have access to the same adapter instances.
  const adapterMap = new Map<string, ChannelAdapter>();
  const enabledAdapters: string[] = [];

  if (config.channels?.telegram?.enabled) {
    const tc = config.channels.telegram;
    // Per-agent config is authoritative — env is only a last-resort fallback.
    // (Env-first precedence caused a cross-agent token leak on 2026-04-18 when
    // pm2 child processes inherited a TELEGRAM_BOT_TOKEN from a polluted shell.)
    const telegramBotToken = tc.botToken || process.env.TELEGRAM_BOT_TOKEN;
    if (!telegramBotToken) {
      throw new Error('[home] Telegram enabled but no botToken found in config or TELEGRAM_BOT_TOKEN env');
    }
    console.log(`[home] Telegram bot token: ${telegramBotToken.slice(0, 10)}... (config=${tc.botToken?.slice(0, 10)}..., env=${!!process.env.TELEGRAM_BOT_TOKEN})`);
    const adapter = new TelegramAdapter(
      {
        botToken: telegramBotToken,
        streaming: tc.streaming as 'partial' | 'off',
        dmPolicy: tc.dmPolicy,
        groupPolicy: tc.groupPolicy,
        groups: tc.groups,
        ackReaction: tc.ackReaction,
      },
      routerHandler,
      RUNTIME_DIR,
    );
    router.registerAdapter(adapter);
    adapterMap.set(adapter.name, adapter);
    enabledAdapters.push('telegram');
    telegramAdapterRef = adapter;
    process.env.TELEGRAM_BOT_TOKEN = telegramBotToken;
  }

  // Wire telegram adapter into tool context now that it exists
  toolContext.telegramAdapter = telegramAdapterRef;

  if (config.channels?.discord?.enabled) {
    const dc = config.channels.discord;
    const adapter = new DiscordAdapter(
      {
        token: dc.token,
        streaming: dc.streaming,
        groupPolicy: dc.groupPolicy,
        guilds: dc.guilds,
        threadBindings: dc.threadBindings,
      },
      routerHandler,
    );
    router.registerAdapter(adapter);
    adapterMap.set(adapter.name, adapter);
    enabledAdapters.push('discord');
  }

  if (config.channels?.imessage?.enabled) {
    const ic = config.channels.imessage;
    const adapter = new IMessageAdapter(
      {
        cliPath: ic.cliPath,
        dmPolicy: ic.dmPolicy,
        groupPolicy: ic.groupPolicy,
      },
      routerHandler,
    );
    router.registerAdapter(adapter);
    adapterMap.set(adapter.name, adapter);
    enabledAdapters.push('imessage');
  }

  let webhookAdapter: InstanceType<typeof WebhookServer> | null = null;
  let webhookToken = '';
  if (config.channels?.webhooks?.enabled) {
    const wc = config.channels.webhooks;
    webhookToken = wc.token;
    const adapter = new WebhookServer(
      {
        port: wc.port ?? HOME_PORT,
        path: wc.path,
        token: wc.token,
        mappings: wc.mappings,
        sessionApi: {
          enabled: true,
          historyDir: SESSIONS_DIR,
          getBindings: () => router.getBindingsSnapshot(),
          getBindingByKey: (key: string) => router.getBindingByKey(key),
        },
      },
      routerHandler,
    );
    router.registerAdapter(adapter);
    adapterMap.set(adapter.name, adapter);
    enabledAdapters.push('webhook');
    webhookAdapter = adapter;
  }

  console.log(`[home] Adapters: ${enabledAdapters.length > 0 ? enabledAdapters.join(', ') : 'none'}`);

  // ── Sibling Protocol ──
  let sibling: SiblingProtocol | null = null;
  let bridgeChat: BridgeChat | null = null;

  if (config.sibling?.enabled) {
    const sc = config.sibling;
    sibling = new SiblingProtocol({
      localInstance: AGENT_NAME,
      remoteUrl: sc.remoteUrl,
      token: sc.token,
      rateLimits: sc.rateLimits,
      ackMode: sc.ackMode,
    });

    // When a sibling message arrives, route it through the engine
    // and send the response back to the sibling
    const siblingRef = sibling;
    sibling.onReceive(async (msg) => {
      console.log(`[home] Sibling message from ${msg.from}: ${msg.text.slice(0, 80)}...`);
      try {
        const result = await queryEngine(`[sibling:${msg.from}] ${msg.text}`, 'normal');
        await siblingRef.sendMessage(result.answer, AGENT_NAME);
      } catch (err) {
        console.error('[home] Failed to handle sibling message:', err);
      }
    });

    console.log('[home] Sibling protocol initialized');

    // ── Bridge Chat ──
    if (sc.bridgeChat.enabled) {
      const dbPath = resolve(PROJECT_ROOT, sc.bridgeChat.dbPath);
      bridgeChat = new BridgeChat({
        dbPath,
        telegramBotToken: sc.bridgeChat.telegramBotToken || undefined,
        telegramTargetId: sc.bridgeChat.telegramTargetId || undefined,
      });
      bridgeChat.init();
      console.log(`[home] Bridge chat initialized (${dbPath})`);
    }
  }

  // ── Delivery Manager & Cron Scheduler ──
  const delivery = new DeliveryManager(adapterMap, config.deliveryProfiles ?? {});
  let scheduler: CronScheduler | null = null;

  if (config.scheduler) {
    const cronHandler = async (job: CronJob): Promise<JobResult> => {
      const startMs = Date.now();
      const cronChatId = `cron-${job.id}`;

      try {
        if (job.payload.kind === 'agentTurn') {
          // Full AgentLoop — 19 tools, isolated chat history per job
          const timeoutMs = (job.payload.timeoutSeconds ?? 900) * 1000;

          // Resolve message: prefer messagePath if set (and readable), else inline message.
          let resolvedMessage = job.payload.message ?? '';
          if (job.payload.messagePath) {
            const abs = job.payload.messagePath.startsWith('/')
              ? job.payload.messagePath
              : resolve(PROJECT_ROOT, job.payload.messagePath);
            try {
              resolvedMessage = readFileSync(abs, 'utf-8');
            } catch (err) {
              const errMsg = err instanceof Error ? err.message : String(err);
              const durationMs = Date.now() - startMs;
              return {
                status: 'error',
                error: `Cannot read messagePath "${job.payload.messagePath}": ${errMsg}`,
                durationMs,
              };
            }
          }
          if (!resolvedMessage.trim()) {
            const durationMs = Date.now() - startMs;
            return { status: 'error', error: 'agentTurn payload has neither message nor readable messagePath', durationMs };
          }

          let timeoutId: ReturnType<typeof setTimeout>;
          const timeoutPromise = new Promise<never>((_, reject) => {
            timeoutId = setTimeout(() => {
              // Actually stop the agent — don't just stop waiting
              agent.stop(cronChatId);
              reject(new Error(`Cron agent timeout after ${timeoutMs}ms`));
            }, timeoutMs);
          });

          try {
            if (job.payload.sessionHistory === 'fresh') {
              agent.getHistory().rotate(cronChatId);
            }
            const agentPromise = agent.run(cronChatId, resolvedMessage);
            const result = await Promise.race([agentPromise, timeoutPromise]);
            clearTimeout(timeoutId!);
            const durationMs = Date.now() - startMs;

            const jobResult: JobResult = { status: 'ok', response: result.text, durationMs };
            delivery.lastDeliveryError = null;
            await delivery.deliver(job, jobResult);
            if (delivery.lastDeliveryError) {
              jobResult.status = 'error';
              jobResult.error = `Delivery failed: ${delivery.lastDeliveryError}`;
            }
            return jobResult;
          } catch (err) {
            clearTimeout(timeoutId!);
            throw err;
          }
        }

        if (job.payload.kind === 'exec') {
          const timeoutMs = (job.payload.timeoutSeconds ?? 60) * 1000;
          const execCwd = (job.payload as Record<string, unknown>).cwd as string | undefined;
          const { stdout } = await execAsync(job.payload.command, {
            timeout: timeoutMs,
            encoding: 'utf-8',
            cwd: execCwd || PROJECT_ROOT,
            env: { ...process.env },
            maxBuffer: 10 * 1024 * 1024,
          });
          const durationMs = Date.now() - startMs;

          const jobResult: JobResult = { status: 'ok', response: stdout.trim(), durationMs };
          delivery.lastDeliveryError = null;
          await delivery.deliver(job, jobResult);
          if (delivery.lastDeliveryError) {
            jobResult.status = 'error';
            jobResult.error = `Delivery failed: ${delivery.lastDeliveryError}`;
          }
          return jobResult;
        }

        if (job.payload.kind === 'query') {
          const timeoutMs = (job.payload.timeoutSeconds ?? 120) * 1000;
          const result = await queryEngine(job.payload.message, job.payload.mode ?? 'normal', {
            model: job.payload.model,
            timeoutMs,
          });
          const durationMs = Date.now() - startMs;

          const jobResult: JobResult = { status: 'ok', response: result.answer, durationMs };
          delivery.lastDeliveryError = null;
          await delivery.deliver(job, jobResult);
          if (delivery.lastDeliveryError) {
            jobResult.status = 'error';
            jobResult.error = `Delivery failed: ${delivery.lastDeliveryError}`;
          }
          return jobResult;
        }

        if (job.payload.kind === 'systemEvent') {
          console.log(`[scheduler] System event: ${job.payload.text}`);
          const durationMs = Date.now() - startMs;
          return { status: 'ok', response: job.payload.text, durationMs };
        }

        const durationMs = Date.now() - startMs;
        return { status: 'error', error: 'Unknown payload kind', durationMs };
      } catch (err) {
        const durationMs = Date.now() - startMs;
        const errorMsg = err instanceof Error ? err.message : String(err);
        console.error(`[scheduler] Job ${job.id} error:`, errorMsg);
        return { status: 'error', error: errorMsg, durationMs };
      }
    };

    scheduler = new CronScheduler(config.scheduler, cronHandler, RUNTIME_DIR);

    // Load external cron jobs from config dir if file exists
    const externalJobsPath = join(PROJECT_ROOT, 'config', 'cron-jobs.json');
    if (existsSync(externalJobsPath)) {
      try {
        const raw = readFileSync(externalJobsPath, 'utf-8');
        const externalJobs: CronJob[] = JSON.parse(raw);
        let added = 0;
        let updated = 0;
        for (const job of externalJobs) {
          const existing = scheduler.getJob(job.id);
          if (!existing) {
            scheduler.addJob(job);
            added++;
          } else {
            scheduler.saveJob({
              ...existing,
              ...job,
              state: existing.state || job.state,
            });
            updated++;
          }
        }
        console.log(`[home] Loaded ${added} new and updated ${updated} cron job(s) from config/cron-jobs.json (${externalJobs.length} total in file)`);
      } catch (err) {
        console.error('[home] Failed to load external cron jobs:', err);
      }
    }

    console.log('[home] Scheduler initialized');
  }

  // Wire scheduler into tool context
  toolContext.scheduler = scheduler;

  // Wire scheduler into command context
  commandCtx.scheduler = scheduler;

  // ── Engine WebSocket Event Listener ──
  const engineEvents = new EngineEventListener(ENGINE_WS_PORT);
  engineEvents.start();

  // ── Start everything ──
  try {
    await router.startAll();
    console.log('[home] All adapters started');
  } catch (err) {
    console.error('[home] Failed to start adapters:', err);
  }

  if (scheduler) {
    scheduler.start();
    console.log('[home] Scheduler started');
  }

  // ── Promoter worker ──
  // Drains cognition's NOTIFY stream into live-problems via LLM classification
  // + verifier dry-run. Unverified concerns get empirically tested before they
  // become tracked problems; vague/subjective ones get dropped. See
  // src/workers/promoter.ts for the full contract.
  try {
    // Classification uses a fixed small model regardless of the agent's chat
    // default. Reason: we need well-behaved structured JSON output; haiku is
    // reliable and cheap. anthropicClient is already configured for Anthropic
    // (OAuth or API key), so this always resolves correctly.
    const promoter = new PromoterWorker({
      brainDir: BRAIN_DIR,
      dashboardBaseUrl: ENGINE_BASE,
      client: anthropicClient,
      model: 'claude-haiku-4-5',
      logger: {
        info: (m) => console.log(m),
        warn: (m) => console.warn(m),
        error: (m) => console.error(m),
      },
    });
    promoter.start();
    console.log('[home] Promoter worker started');
  } catch (err) {
    console.warn('[home] Promoter worker start failed:', err instanceof Error ? err.message : String(err));
  }

  // ── Push notifications (APNs) — optional ──
  const apnsConfig = config.apns;
  const deviceRegistryPath = join(process.env.COSMO_RUNTIME_DIR ?? process.cwd(), 'device-registry.json');
  const deviceRegistry = new DeviceRegistry(deviceRegistryPath);

  if (apnsConfig?.team_id && apnsConfig?.key_id && apnsConfig?.key_path && apnsConfig?.bundle_id) {
    try {
      const apnsClient = new ApnsClient({
        team_id: apnsConfig.team_id,
        key_id: apnsConfig.key_id,
        key_path: apnsConfig.key_path,
        bundle_id: apnsConfig.bundle_id,
        default_env: apnsConfig.default_env ?? 'production',
      });
      const pusher = new ApnsPusher(apnsClient, deviceRegistry, AGENT_NAME);
      agent.setPusher(pusher);
      console.log(`[home] APNs pusher installed — bundle=${apnsConfig.bundle_id}, env=${apnsConfig.default_env ?? 'production'}`);
    } catch (err) {
      console.warn('[home] APNs pusher init failed:', err instanceof Error ? err.message : err);
    }
  } else {
    console.log('[home] APNs pusher not configured — push disabled');
  }

  // ── Evobrew Bridge (standalone Express server) ──
  const BRIDGE_PORT = config.ports?.bridge ?? 5004;
  const bridgeToken = config.channels?.webhooks?.token ?? process.env.BRIDGE_TOKEN ?? '';
  const bridgeApp = (await import('express')).default();
  bridgeApp.use((await import('express')).default.json({ limit: '90mb' }));

  // CORS for evobrew
  bridgeApp.use((_req: any, res: any, next: any) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
    if (_req.method === 'OPTIONS') { res.sendStatus(200); return; }
    next();
  });

  const bridgeConfig = {
    agent,
    token: bridgeToken,
    agentName: AGENT_NAME,
  };
  bridgeApp.use(createWorkerRouter({
    projectRoot: PROJECT_ROOT,
    ctx: toolContext
  }));
  bridgeApp.post('/api/chat', createEvobrewChatHandler(bridgeConfig));
  bridgeApp.post('/api/stop', createStopHandler(bridgeConfig));
  bridgeApp.get('/health', createHealthHandler({ agentName: AGENT_NAME, agent }));

  bridgeApp.get('/api/house/state', async (_req: any, res: any) => {
    const haUrl = config.homeAssistant?.url?.replace(/\/+$/, '');
    const haToken = config.homeAssistant?.token;
    if (!haUrl || !haToken) {
      res.status(503).json({ ok: false, error: 'homeAssistant not configured' });
      return;
    }

    try {
      const startedAt = Date.now();
      const [apiRes, statesRes] = await Promise.all([
        fetch(`${haUrl}/api/`, {
          headers: { Authorization: `Bearer ${haToken}`, 'Content-Type': 'application/json' },
          signal: AbortSignal.timeout(5000),
        }),
        fetch(`${haUrl}/api/states`, {
          headers: { Authorization: `Bearer ${haToken}`, 'Content-Type': 'application/json' },
          signal: AbortSignal.timeout(8000),
        }),
      ]);

      if (!apiRes.ok) throw new Error(`Home Assistant API HTTP ${apiRes.status}`);
      if (!statesRes.ok) throw new Error(`Home Assistant states HTTP ${statesRes.status}`);

      const states = await statesRes.json() as Array<any>;
      const now = Date.now();
      const staleAfterMs = 6 * 60 * 60 * 1000;
      const interesting = /garage|door|lock|leak|water|smoke|carbon|co\b|motion|person|camera|eufy|meross|battery|thermostat|temperature|humidity|homekit/i;
      const matched = states
        .filter((s) => interesting.test(String(s.entity_id ?? '')) || interesting.test(String(s.attributes?.friendly_name ?? '')))
        .map((s) => {
          const updatedAt = s.last_updated ? new Date(s.last_updated).getTime() : 0;
          return {
            entity_id: s.entity_id,
            name: s.attributes?.friendly_name ?? s.entity_id,
            state: s.state,
            domain: String(s.entity_id ?? '').split('.')[0] ?? 'unknown',
            updated_at: s.last_updated,
            stale: updatedAt > 0 ? now - updatedAt > staleAfterMs : true,
          };
        })
        .sort((a, b) => String(a.entity_id).localeCompare(String(b.entity_id)));

      const unavailable = matched.filter((s) => ['unavailable', 'unknown'].includes(String(s.state)));
      const stale = matched.filter((s) => s.stale);
      const open = matched.filter((s) => /garage|door|lock/i.test(`${s.entity_id} ${s.name}`) && ['open', 'opening', 'unlocked'].includes(String(s.state)));

      res.json({
        ok: true,
        source: 'home-assistant',
        url: haUrl,
        response_ms: Date.now() - startedAt,
        entity_count: states.length,
        matched_count: matched.length,
        alerts: [
          ...open.map((s) => ({ severity: 'alert', kind: 'open_access_point', entity_id: s.entity_id, state: s.state })),
          ...unavailable.map((s) => ({ severity: 'warn', kind: 'unavailable', entity_id: s.entity_id, state: s.state })),
          ...stale.map((s) => ({ severity: 'warn', kind: 'stale', entity_id: s.entity_id, updated_at: s.updated_at })),
        ],
        entities: matched,
      });
    } catch (err) {
      res.status(502).json({ ok: false, source: 'home-assistant', url: haUrl, error: err instanceof Error ? err.message : String(err) });
    }
  });

  // ── Notify endpoint — the engine's live-problems loop calls this when
  // autonomous remediation has been exhausted. Routes the message to the
  // owner's default channel (Telegram DM for now). Bearer-token gated so
  // only the local engine can fire it.
  bridgeApp.post('/api/notify', async (req: any, res: any) => {
    if (bridgeToken) {
      const header = req.headers.authorization || '';
      const provided = header.startsWith('Bearer ') ? header.slice(7) : '';
      if (provided !== bridgeToken) {
        res.status(401).json({ error: 'unauthorized' });
        return;
      }
    }
    const { text, severity = 'normal', source = 'engine' } = req.body || {};
    if (!text || typeof text !== 'string') {
      res.status(400).json({ error: 'text required' });
      return;
    }

    const ownerTelegramId = config.agent?.owner?.telegramId || null;
    const sigil = severity === 'alert' ? '🚨' : severity === 'low' ? '·' : '⚠️';
    const prefix = `${sigil} [${source}]`;
    const delivered: string[] = [];
    const failed: Array<{channel: string; error: string}> = [];

    const telegramAdapter = adapterMap.get('telegram');
    if (telegramAdapter && ownerTelegramId) {
      try {
        await telegramAdapter.send({
          channel: 'telegram',
          chatId: String(ownerTelegramId),
          text: `${prefix} ${text}`,
        });
        delivered.push('telegram');
      } catch (err) {
        failed.push({ channel: 'telegram', error: err instanceof Error ? err.message : String(err) });
      }
    }

    if (delivered.length === 0) {
      res.status(503).json({
        error: 'no channel delivered',
        tried: failed,
        hint: telegramAdapter ? 'owner.telegramId not configured' : 'telegram adapter not enabled',
      });
      return;
    }
    res.json({ ok: true, delivered, failed });
  });

  // ── Diagnose endpoint — Tier 3 of live-problems. Engine POSTs a problem
  // here when the rigid remediation plan didn't resolve it. We launch a
  // normal agent turn with a focused diagnostic mission and return the
  // turnId immediately (fire-and-forget from the engine's POV; budget
  // tracking lives in the engine's loop). The agent uses its standard
  // toolbox: shell, files, cron, brain, web.
  const parseDiagnosticCompletion = (text: string, toolCallCount: number) => {
    const clean = String(text || '').replace(/\s+/g, ' ').trim();
    const pick = (re: RegExp) => {
      const m = clean.match(re);
      return m && m[1] ? m[1].trim().toLowerCase() : '';
    };
    let verifierStatus = pick(/VERIFIER_STATUS:\s*(pass|fail|unknown)/i);
    let dispatchOutcome = pick(/DISPATCH_OUTCOME:\s*(fixed|failed|blocked|unknown|not_fixed)/i);
    const summaryMatch = clean.match(/SUMMARY:\s*(.+)$/i);
    const lower = clean.toLowerCase();

    if (dispatchOutcome === 'not_fixed') dispatchOutcome = 'failed';
    if (!verifierStatus) {
      if (/\bverifier(?: now)? passes\b|\bverifier passed\b/i.test(clean)) verifierStatus = 'pass';
      else if (/\bverifier(?: still)? fails\b|\bverifier failed\b|\bdoes not pass\b|\bdoesn't pass\b/i.test(clean)) verifierStatus = 'fail';
      else verifierStatus = 'unknown';
    }
    if (!dispatchOutcome) {
      if (verifierStatus === 'pass') dispatchOutcome = 'fixed';
      else if (
        lower.includes('operation was aborted due to timeout')
        || lower.includes('error calling ')
        || lower.includes('timed out')
        || (toolCallCount === 0 && lower.includes('error'))
      ) dispatchOutcome = 'failed';
      else dispatchOutcome = 'unknown';
    }

    return {
      verifierStatus,
      dispatchOutcome,
      summary: (summaryMatch?.[1] || clean || 'agent run completed').slice(0, 500),
    };
  };

  bridgeApp.post('/api/diagnose', async (req: any, res: any) => {
    if (bridgeToken) {
      const header = req.headers.authorization || '';
      const provided = header.startsWith('Bearer ') ? header.slice(7) : '';
      if (provided !== bridgeToken) {
        res.status(401).json({ error: 'unauthorized' });
        return;
      }
    }
    const { problem, budgetHours = 12 } = req.body || {};
    if (!problem || !problem.id || !problem.claim) {
      res.status(400).json({ error: 'problem with id+claim required' });
      return;
    }

    // Dedicated chatId namespace so diagnostic runs don't collide with
    // user chat or with each other.
    const chatId = `diagnose:${problem.id}`;

    // If the agent is already running something for this problem, short-circuit.
    if (agent.isRunning(chatId)) {
      res.status(409).json({ error: 'diagnosis already in progress for ' + problem.id });
      return;
    }

    // Build the mission message. Explicit, structured, includes prior attempts
    // so the agent doesn't repeat what's already been tried.
    const priorAttempts = Array.isArray(problem.remediationLog) && problem.remediationLog.length > 0
      ? problem.remediationLog.map((r: any, i: number) =>
          `  ${i + 1}. step ${r.step} ${r.type}: ${r.outcome} — ${r.detail}`).join('\n')
      : '  (none)';
    const verifierSpec = problem.verifier
      ? `${problem.verifier.type}(${JSON.stringify(problem.verifier.args || {})})`
      : '(none)';
    // Prior fix recipe — what worked the last time this problem was dispatched.
    // Gives the agent a strong head start instead of starting from scratch.
    const priorRecipe = problem.fixRecipe?.summary
      ? [
          '',
          'Prior successful fix recipe (last time this problem was dispatched):',
          `  ${String(problem.fixRecipe.summary).slice(0, 500)}`,
          '  → try this approach first; if conditions have changed, investigate why.',
        ].join('\n')
      : '';

    const mission = [
      `SYSTEM DIAGNOSTIC REQUEST — Live Problem: ${problem.id}`,
      '',
      'The rigid remediation plan for this problem did not resolve it. You are being',
      'handed this to diagnose and attempt a real fix using your full toolbox (shell,',
      'files, cron, brain, web, subagent). You have autonomy to investigate root',
      'cause, try fixes, and verify.',
      '',
      `Claim: ${problem.claim}`,
      `Verifier: ${verifierSpec}`,
      `  → this is how you know if the fix worked. Re-run the equivalent check`,
      `    yourself after any attempted fix; only claim success if it passes.`,
      '',
      'Prior remediation attempts (already tried, do not repeat):',
      priorAttempts,
      priorRecipe,
      '',
      `Budget: ~${budgetHours}h wall clock. You don't need to rush, but don't loop either.`,
      '',
      'DO:',
      '  - investigate the actual state of the system (shell, file reads, pings)',
      '  - look for siblings that work (e.g. a related cron, a related bridge)',
      '    and clone the pattern if that\'s the gap',
      '  - write scripts, register crons, fix configs, restart processes as needed',
      '  - verify with the verifier\'s check before declaring done',
      '  - if you can\'t fix it (needs physical access, jtr\'s decision, unknown',
      '    component), explain clearly why and what jtr would need to do',
      '',
      'DO NOT:',
      '  - notify jtr — that happens automatically if you can\'t fix it within budget',
      '  - do destructive operations without a verified need (no pm2 delete all,',
      '    no rm -rf on user data, no force pushes)',
      '  - pretend it\'s fixed when the verifier still fails',
      '',
      'End your session with 2-3 sentences plus this exact trailer:',
      '  VERIFIER_STATUS: pass|fail|unknown',
      '  DISPATCH_OUTCOME: fixed|failed|blocked|unknown',
      '  SUMMARY: one sentence on what happened',
    ].join('\n');

    try {
      const { turnId, response } = await agent.runWithTurn(chatId, mission);
      const postFixRecipe = async (payload: Record<string, unknown>) => {
        await fetch(`${ENGINE_BASE}/api/live-problems/${encodeURIComponent(problem.id)}/fix-recipe`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
        });
      };
      // Detach. The engine tracks budget separately via wall clock.
      // When the agent finishes, capture a fix-recipe back to the engine so
      // (a) the dashboard Signals tile can show it, and (b) the next dispatch
      // for this same problem gets it injected into the mission prompt.
      response.then(async (resp: import('./agent/types.js').AgentResponse) => {
        try {
          const minutes = Math.max(1, Math.round(resp.durationMs / 60000));
          const report = parseDiagnosticCompletion(resp.text, resp.toolCallCount);
          const summary = `agent ran ${resp.toolCallCount} tool calls in ~${minutes}min (model=${resp.model}). ${report.summary}`;
          await postFixRecipe({
            summary,
            turnId,
            toolCallCount: resp.toolCallCount,
            durationMs: resp.durationMs,
            dispatchOutcome: report.dispatchOutcome,
            verifierStatus: report.verifierStatus,
          });
          console.log(`[diagnose] ${problem.id} turn ${turnId} → fix-recipe captured (${resp.toolCallCount} tools, ${minutes}m)`);
        } catch (err: any) {
          console.warn(`[diagnose] ${problem.id} fix-recipe post failed:`, err?.message || err);
        }
      }).catch((err: any) => {
        console.error(`[diagnose] ${problem.id} turn ${turnId} error:`, err?.message || err);
        postFixRecipe({
          summary: `agent turn failed before completion: ${String(err?.message || err).slice(0, 500)}`,
          turnId,
          toolCallCount: 0,
          durationMs: 0,
          dispatchOutcome: 'failed',
          verifierStatus: 'unknown',
        }).catch((postErr: any) => {
          console.warn(`[diagnose] ${problem.id} failure post failed:`, postErr?.message || postErr);
        });
      });
      console.log(`[diagnose] ${problem.id}: dispatched agent turn ${turnId}`);
      res.json({ ok: true, turnId, chatId, problemId: problem.id, budgetHours });
    } catch (err) {
      const m = err instanceof Error ? err.message : String(err);
      console.error(`[diagnose] ${problem.id} dispatch error:`, m);
      res.status(500).json({ error: m });
    }
  });

  // Resumable chat routes — turn-based protocol for backgrounding/reconnect
  const chatTurnConfig = {
    agentName: AGENT_NAME,
    agent,
    history,
    token: bridgeToken || undefined,
    modelAliases: MODEL_ALIASES,
    instanceDir: INSTANCE_DIR,
  };
  bridgeApp.post('/api/chat/turn', createTurnStartHandler(chatTurnConfig));
  bridgeApp.get('/api/chat/stream', createTurnStreamHandler(chatTurnConfig));
  bridgeApp.post('/api/chat/stop-turn', createTurnStopHandler(chatTurnConfig));
  bridgeApp.get('/api/chat/pending', createPendingTurnsHandler(chatTurnConfig));
  bridgeApp.get('/api/chat/models', createModelsHandler(chatTurnConfig));
  bridgeApp.get('/api/chat/media', async (req, res) => {
    const filePath = req.query.path;
    if (!filePath || typeof filePath !== 'string') {
      res.status(400).json({ error: 'path required' });
      return;
    }

    const resolved = resolve(filePath);
    if (!resolved.startsWith(PROJECT_ROOT) && !resolved.startsWith('/tmp/')) {
      res.status(403).json({ error: 'Access denied' });
      return;
    }

    if (!existsSync(resolved)) {
      res.status(404).json({ error: 'File not found' });
      return;
    }

    res.sendFile(resolved);
  });

  // Device registration routes (iOS push)
  const deviceConfig = { agentName: AGENT_NAME, registry: deviceRegistry, token: bridgeToken || undefined };
  bridgeApp.post('/api/device/register', createRegisterDeviceHandler(deviceConfig));
  bridgeApp.delete('/api/device/register', createUnregisterDeviceHandler(deviceConfig));
  bridgeApp.get('/api/device/registry', createListDevicesHandler(deviceConfig));

  // Chat history routes (iOS initial load + conversation list)
  const historyRouteConfig = { agentName: AGENT_NAME, history, token: bridgeToken || undefined };
  bridgeApp.get('/api/chat/history', createChatHistoryHandler(historyRouteConfig));
  bridgeApp.get('/api/chat/conversations', createChatListHandler(historyRouteConfig));

  // Step 24 — Bridge-chat inject route for the engine-side publisher.
  // The engine's BridgeChatPublisher POSTs salient observations here so
  // they land on a bridge-chat lane without the engine needing direct
  // SQLite / bridge-chat access (cross-process boundary).
  bridgeApp.post('/api/bridge-chat/inject', async (req: any, res: any) => {
    try {
      if (!bridgeChat) {
        res.status(503).json({ error: 'bridge-chat not configured' });
        return;
      }
      const { lane = 'observations', from = 'os-engine', text } = req.body || {};
      if (!text || typeof text !== 'string') {
        res.status(400).json({ error: 'text required' });
        return;
      }
      const messageId = await bridgeChat.sendToLane(lane, from, text);
      res.json({ ok: true, messageId });
    } catch (err: any) {
      res.status(500).json({ error: err?.message || String(err) });
    }
  });

  // Step 24 — Neighbor protocol public state surface.
  // Returns a cached, minimal snapshot of this agent's state so peers can
  // ingest it as UNCERTIFIED observations via their own NeighborChannel.
  // See docs/design/STEP24-OS-ENGINE-REDESIGN.md §Neighbor Protocol.
  let publicStateCache: unknown = null;
  let publicStateAt = 0;
  const PUBLIC_STATE_CACHE_MS = 60_000;
  bridgeApp.get('/__state/public.json', async (_req: any, res: any) => {
    try {
      const now = Date.now();
      if (!publicStateCache || now - publicStateAt > PUBLIC_STATE_CACHE_MS) {
        const brainDir = process.env.COSMO_RUNTIME_DIR
          ? resolve(process.env.COSMO_RUNTIME_DIR)
          : '';
        let recentObservations: unknown[] = [];
        let lastMemoryWrite = '';
        if (brainDir) {
          try {
            const receiptsPath = join(brainDir, 'crystallization-receipts.jsonl');
            if (existsSync(receiptsPath)) {
              const raw = readFileSync(receiptsPath, 'utf8').trim().split('\n').slice(-20);
              recentObservations = raw.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
              const last = recentObservations[recentObservations.length - 1] as any;
              if (last?.at) lastMemoryWrite = last.at;
            }
          } catch { /* best-effort */ }
        }
        publicStateCache = {
          agent: AGENT_NAME,
          origin: {
            agent: AGENT_NAME,
            protocol: 'home23-neighbor-state',
            protocolVersion: 1,
          },
          activeGoals: [],        // Goals live engine-side; not exposed in Phase 8.
          recentObservations,     // Last 20 crystallization receipts.
          currentFocus: config.agent?.displayName ?? AGENT_NAME,
          dispatchState: 'idle',
          lastMemoryWrite,
          snapshotAt: new Date(now).toISOString(),
        };
        publicStateAt = now;
      }
      res.type('application/json').send(JSON.stringify(publicStateCache));
    } catch (err: any) {
      res.status(500).json({ error: err?.message || String(err) });
    }
  });

  bridgeApp.listen(BRIDGE_PORT, () => {
    console.log(`[home] Evobrew bridge listening on port ${BRIDGE_PORT} (/api/chat, /api/stop, /api/chat/turn, /api/chat/stream, /api/chat/pending, /api/chat/stop-turn, /api/chat/history, /api/chat/conversations, /api/device/register, /api/device/registry, /health)`);
  });

  // ── Startup banner ──
  console.log('');
  console.log('───────────────────────────────────────────────────');
  console.log(`  Home23 — ${config.agent?.displayName ?? AGENT_NAME} is LIVE`);
  console.log(`  Agent:     ${AGENT_NAME}`);
  console.log(`  Instance:  ${INSTANCE_DIR}`);
  console.log(`  Channels:  ${enabledAdapters.join(', ') || 'none'}`);
  console.log(`  Dashboard: ${ENGINE_BASE}`);
  console.log(`  Engine WS: ws://localhost:${ENGINE_WS_PORT}`);
  console.log(`  Bridge:    http://localhost:${BRIDGE_PORT}`);
  console.log(`  Cron:      ${scheduler ? `${scheduler.getJobs().length} job(s)` : 'disabled'}`);
  console.log(`  Tools:     ${registry.size}`);
  console.log(`  Model:     ${agent.getModel()} (${agent.getProvider()})`);
  console.log('───────────────────────────────────────────────────');
  console.log('');

  // ── Graceful shutdown ──
  let shutdownInProgress = false;

  const shutdown = async (signal: string): Promise<void> => {
    if (shutdownInProgress) return;
    shutdownInProgress = true;

    console.log(`\n[home] Received ${signal}, shutting down...`);

    engineEvents.stop();

    if (scheduler) {
      scheduler.stop();
    }

    try {
      await router.stopAll();
    } catch (err) {
      console.error('[home] Error stopping adapters:', err);
    }

    console.log('[home] Shutdown complete');
    process.exit(0);
  };

  process.on('SIGINT', () => shutdown('SIGINT'));
  process.on('SIGTERM', () => shutdown('SIGTERM'));
}

// ── Run ──

main().catch((err) => {
  console.error('[home] Fatal error:', err);
  process.exit(1);
});
