import fs from 'fs';
import net from 'net';
import path from 'path';
import { createHash, randomBytes } from 'node:crypto';
import { execFileSync } from 'child_process';

import {
  ASSISTANT_NAME,
  IDLE_TIMEOUT,
  POLL_INTERVAL,
  REPORTS_CHANNEL_JID,
  WAKE_SOCKET_PATH,
} from './config.js';
import './channels/index.js';
import {
  getChannelFactory,
  getRegisteredChannelNames,
  ChannelOpts,
} from './channels/registry.js';
import { DiscordChannel, DmAutoRegisterInfo, MentionAutoRegisterInfo, ThreadAutoRegisterInfo } from './channels/discord.js';
import {
  ContainerOutput,
  runContainerAgent,
  writeGroupsSnapshot,
  writeGroupSettingsSnapshot,
  writeTasksSnapshot,
} from './container-runner.js';
import {
  cleanupOrphans,
  ensureContainerRuntimeRunning,
} from './container-runtime.js';
import {
  getAllChats,
  getAllRegisteredGroups,
  getAllSessions,
  getAllTasks,
  getTasksForTenant,
  getMessagesSince,
  getNewMessages,
  getRouterState,
  initDatabase,
  setRegisteredGroup,
  deleteRegisteredGroup,
  setRouterState,
  setSession,
  storeChatMetadata,
  storeMessage,
  checkUsageLimit,
  logUsage,
  updateTenantRecord,
  deleteSession,
  setAgentStatus,
  resetAllAgentStatuses,
} from './db.js';
import {
  startTokenRefreshTimer,
  trackContainerCredential,
  untrackContainerCredential,
  writeTokenFile,
  removeTokenFile,
  syncCodexCredentialFromFile,
} from './credential-pool.js';
import { groupKey } from './group-folder.js';
import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath, resolveGroupIpcPath, resolveGroupSessionsPath } from './group-folder.js';
import { regenerateTenantCaddyfile, startIpcWatcher } from './ipc.js';
import { startDomainVerifier } from './domain-verifier.js';
import { findChannel, formatMessages, formatOutbound } from './router.js';
import type { Attachment } from './types.js';
import {
  isSenderAllowed,
  isTriggerAllowed,
  loadSenderAllowlist,
  shouldDropMessage,
} from './sender-allowlist.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { startHooksServer, createHookIpcHandler } from './hooks-server.js';
import { TenantManager } from './tenant-manager.js';
import { Channel, NewMessage, RegisteredGroup, Tenant } from './types.js';
import {
  ensureWorkspaceContract,
} from './workspace-contract.js';
import { logger, perfTimer } from "./logger.js";
import {
  getDefaultModelForProvider,
  getTenantProvider,
  normalizeProvider,
} from './runtime-provider.js';
import { shutdownActiveTenantRuntimeProcs } from './tenant-nerdctl.js';

function hashDashboardPassword(password: string): string {
  return createHash('sha256').update(password).digest('hex');
}

function generateDashboardPassword(length = 6): string {
  const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
  const bytes = randomBytes(length);
  let password = '';
  for (let i = 0; i < length; i++) {
    password += chars[bytes[i] % chars.length];
  }
  return password;
}
import {
  cleanupAttachmentFiles,
  stageAttachmentsForGroup,
} from './attachment-spool.js';

// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';

import { readEnvFile } from './env.js';
import { storeTurnPair, shouldCompact, runCompaction, makeSummarizeFunction, isLcmEnabled } from './lcm.js';

const VIEW_CHANNEL = (1n << 10n).toString();
const SEND_MESSAGES = (1n << 11n).toString();
const READ_MESSAGE_HISTORY = (1n << 16n).toString();
const MANAGE_CHANNELS = (1n << 4n).toString();
const TENANT_ALLOW = ((1n << 4n) | (1n << 10n) | (1n << 11n) | (1n << 16n)).toString();

function formatDiscordProgressMessage(progress: string): string {
  const trimmed = progress.trim();
  if (!trimmed.includes('\n')) return `> ⏳ ${trimmed}`;

  const escaped = trimmed.replace(/```/g, '``\\`');
  return `> ⏳ Running command\n\`\`\`sh\n${escaped}\n\`\`\``;
}

function finalResultAlreadySent(
  text: string,
  sentAssistantTexts: string[],
): boolean {
  const normalized = text.trim();
  if (!normalized) return true;
  if (sentAssistantTexts.includes(normalized)) return true;
  if (sentAssistantTexts.join('\n\n').trim() === normalized) return true;
  if (sentAssistantTexts.join('\n').trim() === normalized) return true;
  // Pi concatenates assistantText chunks without separators
  if (sentAssistantTexts.join('').trim() === normalized) return true;
  return false;
}

function isProgressMessagesEnabled(tenant?: Tenant): boolean {
  return (
    tenant?.show_progress_messages === true ||
    tenant?.show_progress_messages === 1 ||
    tenant?.show_progress_messages === '1'
  );
}

/** Grant a Discord user permission to view a category channel */
async function grantDiscordCategoryAccess(categoryId: string, userId: string): Promise<void> {
  const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
  const token = process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN;
  if (!token) return;

  const res = await fetch(`https://discord.com/api/v10/channels/${categoryId}/permissions/${userId}`, {
    method: 'PUT',
    headers: {
      'Authorization': `Bot ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      id: userId,
      type: 1, // member
      allow: TENANT_ALLOW,
      deny: '0',
    }),
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Discord API ${res.status}: ${body}`);
  }
}

let lastTimestamp = '';
let sessions: Record<string, import('./types.js').SessionRecord> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
const pipedMessageContent = new Map<string, string>();
// Track in-progress prompts per group so interrupted turns can be saved to LCM on shutdown
const pendingPrompts = new Map<string, string>();

// In-memory cache: message ID -> attachments (populated on arrival, consumed by startMessageLoop)
const pendingAttachments = new Map<string, import('./types.js').Attachment[]>();
const deferredAttachmentCleanup = new Map<string, import('./types.js').Attachment[]>();
let messageLoopRunning = false;

// --- Bridge attachment lazy downloader ---
//
// The discord-bridge sidecar stores messages immediately with embedded markers:
//   [discord-bridge-attachment:{"url":"...","name":"...","contentType":"..."}]
//
// nanoclaw downloads those lazily here, right before handing the message to the
// agent. This avoids any timing/race dependency between the bridge and nanoclaw's
// own async attachment handler.

const BRIDGE_ATTACHMENT_RE = /\[discord-bridge-attachment:(\{[^\]]+\})\]/g;

function parseBridgeAttachmentMarkers(messages: NewMessage[]): Map<string, Array<{url:string;name:string;contentType:string}>> {
  const result = new Map<string, Array<{url:string;name:string;contentType:string}>>();
  for (const msg of messages) {
    BRIDGE_ATTACHMENT_RE.lastIndex = 0;
    const metas: Array<{url:string;name:string;contentType:string}> = [];
    let m: RegExpExecArray | null;
    while ((m = BRIDGE_ATTACHMENT_RE.exec(msg.content)) !== null) {
      try { metas.push(JSON.parse(m[1])); } catch { /* skip malformed */ }
    }
    if (metas.length > 0) result.set(msg.id, metas);
  }
  return result;
}

function sniffBridgeMediaType(buf: Buffer, contentType: string): string {
  if (buf[0] === 0xFF && buf[1] === 0xD8) return 'image/jpeg';
  if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png';
  if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif';
  if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp';
  return contentType.split(';')[0].trim() || 'application/octet-stream';
}

async function downloadBridgeAttachments(messages: NewMessage[]): Promise<void> {
  const markerMap = parseBridgeAttachmentMarkers(messages);
  if (markerMap.size === 0) return;
  for (const [msgId, metas] of markerMap) {
    if (pendingAttachments.has(msgId)) continue; // live handler already populated
    const attachments: Attachment[] = [];
    for (const meta of metas) {
      try {
        const resp = await fetch(meta.url);
        if (!resp.ok) {
          logger.warn({ msgId, url: meta.url, status: resp.status }, 'bridge attachment download failed');
          continue;
        }
        const buf = Buffer.from(await resp.arrayBuffer());
        const ct = meta.contentType || '';
        const mediaType = sniffBridgeMediaType(buf, ct);
        if (ct.startsWith('image/') || /\.(jpe?g|png|gif|webp)$/i.test(meta.name)) {
          attachments.push({ kind: 'image', data: buf.toString('base64'), mediaType, name: meta.name });
        } else if (ct === 'application/pdf' || meta.name.endsWith('.pdf')) {
          attachments.push({ kind: 'document', data: buf.toString('base64'), mediaType: 'application/pdf', name: meta.name });
        } else {
          attachments.push({ kind: 'file', data: buf.toString('base64'), mediaType: ct || 'application/octet-stream', name: meta.name });
        }
        logger.info({ msgId, name: meta.name, bytes: buf.byteLength }, 'bridge attachment lazily downloaded');
      } catch (err: any) {
        logger.warn({ msgId, url: meta.url, err: err.message }, 'bridge attachment download error');
      }
    }
    if (attachments.length > 0) {
      pendingAttachments.set(msgId, attachments);
    }
  }
}


const channels: Channel[] = [];
const queue = new GroupQueue();
const tenantManager = new TenantManager();

// Groups currently showing a typing indicator (maintained by start/stopTypingForGroup in main()).
const activeTypingGroups = new Set<string>();

function summarizeAttachments(
  attachments?: Attachment[],
): Array<{ kind: string; mediaType: string; name: string }> {
  return (attachments || []).map((attachment) => ({
    kind: attachment.kind,
    mediaType: attachment.mediaType,
    name: attachment.name,
  }));
}

function peekPendingAttachments(messageIds: string[]): Attachment[] {
  return messageIds.flatMap((messageId) => pendingAttachments.get(messageId) || []);
}

function clearPendingAttachments(messageIds: string[]): void {
  for (const messageId of messageIds) {
    const attachments = pendingAttachments.get(messageId);
    cleanupAttachmentFiles(attachments);
    pendingAttachments.delete(messageId);
  }
}

function dropPendingAttachments(messageIds: string[]): void {
  for (const messageId of messageIds) {
    pendingAttachments.delete(messageId);
  }
}

function deferAttachmentCleanup(chatJid: string, attachments?: Attachment[]): void {
  if (!attachments?.length) return;
  const existing = deferredAttachmentCleanup.get(chatJid) || [];
  deferredAttachmentCleanup.set(chatJid, [...existing, ...attachments]);
}

function flushDeferredAttachmentCleanup(chatJid: string): void {
  const attachments = deferredAttachmentCleanup.get(chatJid);
  if (!attachments?.length) return;
  cleanupAttachmentFiles(attachments);
  deferredAttachmentCleanup.delete(chatJid);
}

function findTenantMainGroup(tenantId?: string): RegisteredGroup | undefined {
  if (!tenantId) return undefined;
  return Object.values(registeredGroups).find(
    (group) => group.tenantId === tenantId && group.isMain,
  );
}

function findTenantMainDiscordChannelId(tenantId?: string): string | undefined {
  if (!tenantId) return undefined;
  const entry = Object.entries(registeredGroups).find(
    ([jid, group]) => group.tenantId === tenantId && group.isMain && jid.startsWith('dc:'),
  );
  return entry?.[0]?.replace(/^dc:/, '');
}

function getTenantRuntimeFingerprint(tenant?: Tenant): string {
  return JSON.stringify({
    provider: getTenantProvider(tenant),
    model: tenant?.model ?? '',
    credentialId: tenant?.credential_id ?? '',
    hasAnthropicKey: Boolean(tenant?.anthropic_key),
    showProgressMessages: isProgressMessagesEnabled(tenant),
  });
}

function getChangedTenantRuntimeIds(previousTenants: Tenant[]): string[] {
  const changed: string[] = [];
  for (const prev of previousTenants) {
    const next = tenantManager.getTenant(prev.id);
    if (!next) continue;
    if (getTenantRuntimeFingerprint(prev) !== getTenantRuntimeFingerprint(next)) {
      changed.push(prev.id);
    }
  }
  return changed;
}

async function invalidateTenantRuntime(tenantId: string, reason: string): Promise<void> {
  const tenantGroups = Object.entries(registeredGroups)
    .filter(([, group]) => group.tenantId === tenantId);

  if (tenantGroups.length === 0) return;

  const killedGroups: string[] = [];
  const clearedSessions: string[] = [];

  for (const [jid, group] of tenantGroups) {
    delete sessions[group.folder];
    deleteSession(group.folder);
    clearedSessions.push(group.folder);

    try {
      const killed = await queue.killContainer(jid);
      if (killed) killedGroups.push(group.folder);
    } catch (err) {
      logger.warn({ tenantId, group: group.folder, err }, 'Failed to invalidate tenant runtime container');
    }
  }

  logger.info(
    {
      tenantId,
      reason,
      killedGroups,
      clearedSessions,
    },
    'Invalidated tenant runtime state',
  );
}

function buildDiscordPairingPrompt(): string {
  return [
    'Welcome to TreeHouse! 🌿',
    '',
    'To get started, please enter your 4-character pairing code. You should have received it along with your invite link.',
    '',
    "Just type the code here and I'll set everything up for you.",
  ].join('\n');
}

function buildDiscordOnboardingMessage(
  tenant: import('./types.js').Tenant,
  dashboardPassword: string,
): string {
  const mainChannelId = findTenantMainDiscordChannelId(tenant.id);
  const channelLink = mainChannelId
    ? `\n\nHead over to <#${mainChannelId}> to start working with your agent.`
    : '';
  const dashboardUrl = `https://${tenant.id}.devitltd.uk`;
  return `You're all set! Welcome to TreeHouse, ${tenant.name}! 🌿 Your private channels should now be visible.${channelLink}\n\n**Your dashboard:** ${dashboardUrl}\n**Username:** ${tenant.id}\n**Password:** ${dashboardPassword}\n\n_(You can change your password in the dashboard settings.)_`;
}

async function sendTenantMainChannelWelcome(tenant: import('./types.js').Tenant): Promise<void> {
  const mainChannelId = findTenantMainDiscordChannelId(tenant.id);
  if (!mainChannelId) return;
  const mainChannelJid = `dc:${mainChannelId}`;
  const ch = findChannel(channels, mainChannelJid);
  const welcomeMsg = `👋 Welcome, ${tenant.name}! Your TreeHouse environment is ready.\n\nSay hello to get started with your agent.`;
  await ch?.sendMessage(mainChannelJid, welcomeMsg);
}

async function completeDiscordTenantOnboarding(
  tenant: import('./types.js').Tenant,
  userId: string,
  reason: 'pairing' | 'known-user',
  pairingCode?: string,
): Promise<string> {
  if (tenant.discord_category_id) {
    try {
      await grantDiscordCategoryAccess(tenant.discord_category_id, userId);
    } catch (err) {
      logger.error({ tenantId: tenant.id, err, reason }, 'Failed to grant category access during onboarding');
    }
  }

  const dashboardPassword = generateDashboardPassword();
  const updates: Partial<import('./types.js').Tenant> = {
    dashboard_password: hashDashboardPassword(dashboardPassword),
  };
  if (reason === 'pairing') {
    updates.discord_user_id = userId;
    updates.discord_pairing_code = null;
  }
  updateTenantRecord(tenant.id, updates);
  tenantManager.reloadTenant(tenant.id);

  try {
    await sendTenantMainChannelWelcome(tenant);
  } catch (err) {
    logger.warn({ tenantId: tenant.id, err, reason }, 'Failed to send welcome message to tenant channel');
  }

  logger.info(
    { tenantId: tenant.id, userId, reason, code: pairingCode },
    'Tenant Discord onboarding completed',
  );

  return buildDiscordOnboardingMessage(tenant, dashboardPassword);
}

function loadState(): void {
  lastTimestamp = getRouterState('last_timestamp') || '';
  const agentTs = getRouterState('last_agent_timestamp');
  try {
    lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
  } catch {
    logger.warn('Corrupted last_agent_timestamp in DB, resetting');
    lastAgentTimestamp = {};
  }
  // Mutate in-place so hooks server (and other deps holding a reference) see updates
  const freshSessions = getAllSessions();
  for (const key of Object.keys(sessions)) {
    if (!(key in freshSessions)) delete sessions[key];
  }
  Object.assign(sessions, freshSessions);
  registeredGroups = getAllRegisteredGroups();

  for (const group of Object.values(registeredGroups)) {
    try {
      const groupDir = resolveGroupFolderPath(group.folder);
      if (group.tenantId && !group.isMain) {
        const mainGroup = Object.values(registeredGroups).find(
          (g) => g.tenantId === group.tenantId && g.isMain,
        );
        ensureWorkspaceContract(groupDir, {
          mainGroupDir: mainGroup ? resolveGroupFolderPath(mainGroup.folder) : undefined,
        });
      } else {
        ensureWorkspaceContract(groupDir);
      }
    } catch (err) {
      logger.warn({ group: group.folder, err }, 'Failed to ensure workspace contract during load');
    }
  }

  // Load tenants so tenant metadata is available for runtime and routing.
  tenantManager.loadFromDb();

  // Associate each registered group with its tenant in the queue
  for (const [jid, group] of Object.entries(registeredGroups)) {
    if (group.tenantId) {
      queue.setGroupTenant(jid, group.tenantId);
    }
  }

  logger.info(
    {
      groupCount: Object.keys(registeredGroups).length,
      tenantCount: tenantManager.getAllTenants().length,
    },
    'State loaded',
  );
}

function saveState(): void {
  setRouterState('last_timestamp', lastTimestamp);
  setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
}

/** Track bot tenant Discord channels so we can manage their lifecycle */
const botChannelsByToken = new Map<string, DiscordChannel>();

/** Concierge tenant ID — only this bot handles GuildMemberAdd / pairing DMs */
function getConcierge(): string {
  const envVars = readEnvFile(['CONCIERGE_TENANT_ID']);
  return process.env.CONCIERGE_TENANT_ID || envVars.CONCIERGE_TENANT_ID || '';
}

/**
 * Create/update Discord channels for bot tenants (tenants with their own bot token).
 * - Connects new bot tenant channels
 * - Disconnects channels for removed/changed bot tenants
 * - Updates jid routing for the platform Discord channel
 */
async function connectBotTenantChannels(
  channelOpts: ChannelOpts,
): Promise<void> {
  // Collect bot tokens and their jids from registered groups
  const tokenToJids = new Map<string, Set<string>>();
  const botTenantTokens = new Map<string, string>(); // tenantId -> token

  for (const tenant of tenantManager.getAllTenants()) {
    if (tenant.tenant_type === 'bot' && tenant.discord_bot_token) {
      botTenantTokens.set(tenant.id, tenant.discord_bot_token);
    }
  }

  // Map jids to bot tokens
  const botJids = new Set<string>();
  for (const [jid, group] of Object.entries(registeredGroups)) {
    if (!jid.startsWith('dc:') || !group.tenantId) continue;
    const token = botTenantTokens.get(group.tenantId);
    if (token) {
      if (!tokenToJids.has(token)) tokenToJids.set(token, new Set());
      tokenToJids.get(token)!.add(jid);
      botJids.add(jid);
    }
  }

  // Restrict platform Discord channel to non-bot jids
  const platformDiscord = channels.find(
    (ch) => ch instanceof DiscordChannel && (ch as DiscordChannel).label === 'platform',
  ) as DiscordChannel | undefined;
  if (platformDiscord && botJids.size > 0) {
    // Platform channel owns all dc: jids EXCEPT those claimed by bot tenants
    const platformJids = new Set<string>();
    for (const [jid] of Object.entries(registeredGroups)) {
      if (jid.startsWith('dc:') && !botJids.has(jid)) {
        platformJids.add(jid);
      }
    }
    platformDiscord.setOwnedJids(platformJids);
  } else if (platformDiscord) {
    // No bot tenants — platform channel owns all dc: jids (default behavior)
    platformDiscord.setOwnedJids(null);
  }

  // Disconnect removed bot channels
  for (const [token, ch] of botChannelsByToken) {
    if (!tokenToJids.has(token)) {
      logger.info({ label: ch.label }, 'Disconnecting removed bot tenant channel');
      await ch.disconnect();
      const idx = channels.indexOf(ch);
      if (idx >= 0) channels.splice(idx, 1);
      botChannelsByToken.delete(token);
    }
  }

  // Connect new bot channels or update existing jid sets
  for (const [token, jids] of tokenToJids) {
    const existing = botChannelsByToken.get(token);
    if (existing) {
      existing.setOwnedJids(jids);
      continue;
    }

    // Find the tenant label for logging
    let label = 'bot';
    for (const [tid, tok] of botTenantTokens) {
      if (tok === token) { label = tid; break; }
    }

    try {
      // Concierge bot gets onGuildMemberAdd (sends welcome DM) and onPairingAttempt
      const conciergeTenantId = getConcierge();
      const isConcierge = conciergeTenantId && label === conciergeTenantId;
      // Bot tenants never respond to DMs (Gaia pairing codes are intercepted before this check)
      const botIsDmAllowed = () => false;
      // Bot tenants must NOT get onCategoryAutoRegister — they only own explicitly
      // assigned channels.  Without this, a bot with Discord-level permissions on
      // another tenant's category would auto-claim new channels there, causing the
      // wrong bot identity to respond.
      const opts = isConcierge
        ? { ...channelOpts, onGuildMemberAdd: channelOpts.onGuildMemberAdd, isDmAllowed: botIsDmAllowed, onCategoryAutoRegister: undefined }
        : { ...channelOpts, onGuildMemberAdd: undefined, onPairingAttempt: undefined, isDmAllowed: botIsDmAllowed, onCategoryAutoRegister: undefined };
      const ch = new DiscordChannel(token, opts, label);
      ch.setOwnedJids(jids);
      await ch.connect();
      channels.push(ch);
      botChannelsByToken.set(token, ch);
      logger.info({ label, jidCount: jids.size, isConcierge }, 'Bot tenant Discord channel connected');
    } catch (err) {
      logger.error({ label, err }, 'Failed to connect bot tenant Discord channel');
    }
  }
}

function registerGroup(jid: string, group: RegisteredGroup): void {
  let groupDir: string;
  try {
    groupDir = resolveGroupFolderPath(group.folder);
  } catch (err) {
    logger.warn(
      { jid, folder: group.folder, err },
      'Rejecting group registration with invalid folder',
    );
    return;
  }

  registeredGroups[jid] = group;
  setRegisteredGroup(jid, group);

  // Create group folder
  fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });

  // Ensure the canonical workspace contract exists for this group. Non-main
  // groups inherit shared profile files from the tenant's main group.
  if (group.tenantId && !group.isMain) {
    for (const g of Object.values(registeredGroups)) {
      if (g.tenantId === group.tenantId && g.isMain) {
        const mainDir = resolveGroupFolderPath(g.folder);
        ensureWorkspaceContract(groupDir, { mainGroupDir: mainDir });
        logger.debug(
          { jid, mainGroupDir: mainDir },
          'Ensured workspace contract with inherited main-group files',
        );
        break;
      }
    }
  } else {
    ensureWorkspaceContract(groupDir);
  }

  // Write proxy.json for tenants with proxy_enabled flag, or propagate from main group
  if (group.tenantId) {
    const ipcDir = resolveGroupIpcPath(group.folder);
    fs.mkdirSync(ipcDir, { recursive: true });
    const proxyDest = path.join(ipcDir, 'proxy.json');
    if (!fs.existsSync(proxyDest)) {
      const tenant = group.tenantId ? tenantManager.getTenant(group.tenantId) : undefined;
      const proxyEnabled = tenant?.proxy_enabled === true || (tenant?.proxy_enabled as unknown) === 1;
      if (proxyEnabled) {
        const networkName = "nanoclaw-tenant-" + group.tenantId;
        try {
          const gateway = execFileSync('docker', [
            'network', 'inspect', networkName,
            '--format', '{{range .IPAM.Config}}{{.Gateway}}{{end}}',
          ], { encoding: 'utf8', timeout: 5000 }).trim();
          if (gateway) {
            const proxyInfo = { url: "socks5://" + gateway + ":1055", protocol: 'socks5', host: gateway, port: 1055 };
            fs.writeFileSync(proxyDest, JSON.stringify(proxyInfo, null, 2));
            logger.debug({ jid, folder: group.folder, gateway }, 'Created proxy.json for proxy-enabled tenant');
          }
        } catch (err) {
          logger.warn({ jid, tenantId: group.tenantId, err }, 'Failed to create proxy.json from Docker network');
        }
      }
      if (!fs.existsSync(proxyDest)) {
        const mainGroup = Object.values(registeredGroups).find(
          (g) => g.tenantId === group.tenantId && g.isMain,
        );
        if (mainGroup) {
          const mainIpcDir = resolveGroupIpcPath(mainGroup.folder);
          const proxySrc = path.join(mainIpcDir, 'proxy.json');
          if (fs.existsSync(proxySrc)) {
            try {
              fs.copyFileSync(proxySrc, proxyDest);
              logger.debug({ jid, folder: group.folder }, 'Propagated proxy.json from main group');
            } catch (err) {
              logger.warn({ jid, err }, 'Failed to propagate proxy.json');
            }
          }
        }
      }
    }
  }

  // Associate with tenant in queue
  if (group.tenantId) {
    queue.setGroupTenant(jid, group.tenantId);
  }

  logger.info(
    { jid, name: group.name, folder: group.folder, tenantId: group.tenantId },
    'Group registered',
  );
}

async function unregisterGroup(jid: string): Promise<void> {
  const group = registeredGroups[jid];
  if (!group) return;

  // Kill any running container and clear session
  delete sessions[group.folder];
  deleteSession(group.folder);
  try {
    await queue.killContainer(jid);
  } catch (err) {
    logger.warn({ jid, group: group.folder, err }, 'Failed to kill container during group unregistration');
  }

  // Remove from in-memory dict and DB
  delete registeredGroups[jid];
  deleteRegisteredGroup(jid);

  // Delete the group workspace folder
  const groupDir = resolveGroupFolderPath(group.folder);
  try {
    fs.rmSync(groupDir, { recursive: true, force: true });
  } catch (err) {
    logger.warn({ jid, groupDir, err }, 'Failed to delete group folder during unregistration');
  }

  logger.info({ jid, name: group.name, folder: group.folder, tenantId: group.tenantId }, 'Group unregistered and folder deleted');
}

/**
 * Get available groups list for the agent.
 * Returns groups ordered by most recent activity.
 */
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
  const chats = getAllChats();
  const registeredJids = new Set(Object.keys(registeredGroups));

  return chats
    .filter((c) => c.jid !== '__group_sync__' && c.is_group)
    .map((c) => ({
      jid: c.jid,
      name: c.name,
      lastActivity: c.last_message_time,
      isRegistered: registeredJids.has(c.jid),
    }));
}

/** @internal - exported for testing */
export function _setRegisteredGroups(
  groups: Record<string, RegisteredGroup>,
): void {
  registeredGroups = groups;
}

/**
 * Get the effective assistant name for a group.
 * Uses the tenant's display name if available, otherwise falls back to platform default.
 */
function getEffectiveAssistantName(group: RegisteredGroup): string {
  if (group.tenantId) {
    const tenant = tenantManager.getTenant(group.tenantId);
    return tenant?.name || ASSISTANT_NAME;
  }
  return ASSISTANT_NAME;
}

/**
 * Process all pending messages for a group.
 * Called by the GroupQueue when it's this group's turn.
 */
async function processGroupMessages(chatJid: string, onStartWorking?: () => void): Promise<boolean> {
  const group = registeredGroups[chatJid];
  if (!group) {
    logger.debug({ chatJid }, 'processGroupMessages: no registered group');
    return true;
  }
  logger.debug({ chatJid, group: group.name, folder: group.folder }, 'processGroupMessages: start');
  const _perfPGM = perfTimer('processGroupMessages', { group: group.name });

  // Check tenant status
  if (group.tenantId && !tenantManager.isTenantActive(group.tenantId)) {
    logger.warn(
      { chatJid, tenantId: group.tenantId },
      'Tenant not active, skipping messages',
    );
    return true;
  }

  // Check usage limits
  if (group.tenantId) {
    const limitCheck = checkUsageLimit(group.tenantId);
    if (!limitCheck.allowed) {
      logger.warn(
        { chatJid, tenantId: group.tenantId, reason: limitCheck.reason },
        'Tenant usage limit reached, skipping messages',
      );
      return true;
    }
  }

  const channel = findChannel(channels, chatJid);
  if (!channel) {
    logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
    return true;
  }

  const isMainGroup = group.isMain === true;
  const effectiveName = getEffectiveAssistantName(group);
  const tenant = group.tenantId
    ? tenantManager.getTenant(group.tenantId)
    : undefined;
  const showProgressMessages = isProgressMessagesEnabled(tenant);

  const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
  const missedMessages = getMessagesSince(
    chatJid,
    sinceTimestamp,
    effectiveName,
  );

  if (missedMessages.length === 0) {
    logger.debug({ chatJid, group: group.name, sinceTimestamp }, 'No messages since cursor, skipping');
    return true;
  }

  // Check if trigger is required and present
  // Main groups default to no trigger, but respect explicit requiresTrigger=true
  if (group.requiresTrigger === true || (!isMainGroup && group.requiresTrigger !== false)) {
    const allowlistCfg = loadSenderAllowlist();
    const hasTrigger = missedMessages.some(
      (m) =>
        m.is_trigger &&
        (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
    );
    if (!hasTrigger) {
      logger.debug({ chatJid, group: group.name, messageCount: missedMessages.length, triggers: missedMessages.map(m => ({ is_trigger: m.is_trigger, sender: m.sender_name })) }, 'No trigger found, skipping');
      return true;
    }
  }

  let prompt = formatMessages(missedMessages, tenant?.discord_user_id);
  pendingPrompts.set(chatJid, prompt); // track for LCM rescue on shutdown

  // No trigger-mode hint needed — the platform already filters messages before
  // spawning the agent. If the agent is running, it should always respond.

  // Collect attachments from in-memory cache (DB doesn't store binary data)
  const messageIds = missedMessages.map((message) => message.id);
  await downloadBridgeAttachments(missedMessages);
  _perfPGM.step('downloadBridgeAttachments');
  const allAttachments = peekPendingAttachments(messageIds);
  const stagedAttachments = stageAttachmentsForGroup(group.folder, allAttachments) || [];
  _perfPGM.step('stageAttachmentsForGroup', { attachmentCount: stagedAttachments.length });
  logger.info(
    {
      chatJid,
      messageIds,
      attachmentCount: stagedAttachments?.length || 0,
      attachments: summarizeAttachments(stagedAttachments),
      pendingAttachmentEntries: pendingAttachments.size,
    },
    'Collected cached attachments for container run',
  );

  // For auto-registered concierge groups (e.g. gaia/user-john), inject sender tenant context
  const folderParts = group.folder.split('/');
  if (folderParts.length === 2 && folderParts[1].startsWith('user-')) {
    const senderTenantId = folderParts[1].replace('user-', '');
    const senderTenant = tenantManager.getTenant(senderTenantId);
    if (senderTenant) {
      prompt = `<context>\nSender is tenant "${senderTenant.name}" (id: ${senderTenant.id}). Dashboard: https://${senderTenant.id}.devitltd.uk\n</context>\n${prompt}`;
    }
  }

  // Advance cursor
  const previousCursor = lastAgentTimestamp[chatJid] || '';
  lastAgentTimestamp[chatJid] =
    missedMessages[missedMessages.length - 1].timestamp;
  saveState();

  logger.info(
    {
      group: group.name,
      messageCount: missedMessages.length,
      tenantId: group.tenantId,
    },
    'Processing messages',
  );

  const processStartTime = Date.now();
  let idleTimer: ReturnType<typeof setTimeout> | null = null;

  const resetIdleTimer = () => {
    if (idleTimer) clearTimeout(idleTimer);
    idleTimer = setTimeout(() => {
      logger.debug(
        { group: group.name },
        'Idle timeout, closing container stdin',
      );
      queue.closeStdin(chatJid);
    }, IDLE_TIMEOUT);
  };

  let hadError = false;
  let outputSentToUser = false;
  const sentAssistantTexts: string[] = [];
  let turnStored = false;

  const voiceModel = channel.isVoiceTextChannel?.(chatJid) ? (process.env.VOICE_MODEL || undefined) : undefined;
  // Signal to the queue that actual work is starting (enables typing indicator)
  onStartWorking?.();

  _perfPGM.step('preRunAgent');
  const output = await runAgent(group, prompt, chatJid, async (result) => {
    if (queue.isKillSignaled(chatJid)) return;

    // Streamed assistant prose — always send immediately
    if (result.assistantText) {
      const text = result.assistantText.replace(/:$/, '.');
      await channel.sendMessage(chatJid, text);
      sentAssistantTexts.push(text.trim());
      outputSentToUser = true;
      // Re-trigger typing after a short delay — Discord clears it on every bot message.
      // Delay lets stopTypingForGroup() cancel re-triggering if the agent is already done.
      setTimeout(() => {
        if (activeTypingGroups.has(chatJid)) {
          channel.setTyping?.(chatJid, true)?.catch(() => {});
        }
      }, 500);
      resetIdleTimer();
      return;
    }

    // Tool use progress — send as a status message so the user sees what the agent is doing.
    if (result.progress && !result.result) {
      if (!showProgressMessages) {
        resetIdleTimer();
        return;
      }
      await channel.sendMessage(chatJid, formatDiscordProgressMessage(result.progress));
      outputSentToUser = true;
      // Re-trigger typing after a short delay — Discord clears it on every bot message.
      // Delay lets stopTypingForGroup() cancel re-triggering if the agent is already done.
      setTimeout(() => {
        if (activeTypingGroups.has(chatJid)) {
          channel.setTyping?.(chatJid, true)?.catch(() => {});
        }
      }, 500);
      resetIdleTimer();
      return;
    }

    if (result.result) {
      const raw =
        typeof result.result === 'string'
          ? result.result
          : JSON.stringify(result.result);
      const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
      logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);

      // Stop typing BEFORE sending the final message so the keepalive
      // interval can't re-trigger it after Discord auto-clears on send.
      if (result.status === 'success') {
        flushDeferredAttachmentCleanup(chatJid);
        queue.notifyIdle(chatJid);
      }

      if (text) {
        if (!finalResultAlreadySent(text, sentAssistantTexts)) {
          await channel.sendMessage(chatJid, text);
          outputSentToUser = true;
        }
        // Capture for LCM turn storage (whether or not it was re-sent)
        if (!sentAssistantTexts.includes(text.trim())) {
          sentAssistantTexts.push(text.trim());
        }
      }
      // Re-trigger typing if agent is still working (not a final result)
      if (!result.status) {
        channel.setTyping?.(chatJid, true)?.catch(() => {});
      }
      resetIdleTimer();
    }

    if (result.status === 'success' && !result.result) {
      flushDeferredAttachmentCleanup(chatJid);
      queue.notifyIdle(chatJid);
    }

    // LCM: store turn immediately on success so we don't lose it to idle timeout.
    // For piped turns (same container, multiple rounds), use pipedMessageContent
    // map set at queue.sendMessage call site for accurate userContent.
    if (result.status === 'success' && group.tenantId) {
      const agentTextNow = sentAssistantTexts.join('\n').trim();
      if (agentTextNow) {
        const inputTokensNow = Math.ceil(prompt.length / 4);
        const pipedContent = pipedMessageContent.get(chatJid);
        const userContentNow = pipedContent ?? prompt;
        if (pipedContent) pipedMessageContent.delete(chatJid);
        storeTurnPair({
          groupFolder: group.folder,
          sessionId: sessions[group.folder]?.id ?? group.folder,
          userContent: userContentNow,
          agentContent: agentTextNow,
          inputTokens: inputTokensNow,
        });
        sentAssistantTexts.length = 0; // reset for next piped round
        turnStored = true;

        // LCM: async compaction check immediately after storing (best-effort)
        if (shouldCompact(group.folder)) {
          const envSecrets = readEnvFile(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']);
          const apiKey = envSecrets['ANTHROPIC_API_KEY'];
          const oauthToken = envSecrets['CLAUDE_CODE_OAUTH_TOKEN'];
          if (apiKey || oauthToken) {
            const summarize = makeSummarizeFunction(apiKey || '', oauthToken || '');
            runCompaction(group.folder, summarize).catch((err) => {
              logger.warn({ group: group.name, err: err?.message }, 'LCM: compaction failed (best-effort)');
            });
          }
        }
      }
    }

    if (result.status === 'error') {
      hadError = true;
      if (result.error?.includes("You've hit your usage limit")) {
        await channel.sendMessage(
          chatJid,
          '⚠️ Usage limit reached. Please try again later or upgrade your plan.',
        );
        outputSentToUser = true;
      }
      flushDeferredAttachmentCleanup(chatJid);
    }
  }, stagedAttachments, voiceModel);
  _perfPGM.done({ outputStatus: output });

  if (idleTimer) clearTimeout(idleTimer);
  pendingPrompts.delete(chatJid); // turn complete, no longer needs rescue

  // Log usage
  if (group.tenantId) {
    const durationMs = Date.now() - processStartTime;
    const inputTokens = Math.ceil(prompt.length / 4); // rough char-to-token estimate
    logUsage(group.tenantId, group.folder, durationMs, inputTokens);

    // LCM: persist both sides of the turn for cross-session context
    const agentText = sentAssistantTexts.join('\n').trim();
    if (agentText && !turnStored) {
      storeTurnPair({
        groupFolder: group.folder,
        sessionId: sessions[group.folder]?.id ?? group.folder,
        userContent: prompt,
        agentContent: agentText,
        inputTokens,
      });
    }

    // LCM: async compaction trigger (best-effort, never blocks the turn)
    if (shouldCompact(group.folder)) {
      const envSecrets = readEnvFile(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']);
      const apiKey = envSecrets['ANTHROPIC_API_KEY'];
      const oauthToken = envSecrets['CLAUDE_CODE_OAUTH_TOKEN'];
      if (apiKey || oauthToken) {
        const summarize = makeSummarizeFunction(apiKey || '', oauthToken || '');
        runCompaction(group.folder, summarize).catch((err) => {
          logger.warn({ group: group.name, err: err?.message }, 'LCM: compaction failed (best-effort)');
        });
      }
    }
  }

  if (output === 'error' || hadError) {
    flushDeferredAttachmentCleanup(chatJid);
    if (outputSentToUser) {
      clearPendingAttachments(messageIds);
      logger.warn(
        { group: group.name },
        'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
      );
      return true;
    }
    lastAgentTimestamp[chatJid] = previousCursor;
    saveState();
    logger.warn(
      { group: group.name },
      'Agent error, rolled back message cursor for retry',
    );
    return false;
  }

  flushDeferredAttachmentCleanup(chatJid);
  clearPendingAttachments(messageIds);
  return true;
}

async function runAgent(
  group: RegisteredGroup,
  prompt: string,
  chatJid: string,
  onOutput?: (output: ContainerOutput) => Promise<void>,
  attachments?: Attachment[],
  modelOverride?: string,
): Promise<'success' | 'error'> {
  const _perfRA = perfTimer('runAgent', { group: group.name });
  const isMain = group.isMain === true;
  const effectiveName = getEffectiveAssistantName(group);

  // Get tenant for resource limits and secrets
  const tenant = group.tenantId
    ? tenantManager.getTenant(group.tenantId)
    : undefined;
  const provider = getTenantProvider(tenant);
  const sessionRecord = sessions[group.folder];
  const sessionId = sessionRecord?.runtime === provider ? sessionRecord.id : undefined;
  const mainGroup = !isMain ? findTenantMainGroup(group.tenantId) : undefined;
  const secretsResult = group.tenantId
    ? tenantManager.getSecretsForTenant(group.tenantId)
    : undefined;
  if (group.tenantId && !secretsResult) {
    logger.error({ tenantId: group.tenantId }, 'No credentials available for tenant, aborting agent run');
    await onOutput?.({
      status: 'error',
      result: "⚙️ I'm not available right now — no LLM credentials are configured for this tenant. Please contact your administrator.",
    });
    return 'error';
  }
  const secrets = secretsResult?.secrets;
  const credentialId = secretsResult?.credentialId;

  // hosting.json is served on-demand via IPC (hosting-snapshot). Tasks/groups/settings snapshots are still written at startup.

  // Wrap onOutput to track session ID (only from successful outputs —
  // saving from errors causes death loops when the session is corrupted)
  const wrappedOnOutput = onOutput
    ? async (output: ContainerOutput) => {
        if (output.newSessionId && output.status !== 'error') {
          sessions[group.folder] = { id: output.newSessionId, runtime: provider };
          setSession(group.folder, output.newSessionId, provider, group.tenantId);
        }
        await onOutput(output);
      }
    : undefined;

  // Write initial token file and track credential for proactive refresh
  if (secrets) {
    const ipcDir = resolveGroupIpcPath(group.folder);
    writeTokenFile(ipcDir, secrets, credentialId);
    _perfRA.step('writeTokenFile');
    if (credentialId) {
      trackContainerCredential(group.folder, credentialId);
    }
  }

  const startTime = Date.now();
  _perfRA.step('preContainerAgent');
  try {
    const output = await runContainerAgent(
      group,
      {
        prompt,
        sessionId,
        groupFolder: group.folder,
        chatJid,
        isMain,
        mainGroupFolder: mainGroup?.folder,
        assistantName: effectiveName,
        tenantId: group.tenantId,
        provider,
        model: modelOverride || getDefaultModelForProvider(provider, tenant),
        attachments: attachments && attachments.length > 0 ? attachments : undefined,
      },
      (proc, containerName) =>
        queue.registerProcess(chatJid, proc, containerName, group.folder),
      wrappedOnOutput,
      tenant,
      secrets,
    );

    if (output.newSessionId && output.status !== 'error') {
      sessions[group.folder] = { id: output.newSessionId, runtime: provider };
      setSession(group.folder, output.newSessionId, provider, group.tenantId);
    }

    if (output.status === 'error') {
      // Auto-recover: if the agent crashed quickly, the session is likely
      // corrupted. Clear it so the next run starts fresh.
      const elapsed = Date.now() - startTime;
      if (elapsed < 10_000 && sessions[group.folder] && !queue.isKillSignaled(chatJid)) {
        logger.warn(
          { group: group.name, elapsed, sessionId: sessions[group.folder].id },
          'Quick crash detected, clearing potentially corrupted session',
        );
        delete sessions[group.folder];
        deleteSession(group.folder);
      }
      logger.error(
        { group: group.name, error: output.error },
        'Container agent error',
      );
      // Send a user-visible message for known startup failures.
      // pi crashes before producing any output are almost always auth/credential issues
      // (output.error only contains the last 200 chars of stderr, so we use elapsed time as proxy).
      const errText = output.error ?? '';
      const isPiStartupCrash = provider === 'pi' && elapsed < 8_000;
      if (errText.includes('No API key found') || errText.includes('API key environment variable') || isPiStartupCrash) {
        await onOutput?.({
          status: 'error',
          result: "⚙️ I'm not available right now — the LLM backend credentials aren't configured. Please contact your administrator.",
        });
      }
      return 'error';
    }

    return 'success';
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code === 'ENOSPC') {
      logger.error({ group: group.name }, 'Disk full (ENOSPC), cannot launch agent');
      await onOutput?.({
        status: 'error',
        error: 'ENOSPC',
        result: '⚠️ Your workspace storage is full. Please free up space before sending more messages.',
      });
    } else {
      logger.error({ group: group.name, err }, 'Agent error');
    }
    return 'error';
  } finally {
    if (provider === 'codex' && credentialId) {
      syncCodexCredentialFromFile(
        credentialId,
        path.join(resolveGroupSessionsPath(group.folder), '.codex', 'auth.json'),
      );
    }
    // Cleanup: untrack credential and remove token file
    untrackContainerCredential(group.folder);
    const ipcDir = resolveGroupIpcPath(group.folder);
    removeTokenFile(ipcDir);
  }
}


// ---------------------------------------------------------------------------
// Wake socket — lets discord-bridge interrupt the poll sleep on new messages
// ---------------------------------------------------------------------------
let _wakeResolve: (() => void) | null = null;

function startWakeSocketServer(): void {
  // Remove stale socket file from previous run
  try { fs.unlinkSync(WAKE_SOCKET_PATH); } catch { /* ok */ }

  const server = net.createServer((socket) => {
    socket.destroy(); // we don't need to read anything — connection is the signal
    if (_wakeResolve) {
      const resolve = _wakeResolve;
      _wakeResolve = null;
      resolve();
    }
  });

  server.on('error', (err) => {
    logger.warn({ err }, 'Wake socket server error');
  });

  server.listen(WAKE_SOCKET_PATH, () => {
    logger.info({ path: WAKE_SOCKET_PATH }, 'Wake socket listening');
  });
}

/** Sleep for up to ms milliseconds, but wake early if discord-bridge signals. */
function pollSleep(ms: number): Promise<void> {
  return new Promise((resolve) => {
    const timer = setTimeout(() => {
      _wakeResolve = null;
      resolve();
    }, ms);
    _wakeResolve = () => {
      clearTimeout(timer);
      resolve();
    };
  });
}

async function startMessageLoop(): Promise<void> {
  if (messageLoopRunning) {
    logger.debug('Message loop already running, skipping duplicate start');
    return;
  }
  messageLoopRunning = true;

  logger.info(`NanoClaw running (default trigger: @${ASSISTANT_NAME})`);

  while (true) {
    try {
      const jids = Object.keys(registeredGroups);
      const { messages, newTimestamp } = getNewMessages(
        jids,
        lastTimestamp,
        ASSISTANT_NAME,
      );

      if (messages.length > 0) {
        logger.info({ count: messages.length }, 'New messages');

        lastTimestamp = newTimestamp;
        saveState();

        // Deduplicate by group
        const messagesByGroup = new Map<string, NewMessage[]>();
        for (const msg of messages) {
          const existing = messagesByGroup.get(msg.chat_jid);
          if (existing) {
            existing.push(msg);
          } else {
            messagesByGroup.set(msg.chat_jid, [msg]);
          }
        }

        for (const [chatJid, groupMessages] of messagesByGroup) {
          let group = registeredGroups[chatJid];
          if (!group) continue;

          // Check tenant status
          if (group.tenantId && !tenantManager.isTenantActive(group.tenantId)) {
            continue;
          }

          const channel = findChannel(channels, chatJid);
          if (!channel) {
            logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
            continue;
          }

          const isMainGroup = group.isMain === true;

          // Inline-drain update_group_settings tasks so settings take effect immediately
          // (before trigger check, avoiding the 1-second IPC poll window)
          if (group.folder) {
            try {
              const tasksDir = path.join(resolveGroupIpcPath(group.folder), 'tasks');
              if (fs.existsSync(tasksDir)) {
                const settingsFiles = fs.readdirSync(tasksDir)
                  .filter(f => f.endsWith('.json'));
                for (const file of settingsFiles) {
                  const filePath = path.join(tasksDir, file);
                  try {
                    const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
                    if (data.type === 'update_group_settings' && data.jid === chatJid && data.requiresTrigger !== undefined) {
                      const updated = { ...group, requiresTrigger: data.requiresTrigger };
                      registerGroup(chatJid, updated);
                      group = registeredGroups[chatJid]; // refresh local ref
                      logger.info({ chatJid, requiresTrigger: data.requiresTrigger }, 'Group settings applied inline (pre-trigger-check)');
                    }
                  } catch { /* ignore malformed files */ }
                }
              }
            } catch { /* ignore IPC dir errors */ }
          }

          const needsTrigger = group.requiresTrigger === true || (!isMainGroup && group.requiresTrigger !== false);
          const effectiveName = getEffectiveAssistantName(group);

          if (needsTrigger) {
            const allowlistCfg = loadSenderAllowlist();
            const hasTrigger = groupMessages.some(
              (m) =>
                m.is_trigger &&
                (m.is_from_me ||
                  isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
            );
            if (!hasTrigger) continue;
          }

          const allPending = getMessagesSince(
            chatJid,
            lastAgentTimestamp[chatJid] || '',
            effectiveName,
          );
          const messagesToSend =
            allPending.length > 0 ? allPending : groupMessages;
          const groupTenant = group.tenantId ? tenantManager.getTenant(group.tenantId) : undefined;
          const formatted = formatMessages(messagesToSend, groupTenant?.discord_user_id);

          const messageIds = messagesToSend.map((message) => message.id);
          if (queue.canAcceptIpcMessage(chatJid)) {
            await downloadBridgeAttachments(messagesToSend);
            const ipcAttachments = stageAttachmentsForGroup(group.folder, peekPendingAttachments(messageIds)) || [];
            logger.info(
              {
                chatJid,
                messageIds,
                attachmentCount: ipcAttachments.length,
                attachments: summarizeAttachments(ipcAttachments),
                pendingAttachmentEntries: pendingAttachments.size,
              },
              'Collected cached attachments for IPC handoff',
            );
            if (queue.sendMessage(chatJid, formatted, ipcAttachments.length > 0 ? ipcAttachments : undefined)) {
              pipedMessageContent.set(chatJid, formatted);
              deferAttachmentCleanup(chatJid, ipcAttachments);
              dropPendingAttachments(messageIds);
              logger.debug(
                { chatJid, count: messagesToSend.length },
                'Piped messages to active container',
              );
              lastAgentTimestamp[chatJid] =
                messagesToSend[messagesToSend.length - 1].timestamp;
              saveState();
              channel
                .setTyping?.(chatJid, true)
                ?.catch((err) =>
                  logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
                );
              continue;
            }
          }
          queue.enqueueMessageCheck(chatJid);
        }
      }
    } catch (err) {
      logger.error({ err }, 'Error in message loop');
    }
    await pollSleep(POLL_INTERVAL);
  }
}

function recoverPendingMessages(): void {
  for (const [chatJid, group] of Object.entries(registeredGroups)) {
    // Skip suspended tenants
    if (group.tenantId && !tenantManager.isTenantActive(group.tenantId)) {
      continue;
    }
    const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
    const effectiveName = getEffectiveAssistantName(group);
    const pending = getMessagesSince(chatJid, sinceTimestamp, effectiveName);
    if (pending.length > 0) {
      logger.info(
        { group: group.name, pendingCount: pending.length },
        'Recovery: found unprocessed messages',
      );
      queue.enqueueMessageCheck(chatJid);
    }
  }
}

function ensureContainerSystemRunning(): void {
  ensureContainerRuntimeRunning();
  cleanupOrphans();
}

async function main(): Promise<void> {
  ensureContainerSystemRunning();
  initDatabase();
  logger.info('Database initialized');
  resetAllAgentStatuses();
  loadState();

  // LCM: pre-warm embedding model so first turn doesn't pay the 6s ONNX load cost
  if (isLcmEnabled()) {
    import('./embedder.js').then(({ warmupEmbedder }) => warmupEmbedder()).catch(() => {});
  }

  // Graceful shutdown handlers
  let shutdownPromise: Promise<void> | null = null;
  const shutdown = async (signal: string) => {
    if (shutdownPromise) {
      logger.info({ signal }, 'Shutdown already in progress');
      return shutdownPromise;
    }

    shutdownPromise = (async () => {
      logger.info({ signal }, 'Shutdown signal received');
      const activeJids = queue.getActiveGroupJids();
      if (activeJids.length > 0) {
        const notice = '⚠️ The agent service is restarting for scheduled maintenance. Your session will resume shortly.';
        await Promise.allSettled(
          activeJids.map(jid => {
            const ch = findChannel(channels, jid);
            return ch ? ch.sendMessage(jid, notice) : Promise.resolve();
          }),
        );

        // Insert resume markers for groups that were actively working (not
        // idle-waiting) so the agent continues where it left off on restart.
        const workingJids = queue.getWorkingGroupJids();
        for (const jid of workingJids) {
          const group = registeredGroups[jid];
          if (!group) continue;
          const resumeContent =
            '[System] The agent was interrupted by a service restart while working on your last request. ' +
            'Please continue where you left off.';
          storeMessage(
            {
              id: `resume-${Date.now()}-${jid}`,
              chat_jid: jid,
              sender: 'system',
              sender_name: 'System',
              content: resumeContent,
              timestamp: new Date().toISOString(),
              is_from_me: false,
              is_bot_message: false,
              is_trigger: true,
            },
            group.tenantId,
          );
          logger.info({ chatJid: jid }, 'Inserted resume marker for active group');

          // LCM: preserve the interrupted prompt so context survives the restart
          if (isLcmEnabled() || group.tenantId) {
            const interruptedPrompt = pendingPrompts.get(jid);
            if (interruptedPrompt && group.tenantId) {
              storeTurnPair({
                groupFolder: group.folder,
                sessionId: sessions[group.folder]?.id ?? group.folder,
                userContent: interruptedPrompt,
                agentContent: '[Response interrupted — service restarted before completion]',
                inputTokens: 0,
              });
              logger.info({ chatJid: jid }, 'LCM: stored interrupted turn pair on shutdown');
            }
          }
        }
      }

      const shutdownBudgetMs = 10000;
      await Promise.allSettled([
        queue.shutdown(shutdownBudgetMs),
        shutdownActiveTenantRuntimeProcs(shutdownBudgetMs),
      ]);
      resetAllAgentStatuses();
      await Promise.allSettled(channels.map((ch) => ch.disconnect()));
      process.exit(0);
    })().catch((err) => {
      logger.error({ signal, err }, 'Graceful shutdown failed');
      process.exit(1);
    });

    return shutdownPromise;
  };
  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));
  process.on('SIGHUP', async () => {
    logger.info('SIGHUP received, reloading tenants and groups from DB');
    const previousTenants = tenantManager.getAllTenants();
    loadState();
    const changedTenantRuntimeIds = getChangedTenantRuntimeIds(previousTenants);
    for (const tenantId of changedTenantRuntimeIds) {
      await invalidateTenantRuntime(tenantId, 'tenant runtime config changed');
    }
    await connectBotTenantChannels(channelOpts);
    // Regenerate Caddyfiles so custom domain changes from MC take effect
    const allTenants = tenantManager.getAllTenants();
    for (const tenant of allTenants) {
      try {
        await regenerateTenantCaddyfile(tenant.id);
      } catch (err: any) {
        logger.warn({ tenantId: tenant.id, err: err.message }, 'SIGHUP: failed to regen Caddyfile');
      }
    }
  });

  /**
   * DM auto-registration: when a user DMs the bot, bind the DM to the tenant
   * whose paired Discord user ID matches the sender.
   */
  function onDmAutoRegister(info: DmAutoRegisterInfo): boolean {
    const { userId, channelId, senderName } = info;
    const chatJid = `dc:${channelId}`;

    // Check if already registered (race condition guard)
    if (registeredGroups[chatJid]) return true;

    // Match directly by the paired Discord user ID.
    let matchedTenant: import('./types.js').Tenant | undefined;

    for (const tenant of tenantManager.getAllTenants()) {
      if (tenant.discord_user_id === userId) {
        matchedTenant = tenant;
        break;
      }
    }

    if (!matchedTenant) {
      logger.debug(
        { userId, senderName },
        'DM auto-register: user has no tenant mapping',
      );
      return false;
    }

    // Register a personal DM group for this user under the tenant
    const folderName = `dm-${userId}`;
    const compositeFolder = groupKey(matchedTenant.id, folderName);
    const group: RegisteredGroup = {
      name: `DM: ${senderName}`,
      folder: compositeFolder,
      added_at: new Date().toISOString(),
      requiresTrigger: false, // DMs never need triggers
      isMain: false,
      tenantId: matchedTenant.id,
    };

    try {
      registerGroup(chatJid, group);
      logger.info(
        { chatJid, userId, senderName, tenantId: matchedTenant.id, folder: compositeFolder },
        'DM group auto-registered',
      );
      return true;
    } catch (err) {
      logger.error(
        { chatJid, userId, err },
        'Failed to auto-register DM group',
      );
      return false;
    }
  }

  /**
   * Mention auto-registration: when a bot tenant's bot is mentioned (or DM'd)
   * in an unregistered channel, check if the sender is a known tenant (by discord_user_id).
   * If so, auto-register the channel under the bot tenant (e.g. Gaia concierge).
   */
  function onMentionAutoRegister(info: MentionAutoRegisterInfo): boolean {
    const { userId, channelId, senderName, isDm, botLabel } = info;
    const chatJid = `dc:${channelId}`;

    if (registeredGroups[chatJid]) return true;

    // The bot label is the tenant ID of the bot tenant (e.g. "gaia")
    const botTenant = tenantManager.getTenant(botLabel);
    if (!botTenant) return false;

    // Check if the sender's Discord user ID matches any tenant
    let senderTenant: import('./types.js').Tenant | undefined;
    for (const tenant of tenantManager.getAllTenants()) {
      if (tenant.discord_user_id === userId) {
        senderTenant = tenant;
        break;
      }
    }

    if (!senderTenant) {
      logger.debug(
        { userId, senderName, botLabel },
        'Mention auto-register: sender is not a known tenant',
      );
      return false;
    }

    // Register under the bot tenant with a folder named after the sender's tenant ID
    const folderName = `user-${senderTenant.id}`;
    const compositeFolder = groupKey(botTenant.id, folderName);
    const group: RegisteredGroup = {
      name: isDm ? `DM: ${senderName}` : `Mention: ${senderName}`,
      folder: compositeFolder,
      added_at: new Date().toISOString(),
      requiresTrigger: false,
      isMain: false,
      tenantId: botTenant.id,
    };

    try {
      registerGroup(chatJid, group);
      logger.info(
        { chatJid, userId, senderName, senderTenantId: senderTenant.id, botTenantId: botTenant.id, folder: compositeFolder },
        'Mention group auto-registered for concierge',
      );
      return true;
    } catch (err) {
      logger.error(
        { chatJid, userId, err },
        'Failed to auto-register mention group',
      );
      return false;
    }
  }

  /**
   * Thread auto-registration: when a message arrives in a Discord thread whose parent
   * channel is registered, automatically register the thread as an isolated group.
   * Threads always respond without requiring a trigger mention.
   */
  function onThreadAutoRegister(info: ThreadAutoRegisterInfo): boolean {
    const { threadId, threadName, parentChannelId, starterTimestamp } = info;
    const chatJid = `dc:${threadId}`;
    const parentChatJid = `dc:${parentChannelId}`;

    // Already registered (race condition guard)
    if (registeredGroups[chatJid]) return true;

    // Find parent group to inherit tenant and config
    const parentGroup = registeredGroups[parentChatJid];
    if (!parentGroup || !parentGroup.tenantId) {
      logger.debug({ chatJid, parentChatJid }, 'Thread auto-register: parent group not found or has no tenantId');
      return false;
    }

    // Derive a stable folder name for the thread.
    // Segment limit is 64 chars: "thread-" (7) + slug + "-" (1) + threadId (up to 20) = budget 36 for slug.
    const MAX_SLUG_LEN = 36;
    const rawSlug = threadName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 't';
    const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, '');
    const folderName = `thread-${slug}-${threadId}`;
    const compositeFolder = groupKey(parentGroup.tenantId, folderName);

    try {
      registerGroup(chatJid, {
        name: `Thread: ${threadName}`,
        folder: compositeFolder,
        added_at: new Date().toISOString(),
        requiresTrigger: false, // Always respond in threads — no @mention needed
        isMain: false,
        tenantId: parentGroup.tenantId,
        hooksToken: parentGroup.hooksToken,
        hooksEnabled: parentGroup.hooksEnabled,
      });
    } catch (err) {
      logger.error({ chatJid, threadId, err }, 'Failed to auto-register thread group');
      return false;
    }

    // registerGroup logs-and-returns on invalid folder without throwing — verify it stuck
    if (!registeredGroups[chatJid]) {
      logger.error({ chatJid, threadId, folder: compositeFolder }, 'Thread group registration silently failed (invalid folder?)');
      return false;
    }

    // Advance the parent channel's cursor to at least this message's timestamp so
    // the parent polling loop won't also process the message that spawned the thread.
    if (starterTimestamp) {
      const current = lastAgentTimestamp[parentChatJid] || '';
      if (starterTimestamp > current) {
        lastAgentTimestamp[parentChatJid] = starterTimestamp;
        saveState();
      }
    }

    logger.info(
      { chatJid, threadName, parentChatJid, tenantId: parentGroup.tenantId, folder: compositeFolder },
      'Thread group auto-registered',
    );
    return true;
  }

  // Channel callbacks (shared by all channels)
  const botAllowedJids = new Set<string>(REPORTS_CHANNEL_JID ? [REPORTS_CHANNEL_JID] : []);
  const channelOpts = {
    onMessage: (chatJid: string, msg: NewMessage) => {
      if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
        const cfg = loadSenderAllowlist();
        if (
          shouldDropMessage(chatJid, cfg) &&
          !isSenderAllowed(chatJid, msg.sender, cfg)
        ) {
          if (cfg.logDenied) {
            logger.debug(
              { chatJid, sender: msg.sender },
              'sender-allowlist: dropping message (drop mode)',
            );
          }
          return;
        }
      }
      // Pass tenant_id when storing messages
      const group = registeredGroups[chatJid];

      storeMessage(msg, group?.tenantId);
      // Cache attachments — not stored in DB, held until startMessageLoop consumes them
      if (msg.attachments && msg.attachments.length > 0) {
        pendingAttachments.set(msg.id, msg.attachments);
        logger.info(
          {
            chatJid,
            msgId: msg.id,
            attachmentCount: msg.attachments.length,
            attachments: summarizeAttachments(msg.attachments),
            pendingAttachmentEntries: pendingAttachments.size,
          },
          'Cached inbound attachments in memory',
        );
      } else {
        logger.info(
          {
            chatJid,
            msgId: msg.id,
            attachmentCount: 0,
          },
          'Inbound message had no cached attachments',
        );
      }
    },
    onChatMetadata: (
      chatJid: string,
      timestamp: string,
      name?: string,
      channel?: string,
      isGroup?: boolean,
    ) => {
      // Look up tenant from registered group
      const group = registeredGroups[chatJid];
      storeChatMetadata(
        chatJid,
        timestamp,
        name,
        channel,
        isGroup,
        group?.tenantId,
      );
    },
    registeredGroups: () => registeredGroups,
    onDmAutoRegister,
    onMentionAutoRegister,
    onThreadAutoRegister,
    onSlashCommand: (chatJid: string, command: string) => {
      const group = registeredGroups[chatJid];
      const ch = findChannel(channels, chatJid);
      if (command === 'stop') {
        // Save interrupted turn to LCM before killing, so context survives future nanoclaw restarts
        const interruptedPrompt = pendingPrompts.get(chatJid);
        if (interruptedPrompt && group?.tenantId) {
          storeTurnPair({
            groupFolder: group.folder,
            sessionId: sessions[group.folder]?.id ?? group.folder,
            userContent: interruptedPrompt,
            agentContent: '[Response interrupted by /stop]',
            inputTokens: 0,
          });
          pendingPrompts.delete(chatJid);
        }
        queue.killContainer(chatJid).then((killed) => {
          ch?.sendMessage(chatJid, killed ? '🛑 Agent stopped.' : 'No agent running.').catch(() => {});
        }).catch(() => {
          ch?.sendMessage(chatJid, '🛑 Agent stopped.').catch(() => {});
        });
      } else if (command === 'new') {
        queue.killContainer(chatJid).catch(() => {});
        if (group) {
          delete sessions[group.folder];
          deleteSession(group.folder);
        }
        ch?.sendMessage(chatJid, '🆕 Session cleared. Next message starts a fresh conversation.').catch(() => {});
      }
    },
    onGuildMemberAdd: async (userId: string, username: string, member: any) => {
      logger.info({ userId, username }, 'New member joined guild');
      const matchedTenant = tenantManager.getAllTenants().find(
        (tenant) => tenant.discord_user_id === userId,
      );

      try {
        if (matchedTenant) {
          const welcome = await completeDiscordTenantOnboarding(
            matchedTenant,
            userId,
            'known-user',
          );
          await member.send(welcome);
          logger.info({ userId, username, tenantId: matchedTenant.id }, 'Sent automatic welcome DM to known member');
          return;
        }

        await member.send(buildDiscordPairingPrompt());
        logger.info({ userId, username }, 'Sent pairing DM to new member');
      } catch (err: any) {
        logger.warn({ userId, err, tenantId: matchedTenant?.id }, 'Could not DM new member (DMs may be disabled)');
      }
    },
    onPairingAttempt: async (userId: string, code: string): Promise<string | null> => {
      // Find tenant by pairing code
      const allTenants = tenantManager.getAllTenants();
      const tenant = allTenants.find(
        (t) => t.discord_pairing_code && t.discord_pairing_code.toUpperCase() === code.toUpperCase(),
      );

      if (!tenant) {
        return "Hmm, I don't recognise that code. Please double-check and try again.";
      }

      if (tenant.discord_user_id) {
        return 'This account has already been paired. If you think this is an error, please reach out to the team.';
      }

      return completeDiscordTenantOnboarding(tenant, userId, 'pairing', code);
    },
    botAllowedJids,
    onCategoryAutoRegister: (info: { channelId: string; channelName: string; categoryId: string | null }): boolean => {
      const { channelId, channelName, categoryId } = info;
      const chatJid = `dc:${channelId}`;
      if (registeredGroups[chatJid]) return false;

      const allTenants = tenantManager.getAllTenants();
      const ownerTenant = allTenants.find(
        t => t.discord_category_id && t.discord_category_id === categoryId,
      );
      if (!ownerTenant) return false;

      const slug = channelName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || channelId;
      const folder = groupKey(ownerTenant.id, `discord-${slug}`);

      try {
        registerGroup(chatJid, {
          name: `#${channelName}`,
          folder,
          added_at: new Date().toISOString(),
          requiresTrigger: false,
          isMain: false,
          tenantId: ownerTenant.id,
        });
        logger.info({ chatJid, channelName, tenantId: ownerTenant.id }, 'Channel auto-registered via category fallback');
        return true;
      } catch (err) {
        logger.error({ chatJid, err }, 'Failed to auto-register channel');
        return false;
      }
    },
    onChannelDelete: (info: { channelId: string }) => {
      const { channelId } = info;
      const chatJid = `dc:${channelId}`;
      if (!registeredGroups[chatJid]) {
        logger.debug({ chatJid }, 'Deleted channel was not registered, ignoring');
        return;
      }
      unregisterGroup(chatJid).catch((err) => {
        logger.error({ chatJid, err }, 'Failed to unregister deleted Discord channel');
      });
    },
  };

  // Platform channels don't handle GuildMemberAdd or pairing (only the concierge bot does)
  // Platform bot only responds to DMs from admin tenants
  const platformIsDmAllowed = (userId: string): boolean => {
    for (const tenant of tenantManager.getAllTenants()) {
      if (tenant.discord_user_id === userId && tenant.is_admin) return true;
    }
    return false;
  };
  const platformOpts = { ...channelOpts, onGuildMemberAdd: undefined, onPairingAttempt: undefined, isDmAllowed: platformIsDmAllowed };

  // Create and connect all registered channels
  for (const channelName of getRegisteredChannelNames()) {
    const factory = getChannelFactory(channelName)!;
    const channel = factory(platformOpts);
    if (!channel) {
      logger.warn(
        { channel: channelName },
        'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
      );
      continue;
    }
    channels.push(channel);
    await channel.connect();
  }

  // Create additional Discord channels for bot tenants (tenants with their own bot token)
  await connectBotTenantChannels(channelOpts);

  if (channels.length === 0) {
    logger.fatal('No channels connected');
    process.exit(1);
  }

  // Clear any stale typing indicators left over from a previous crash or restart
  for (const chatJid of Object.keys(registeredGroups)) {
    const ch = findChannel(channels, chatJid);
    ch?.setTyping?.(chatJid, false).catch(() => {});
  }

  // Start subsystems
  startSchedulerLoop({
    registeredGroups: () => registeredGroups,
    getSessions: () => sessions,
    queue,
    tenantManager,
    onProcess: (groupJid, proc, containerName, groupFolder) =>
      queue.registerProcess(groupJid, proc, containerName, groupFolder),
    sendMessage: async (jid, rawText) => {
      const channel = findChannel(channels, jid);
      if (!channel) {
        logger.warn({ jid }, 'No channel owns JID, cannot send message');
        return;
      }
      const text = formatOutbound(rawText);
      if (text) await channel.sendMessage(jid, text);
    },
  });
  startIpcWatcher({
    sendMessage: (jid, text) => {
      const channel = findChannel(channels, jid);
      if (!channel) throw new Error(`No channel for JID: ${jid}`);
      return channel.sendMessage(jid, text);
    },
    sendFile: (jid, filePath, caption) => {
      const channel = findChannel(channels, jid);
      if (!channel) throw new Error(`No channel for JID: ${jid}`);
      if (channel.sendFile) return channel.sendFile(jid, filePath, caption);
      return channel.sendMessage(jid, caption || `[File: ${filePath}]`);
    },
    registeredGroups: () => registeredGroups,
    registerGroup,
    syncGroups: async (force: boolean) => {
      await Promise.all(
        channels
          .filter((ch) => ch.syncGroups)
          .map((ch) => ch.syncGroups!(force)),
      );
    },
    getAvailableGroups,
    writeGroupsSnapshot: (gf, im, ag, rj) =>
      writeGroupsSnapshot(gf, im, ag, rj),
    writeGroupSettingsSnapshot: (gf, settings) =>
      writeGroupSettingsSnapshot(gf, settings),
    refreshTasksSnapshot: (groupFolder: string) => {
      const group = Object.values(registeredGroups).find((g) => g.folder === groupFolder);
      // Scope tasks to this tenant only — never leak tasks across tenant boundaries
      const tenantTasks = group?.tenantId ? getTasksForTenant(group.tenantId) : getAllTasks();
      writeTasksSnapshot(
        groupFolder,
        group?.isMain ?? false,
        tenantTasks.map((t) => ({
          id: t.id,
          groupFolder: t.group_folder,
          prompt: t.prompt,
          scheduleType: t.schedule_type,
          scheduleValue: t.schedule_value,
          status: t.status,
          nextRun: t.next_run,
        })),
      );
    },
    onHookTask: createHookIpcHandler({
      registeredGroups: () => registeredGroups,
      sessions,
      queue,
      tenantManager,
      onProcess: (jid, proc, containerName, folder) =>
        queue.registerProcess(jid, proc, containerName, folder),
    }),
  });
  queue.setProcessMessagesFn(processGroupMessages);

  // Per-group typing keepalive intervals driven by WORKING/IDLE status transitions.
  const typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
  function sendTypingForGroup(chatJid: string): void {
    findChannel(channels, chatJid)?.setTyping?.(chatJid, true).catch(() => {});
  }
  function startTypingForGroup(chatJid: string): void {
    activeTypingGroups.add(chatJid);
    if (typingIntervals.has(chatJid)) return;
    sendTypingForGroup(chatJid);
    const interval = setInterval(() => sendTypingForGroup(chatJid), 5000);
    typingIntervals.set(chatJid, interval);
  }
  function stopTypingForGroup(chatJid: string): void {
    activeTypingGroups.delete(chatJid);
    const interval = typingIntervals.get(chatJid);
    if (interval) {
      clearInterval(interval);
      typingIntervals.delete(chatJid);
    }
    const ch = findChannel(channels, chatJid);
    ch?.setTyping?.(chatJid, false).catch(() => {});
  }

  queue.setStatusChangeFn((chatJid, status) => {
    setAgentStatus(chatJid, status);
    if (status === 'WORKING') startTypingForGroup(chatJid);
    else stopTypingForGroup(chatJid);
  });

  recoverPendingMessages();
  startTokenRefreshTimer(resolveGroupIpcPath);
  startDomainVerifier(async (tenantId) => {
    try {
      await regenerateTenantCaddyfile(tenantId);
    } catch (err: any) {
      logger.warn({ tenantId, err: err.message }, 'Domain verifier: failed to regen Caddyfile after activation');
    }
  });
  startHooksServer({
    registeredGroups: () => registeredGroups,
    sessions,
    queue,
    tenantManager,
    onProcess: (jid, proc, containerName, folder) =>
      queue.registerProcess(jid, proc, containerName, folder),
  });
  startWakeSocketServer();
  startMessageLoop().catch((err) => {
    logger.fatal({ err }, 'Message loop crashed unexpectedly');
    process.exit(1);
  });
}

// Guard: only run when executed directly, not when imported by tests
// Use fs.realpathSync to resolve symlinks so this works when invoked via /opt/nanoclaw/base -> treehouse/nanoclaw
const isDirectRun =
  process.argv[1] &&
  new URL(import.meta.url).pathname ===
    fs.realpathSync(new URL(`file://${process.argv[1]}`).pathname);

if (isDirectRun) {
  main().catch((err) => {
    logger.error({ err }, 'Failed to start NanoClaw');
    process.exit(1);
  });
}
