import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';
import { readEnvFile } from './env.js';
import { runCompaction, makeSummarizeFunction, searchLcm, expandSummaryNode, describeLcm, semanticSearchLcm, hybridSearchLcm } from './lcm.js';

import { CronExpressionParser } from 'cron-parser';

import {
  DATA_DIR,
  IPC_POLL_INTERVAL,
  TENANT_HOME_BASE,
  TIMEZONE,
REPORTS_CHANNEL_JID,
} from './config.js';
import {
  TenantPortRange,
  chooseAvailablePort,
  getTenantAppComposeProjectName,
  getTenantAppComposePath,
  getTenantAppsRoot,
  getTenantPortRange,
  resolveServiceRemovalSubdomains,
  buildHostingSnapshot,
  sanitizeAppLabel,
  validateTenantCompose,
} from './app-hosting.js';
import {
  buildNerdctlAppDeployPlan,
  getTenantAppSpecPath,
  getTenantLegacyAppSpecPath,
  validateTenantAppSpec,
  SdkContext,
} from './nerdctl-hosting.js';

const execAsync = promisify(exec);

const MC_PORT = 19100;
const HOOKS_PORT = parseInt(process.env.NANOCLAW_HOOKS_PORT || '19200', 10);
function tenantDashboardBlock(tenantId: string): string {
  return (
    `# Tenant dashboard\n` +
    `${tenantId}.devitltd.uk {\n` +
    `  handle /hooks/* {\n    reverse_proxy localhost:${HOOKS_PORT} {\n      flush_interval -1\n      transport http {\n        read_timeout 0\n      }\n    }\n  }\n` +
    `  handle {\n    reverse_proxy localhost:${MC_PORT}\n  }\n` +
    `}\n`
  );
}

export function isBlockedCustomDomain(domain: string): boolean {
  const d = domain.toLowerCase().trim();
  if (!d || d.length > 253) return true;
  // Must look like a valid FQDN with at least one dot
  if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(d) || !d.includes('.')) return true;
  if (d.endsWith('.devitltd.uk') || d === 'devitltd.uk') return true;
  if (d === 'mission.null' || d.endsWith('.null')) return true;
  if (d === 'localhost' || d.endsWith('.localhost')) return true;
  // Block bare IPs
  if (/^\d+\.\d+\.\d+\.\d+$/.test(d)) return true;
  return false;
}
import { AvailableGroup, writeGroupSettingsSnapshot } from './container-runner.js';
import {
  addCustomDomain,
  createTask,
  deleteTask,
  deleteTenantService,
  getAllTenants,
  getCustomDomain,
  getTaskById,
  getTenant,
  getTenantCustomDomains,
  getTenantServices,
  removeCustomDomain,
  updateTask,
  upsertTenantService,
  getHooksConfig,
  rotateHooksToken,
  setHooksEnabled,
} from './db.js';
import {
  isValidGroupFolder,
  isValidFolderName,
  resolveGroupFolderPath,
  resolveGroupIpcPath,
} from './group-folder.js';
import { logger } from './logger.js';
import {
  buildComposeCgroupOverride,
  getComposeCgroupOverridePath,
  getTenantSystemdSlice,
} from './tenant-cgroups.js';
import {
  connectContainerToNetwork,
  ensureTenantDockerNetwork,
  getTenantDockerNetworkName,
} from './container-runtime.js';
import { shouldCleanupGeneratedOutboundFile } from './outbound-media.js';
import { notifyTaskIpcMessage } from './task-scheduler.js';
import {
  deployTenantNerdctlApp,
  ensureTenantOwnedWorkspaceDir,
  execTenantNerdctlContainer,
  getTenantNerdctlAppDebugInfo,
  getTenantNerdctlContainerLogs,
  listTenantNerdctlVolumes,
  pruneTenantRuntime,
  resetTenantBuildkitStore,
  resetTenantRuntime,
  getTenantDiskUsage,
  removeTenantNerdctlApp,
  removeTenantNerdctlVolume,
  restartTenantNerdctlApp,
  cleanTenantProjectContainers,
} from './tenant-nerdctl.js';
import { RegisteredGroup } from './types.js';

interface HostingServiceDef {
  name?: string;
  subdomain?: string;
  port?: number;
  type?: 'proxy' | 'static' | string;
  root?: string;
  websocket?: boolean;
  basicAuth?: { user?: string; passwordHash?: string };
}

export function synthesizeAppApplyServices(data: any): HostingServiceDef[] | null {
  if (Array.isArray(data?.services)) {
    return data.services as HostingServiceDef[];
  }
  const appName = sanitizeAppLabel(typeof data?.app === 'string' ? data.app : '');
  if (!appName) return null;
  const subdomain = sanitizeAppLabel(
    typeof data?.subdomain === 'string' ? data.subdomain : appName,
  );
  const port = Number(data?.port);
  return [{
    name: appName,
    subdomain: subdomain || appName,
    ...(Number.isInteger(port) ? { port } : {}),
    type: 'proxy',
  }];
}

export interface HookIpcTask {
  type: 'hooks_agent' | 'hooks_wake' | 'hooks_invoke';
  prompt?: string;
  tool?: string;
  args?: unknown;
  requestId: string;
}

export interface IpcDeps {
  sendMessage: (jid: string, text: string) => Promise<void>;
  sendFile: (jid: string, filePath: string, caption?: string) => Promise<void>;
  registeredGroups: () => Record<string, RegisteredGroup>;
  registerGroup: (jid: string, group: RegisteredGroup) => void;
  syncGroups: (force: boolean) => Promise<void>;
  getAvailableGroups: () => AvailableGroup[];
  writeGroupsSnapshot: (
    groupFolder: string,
    isMain: boolean,
    availableGroups: AvailableGroup[],
    registeredJids: Set<string>,
  ) => void;
  writeGroupSettingsSnapshot: (groupFolder: string, settings: { requiresTrigger: boolean }) => void;
  refreshTasksSnapshot: (groupFolder: string) => void;
  /** Optional handler for hooks IPC tasks (hooks_agent, hooks_wake, hooks_invoke). */
  onHookTask?: (sourceGroup: string, task: HookIpcTask) => void;
}

function isProgressMessagesEnabled(
  value: boolean | number | string | undefined,
): boolean {
  return value === true || value === 1 || value === '1';
}

function shellQuote(value: string): string {
  return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}

async function ensureComposeCgroupOverride(
  composePath: string,
  tenantId: string,
  projectName: string,
): Promise<{ overridePath: string | null; serviceNames: string[] }> {
  const tenantSlice = getTenantSystemdSlice(tenantId);
  const { stdout } = await execAsync(
    `docker compose --project-name ${shellQuote(projectName)} -f ${shellQuote(composePath)} config --services`,
    { timeout: 120000 },
  );
  const serviceNames = stdout
    .split('\n')
    .map((line) => line.trim())
    .filter(Boolean);

  if (!tenantSlice) return { overridePath: null, serviceNames };

  if (serviceNames.length === 0) return { overridePath: null, serviceNames };

  const overridePath = getComposeCgroupOverridePath(composePath, tenantId);
  fs.mkdirSync(path.dirname(overridePath), { recursive: true });
  fs.writeFileSync(
    overridePath,
    buildComposeCgroupOverride(serviceNames, tenantSlice),
  );
  return { overridePath, serviceNames };
}

async function attachComposeAppToTenantNetwork(
  composePath: string,
  projectName: string,
  tenantId: string,
  appName: string,
  serviceNames: string[],
): Promise<void> {
  if (serviceNames.length === 0) return;

  const networkName = ensureTenantDockerNetwork(tenantId);
  for (const serviceName of serviceNames) {
    const { stdout } = await execAsync(
      `docker compose --project-name ${shellQuote(projectName)} -f ${shellQuote(composePath)} ps -q ${shellQuote(serviceName)}`,
      { timeout: 120000 },
    );
    const containerIds = stdout
      .split('\n')
      .map((line) => line.trim())
      .filter(Boolean);

    for (const containerId of containerIds) {
      const aliases = serviceNames.length === 1
        ? [appName, `${appName}-${serviceName}`]
        : [`${appName}-${serviceName}`];
      connectContainerToNetwork(containerId, networkName, aliases);
    }
  }
}

let ipcWatcherRunning = false;

/**
 * Scan IPC directories for all tenants.
 * Each tenant's IPC dirs live at /home/<tenantId>/nanoclaw/data/ipc/<folder>/.
 * Also scans the local DATA_DIR/ipc/ for non-tenant (flat) groups.
 */
function scanIpcFolders(): string[] {
  const folders: string[] = [];

  // Scan per-tenant IPC directories
  const tenants = getAllTenants();
  for (const tenant of tenants) {
    const tenantIpcDir = path.join(
      TENANT_HOME_BASE,
      tenant.id,
      'nanoclaw',
      'data',
      'ipc',
    );
    try {
      const entries = fs.readdirSync(tenantIpcDir);
      for (const entry of entries) {
        if (entry === 'errors') continue;
        const entryPath = path.join(tenantIpcDir, entry);
        try {
          if (fs.statSync(entryPath).isDirectory()) {
            const composite = `${tenant.id}/${entry}`;
            if (isValidGroupFolder(composite)) {
              folders.push(composite);
            }
          }
        } catch {
          continue;
        }
      }
    } catch {
      continue;
    }
  }

  // Also scan local DATA_DIR/ipc/ for flat (non-tenant) groups
  const localIpcDir = path.join(DATA_DIR, 'ipc');
  try {
    const entries = fs.readdirSync(localIpcDir);
    for (const entry of entries) {
      if (entry === 'errors') continue;
      const entryPath = path.join(localIpcDir, entry);
      try {
        if (!fs.statSync(entryPath).isDirectory()) continue;
        const hasIpcSubdirs =
          fs.existsSync(path.join(entryPath, 'messages')) ||
          fs.existsSync(path.join(entryPath, 'tasks')) ||
          fs.existsSync(path.join(entryPath, 'input'));
        if (hasIpcSubdirs && isValidFolderName(entry)) {
          folders.push(entry);
        }
      } catch {
        continue;
      }
    }
  } catch {
    // DATA_DIR/ipc/ may not exist
  }

  return folders;
}

function writeIpcError(
  groupIpcDir: string,
  tenantId: string,
  sourceGroup: string,
  prefix: string,
  error: string,
  details?: Record<string, unknown>,
): void {
  const errDir = path.join(groupIpcDir, 'errors');
  fs.mkdirSync(errDir, { recursive: true });
  const errFile = path.join(
    errDir,
    `${tenantId}-${sourceGroup.replace('/', '-')}-${prefix}-${Date.now()}.json`,
  );
  fs.writeFileSync(
    errFile,
    JSON.stringify(
      {
        error,
        ...details,
      },
      null,
      2,
    ),
  );
}

function writeHostingStatus(
  groupIpcDir: string,
  appName: string,
  details: Record<string, unknown>,
): void {
  const statusDir = path.join(groupIpcDir, 'hosting-status');
  fs.mkdirSync(statusDir, { recursive: true });
  fs.writeFileSync(
    path.join(statusDir, `${sanitizeAppLabel(appName) || 'unknown'}.json`),
    JSON.stringify(
      {
        appName,
        updatedAt: new Date().toISOString(),
        ...details,
      },
      null,
      2,
    ),
  );
}

async function refreshHostingSnapshot(tenantId: string, groupIpcDir: string): Promise<void> {
  try {
    const tenant = getTenant(tenantId);
    const snapshot = await buildHostingSnapshot(tenant ?? undefined);
    const hostingFile = path.join(groupIpcDir, 'hosting.json');
    fs.writeFileSync(hostingFile, JSON.stringify(snapshot, null, 2));
    logger.debug({ tenantId }, 'Hosting snapshot refreshed after container state change');
  } catch (err: any) {
    logger.warn({ tenantId, err: err.message }, 'Failed to refresh hosting snapshot');
  }
}

function buildTenantCaddyBlocks(tenantId: string): string[] {
  const appsRoot = getTenantAppsRoot(tenantId);
  const allServices = getTenantServices(tenantId);
  const customDomains = getTenantCustomDomains(tenantId).filter(
    (d) => d.status === 'active',
  );
  const blocks: string[] = [];

  // Dashboard custom domains
  const dashboardDomains = customDomains
    .filter((d) => d.target === 'dashboard')
    .map((d) => d.domain);
  if (dashboardDomains.length > 0) {
    for (const domain of dashboardDomains) {
      blocks.push(
        `# Custom dashboard domain\n${domain} {\n  reverse_proxy localhost:${MC_PORT}\n}`,
      );
    }
  }

  for (const { subdomain, config: svc } of allServices) {
    const svcType = (svc.type as string) || 'proxy';
    const directives: string[] = [];

    if (svc.basicAuth && typeof svc.basicAuth === 'object') {
      const ba = svc.basicAuth as { user?: string; passwordHash?: string };
      const user = String(ba.user || '').replace(/[^a-zA-Z0-9_.-]/g, '');
      const hash = String(ba.passwordHash || '');
      if (user && /^\$2[aby]\$\d+\$.{53}$/.test(hash)) {
        directives.push(`  basic_auth {\n    ${user} ${hash}\n  }`);
      }
    }

    if (svcType === 'static') {
      const rawRoot = String(svc.root || '.');
      const resolved = path.resolve(appsRoot, rawRoot);
      directives.push(`  root * ${resolved}`);
      directives.push('  file_server');
    } else {
      const port = Number(svc.port);
      if (svc.websocket) {
        directives.push(
          `  @ws {\n    header Connection *Upgrade*\n    header Upgrade websocket\n  }`,
        );
        directives.push(`  reverse_proxy @ws localhost:${port}`);
        directives.push(`  reverse_proxy localhost:${port}`);
      } else {
        directives.push(`  reverse_proxy localhost:${port}`);
      }
    }

    // Include active custom domains pointing at this app subdomain
    const appCustomDomains = customDomains
      .filter((d) => d.target === subdomain)
      .map((d) => d.domain);

    blocks.push(
      `${subdomain}.${tenantId}.devitltd.uk {\n${directives.join('\n')}\n}`,
    );
    // Each custom domain gets its own block so Caddy can independently
    // obtain its TLS certificate (combined blocks break ACME http-01 challenges
    // when the primary subdomain already has a cert)
    for (const customDomain of appCustomDomains) {
      blocks.push(`${customDomain} {\n${directives.join('\n')}\n}`);
    }
  }

  return blocks;
}

export async function regenerateTenantCaddyfile(tenantId: string): Promise<void> {
  const caddyPath = `/etc/caddy/tenants/${tenantId}.caddy`;
  const blocks = buildTenantCaddyBlocks(tenantId);
  const content =
    blocks.length > 0
      ? tenantDashboardBlock(tenantId) + '\n' + blocks.join('\n\n') + '\n'
      : tenantDashboardBlock(tenantId);
  const hadExisting = fs.existsSync(caddyPath);
  const backup = hadExisting ? fs.readFileSync(caddyPath, 'utf-8') : null;

  fs.writeFileSync(caddyPath, content);
  try {
    await execAsync(
      'caddy validate --config /etc/caddy/caddy_config --adapter caddyfile',
      { timeout: 15000 },
    );
    await execAsync('sudo systemctl reload caddy', { timeout: 10000 });
  } catch (validateErr: any) {
    if (backup != null) {
      fs.writeFileSync(caddyPath, backup);
    } else if (fs.existsSync(caddyPath)) {
      fs.unlinkSync(caddyPath);
    }
    throw validateErr;
  }
}

async function deployProxyService(
  tenantId: string,
  sourceGroup: string,
  groupIpcDir: string,
  svc: HostingServiceDef,
  portRange: TenantPortRange,
): Promise<{ success: boolean; subdomain?: string }> {
  const subdomain = sanitizeAppLabel(svc.subdomain || '');
  const appName = sanitizeAppLabel(svc.name || subdomain);
  const port = Number(svc.port);

  if (!subdomain || subdomain.length > 63) {
    logger.warn({ tenantId, subdomain }, 'IPC compose-apply: invalid subdomain, skipping');
    return { success: false };
  }
  if (!appName) {
    writeHostingStatus(groupIpcDir, 'unknown', {
      ok: false,
      stage: 'request',
      error: 'Proxy deployments require an app name.',
      service: svc,
    });
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'compose-apply',
      'Proxy deployments require a service name.',
      { service: svc },
    );
    return { success: false };
  }
  if (
    portRange.start == null ||
    portRange.end == null ||
    Number.isNaN(port) ||
    port < portRange.start ||
    port > portRange.end
  ) {
    writeHostingStatus(groupIpcDir, appName, {
      ok: false,
      stage: 'request',
      error: `Port ${port} is outside the allocated tenant range ${portRange.env ?? 'unknown'}.`,
      port,
      allowedRange: portRange.env,
      subdomain,
    });
    logger.warn(
      { tenantId, port, allowedRange: portRange.env },
      'IPC compose-apply: port outside tenant range, skipping',
    );
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'compose-apply',
      `Port ${port} is outside the allocated tenant range ${portRange.env ?? 'unknown'}.`,
      { appName, subdomain, port },
    );
    return { success: false };
  }

  const composePath = getTenantAppComposePath(tenantId, appName);
  if (!fs.existsSync(composePath)) {
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'compose-apply',
      `Missing compose file for app "${appName}". Expected ${composePath}.`,
      { appName, subdomain, composePath },
    );
    return { success: false };
  }

  const composeDir = path.dirname(composePath);
  const composeContent = fs.readFileSync(composePath, 'utf-8');
  const validation = validateTenantCompose({
    tenantId,
    composeDir,
    composeContent,
    requiredHostPort: port,
    portRange,
  });
  if (!validation.valid) {
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'compose-apply',
      `Compose validation failed for app "${appName}".`,
      {
        appName,
        subdomain,
        composePath,
        validationErrors: validation.errors,
      },
    );
    logger.warn(
      { tenantId, appName, errors: validation.errors },
      'IPC compose-apply: compose validation failed',
    );
    return { success: false };
  }

  try {
    const projectName = getTenantAppComposeProjectName(tenantId, appName);
    const {
      overridePath: cgroupOverridePath,
      serviceNames,
    } = await ensureComposeCgroupOverride(composePath, tenantId, projectName);
    const composeFiles = cgroupOverridePath
      ? `-f ${shellQuote(composePath)} -f ${shellQuote(cgroupOverridePath)}`
      : `-f ${shellQuote(composePath)}`;
    const { stdout } = await execAsync(
      `docker compose --project-name ${shellQuote(projectName)} ${composeFiles} up -d`,
      { timeout: 120000 },
    );
    await attachComposeAppToTenantNetwork(
      composePath,
      projectName,
      tenantId,
      appName,
      serviceNames,
    );
    logger.info(
      { tenantId, appName, cgroupOverridePath, stdout: stdout.slice(0, 200) },
      'Compose services started via IPC',
    );
  } catch (composeErr: any) {
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'compose-apply',
      `Failed to start compose app "${appName}".`,
      {
        appName,
        subdomain,
        composePath,
        stderr: composeErr.stderr || composeErr.message,
      },
    );
    logger.error(
      { tenantId, appName, err: composeErr.stderr || composeErr.message },
      'Failed to start compose services via IPC',
    );
    return { success: false };
  }

  upsertTenantService(tenantId, subdomain, {
    ...svc,
    name: appName,
    subdomain,
    port,
    type: 'proxy',
  });
  return { success: true, subdomain };
}

async function deployNerdctlProxyService(
  tenantId: string,
  sourceGroup: string,
  groupIpcDir: string,
  svc: HostingServiceDef,
  portRange: TenantPortRange,
  options?: { forceRebuild?: boolean; chatJid?: string },
): Promise<{ success: boolean; subdomain?: string }> {
  const subdomain = sanitizeAppLabel(svc.subdomain || '');
  const appName = sanitizeAppLabel(svc.name || subdomain);

  if (!subdomain || subdomain.length > 63) {
    logger.warn({ tenantId, subdomain }, 'IPC app-apply: invalid subdomain, skipping');
    return { success: false };
  }
  if (!appName) {
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'app-apply',
      'Proxy deployments require an app name.',
      { service: svc },
    );
    return { success: false };
  }

  const primarySpecPath = getTenantAppSpecPath(tenantId, appName);
  const legacySpecPath = getTenantLegacyAppSpecPath(tenantId, appName);
  const specPath = fs.existsSync(primarySpecPath)
    ? primarySpecPath
    : legacySpecPath;
  if (!fs.existsSync(specPath)) {
    writeHostingStatus(groupIpcDir, appName, {
      ok: false,
      stage: 'spec',
      error: `Missing app spec for "${appName}".`,
      specPath: primarySpecPath,
      legacySpecPath,
      subdomain,
    });
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'app-apply',
      `Missing app spec for "${appName}". Expected ${primarySpecPath}.`,
      { appName, subdomain, specPath: primarySpecPath, legacySpecPath },
    );
    return { success: false };
  }

  const rawSpec = fs.readFileSync(specPath, 'utf-8');
  const appDir = path.dirname(specPath);
  const tenantRecord = getTenant(tenantId);
  const storedServices = getTenantServices(tenantId);
  const existingService = storedServices.find((service) => service.subdomain === subdomain);
  const existingPort =
    typeof existingService?.config?.port === 'number'
      ? (existingService.config.port as number)
      : null;
  const requestedPort = Number(svc.port);
  const explicitPort =
    Number.isInteger(requestedPort) && requestedPort > 0 ? requestedPort : null;
  const snapshot = await buildHostingSnapshot(tenantRecord ?? undefined);
  const occupiedPorts = new Set<number>();
  for (const service of storedServices) {
    if (service.subdomain === subdomain) continue;
    const port = Number(service.config?.port);
    if (Number.isInteger(port) && port > 0) {
      occupiedPorts.add(port);
    }
  }
  for (const live of snapshot.liveContainers) {
    if (live.app && live.app === appName) continue;
    occupiedPorts.add(live.port);
  }
  for (const usedPort of snapshot.usedPorts) {
    if (existingPort != null && usedPort === existingPort) continue;
    occupiedPorts.add(usedPort);
  }

  if (
    explicitPort != null &&
    (portRange.start == null ||
      portRange.end == null ||
      explicitPort < portRange.start ||
      explicitPort > portRange.end)
  ) {
    logger.warn(
      { tenantId, port: explicitPort, allowedRange: portRange.env },
      'IPC app-apply: port outside tenant range, skipping',
    );
    writeHostingStatus(groupIpcDir, appName, {
      ok: false,
      stage: 'request',
      error: `Port ${explicitPort} is outside the allocated tenant range ${portRange.env ?? 'unknown'}.`,
      port: explicitPort,
      allowedRange: portRange.env,
      subdomain,
    });
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'app-apply',
      `Port ${explicitPort} is outside the allocated tenant range ${portRange.env ?? 'unknown'}.`,
      { appName, subdomain, port: explicitPort },
    );
    return { success: false };
  }

  let port: number | null = null;
  if (
    existingPort != null &&
    portRange.start != null &&
    portRange.end != null &&
    existingPort >= portRange.start &&
    existingPort <= portRange.end &&
    !occupiedPorts.has(existingPort)
  ) {
    port = existingPort;
  } else if (
    explicitPort != null &&
    portRange.start != null &&
    portRange.end != null &&
    explicitPort >= portRange.start &&
    explicitPort <= portRange.end &&
    !occupiedPorts.has(explicitPort)
  ) {
    port = explicitPort;
  } else {
    port = chooseAvailablePort(portRange, [...occupiedPorts]);
  }

  if (port == null) {
    logger.warn(
      { tenantId, appName, allowedRange: portRange.env },
      'IPC app-apply: no free host port available',
    );
    writeHostingStatus(groupIpcDir, appName, {
      ok: false,
      stage: 'request',
      error: `No free host port is available in ${portRange.env ?? 'the tenant range'}.`,
      port: explicitPort ?? existingPort ?? undefined,
      allowedRange: portRange.env,
      subdomain,
    });
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'app-apply',
      `No free host port is available in ${portRange.env ?? 'the tenant range'}.`,
      { appName, subdomain, requestedPort: explicitPort, existingPort },
    );
    return { success: false };
  }
  const allowedHostPaths: string[] = [];
  if (tenantRecord?.android_sdk === true || (tenantRecord?.android_sdk as unknown) === 1) {
    allowedHostPaths.push('/opt/android-sdk');
  }
  const validation = validateTenantAppSpec({
    tenantId,
    appName,
    appDir,
    rawSpec,
    requirePublicService: true,
    allowedHostPaths,
  });
  if (!validation.valid || !validation.spec) {
    writeHostingStatus(groupIpcDir, appName, {
      ok: false,
      stage: 'validation',
      error: `App spec validation failed for "${appName}".`,
      specPath,
      validationErrors: validation.errors,
      subdomain,
    });
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'app-apply',
      `App spec validation failed for "${appName}".`,
      {
        appName,
        subdomain,
        specPath,
        validationErrors: validation.errors,
      },
    );
    logger.warn(
      { tenantId, appName, errors: validation.errors },
      'IPC app-apply: app spec validation failed',
    );
    return { success: false };
  }

  // Pre-create bind mount source directories so containers start without errors
  for (const svc of validation.spec.services) {
    for (const vol of svc.volumes.filter((v) => v.type === 'bind')) {
      try {
        await ensureTenantOwnedWorkspaceDir(tenantId, vol.sourcePath, '0777');
      } catch (mkdirErr: any) {
        logger.warn(
          { tenantId, appName, path: vol.sourcePath, err: mkdirErr.message },
          'IPC app-apply: could not pre-create bind mount dir (continuing)',
        );
      }
    }
  }

  writeHostingStatus(groupIpcDir, appName, {
    ok: null,
    stage: 'building',
    subdomain,
    specPath,
    hostPort: port,
  });

  const sdkHostPath = path.join(process.cwd(), '..', 'sdk');
  const ipcHostPath = resolveGroupIpcPath(sourceGroup);
  const sdkContext: SdkContext | undefined = validation.spec.services.some((s) => s.sdk)
    ? {
        sdkHostPath,
        ipcHostPath,
        groupFolder: sourceGroup,
        chatJid: options?.chatJid ?? '',
        tenantId,
      }
    : undefined;

  try {
    const plan = buildNerdctlAppDeployPlan({
      tenantId,
      appName,
      appDir,
      hostPort: port,
      spec: validation.spec,
      sdkContext,
    });
    await deployTenantNerdctlApp(plan, { forceRebuild: options?.forceRebuild });
    const debugInfo = await getTenantNerdctlAppDebugInfo(tenantId, appName, 30);
    upsertTenantService(tenantId, subdomain, {
      ...svc,
      name: appName,
      subdomain,
      port,
      type: 'proxy',
      runtime: 'nerdctl',
      specPath,
      serviceUnitName: plan.unitName,
      projectName: plan.projectName,
      publicService: plan.publicServiceName,
    });
    writeHostingStatus(groupIpcDir, appName, {
      ok: true,
      stage: 'deployed',
      subdomain,
      specPath,
      hostPort: port,
      publicUrl: `https://${subdomain}.${tenantId}.devitltd.uk`,
      serviceUnitName: plan.unitName,
      projectName: plan.projectName,
      debug: debugInfo,
    });
    return { success: true, subdomain };
  } catch (nerdctlErr: any) {
    const debugInfo = await getTenantNerdctlAppDebugInfo(tenantId, appName, 50);
    writeHostingStatus(groupIpcDir, appName, {
      ok: false,
      stage: 'deploy',
      error: `Failed to deploy nerdctl app "${appName}".`,
      subdomain,
      specPath,
      hostPort: port,
      stderr: nerdctlErr.stderr || nerdctlErr.message,
      debug: debugInfo,
    });
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'app-apply',
      `Failed to deploy nerdctl app "${appName}".`,
      {
        appName,
        subdomain,
        specPath,
        stderr: nerdctlErr.stderr || nerdctlErr.message,
        debug: debugInfo,
      },
    );
    logger.error(
      { tenantId, appName, err: nerdctlErr.stderr || nerdctlErr.message },
      'Failed to deploy nerdctl services via IPC',
    );
    return { success: false };
  }
}

function registerStaticService(
  tenantId: string,
  sourceGroup: string,
  groupIpcDir: string,
  svc: HostingServiceDef,
): { success: boolean; subdomain?: string } {
  const appsRoot = getTenantAppsRoot(tenantId);
  const subdomain = sanitizeAppLabel(svc.subdomain || '');
  if (!subdomain || subdomain.length > 63) {
    logger.warn({ tenantId, subdomain }, 'IPC compose-apply: invalid subdomain, skipping');
    return { success: false };
  }

  const rawRoot = String(svc.root || '.');
  const resolved = path.resolve(appsRoot, rawRoot);
  if (!resolved.startsWith(appsRoot + path.sep) && resolved !== appsRoot) {
    logger.warn(
      { tenantId, subdomain, root: rawRoot, resolved },
      'IPC compose-apply: static root path traversal blocked, skipping',
    );
    writeIpcError(
      groupIpcDir,
      tenantId,
      sourceGroup,
      'compose-apply',
      `Static root "${rawRoot}" escapes the tenant apps directory.`,
      { subdomain, root: rawRoot, resolved },
    );
    return { success: false };
  }

  upsertTenantService(tenantId, subdomain, {
    ...svc,
    subdomain,
    type: 'static',
  });
  return { success: true, subdomain };
}

async function stopStoredService(
  tenantId: string,
  subdomain: string,
  svc: Record<string, unknown>,
): Promise<void> {
  if ((svc.type as string) === 'static') return;

  const appName = sanitizeAppLabel(String(svc.name || subdomain));
  if (!appName) return;
  const composePath = getTenantAppComposePath(tenantId, appName);
  if (!fs.existsSync(composePath)) return;
  const projectName = getTenantAppComposeProjectName(tenantId, appName);
  const cgroupOverridePath = getComposeCgroupOverridePath(composePath, tenantId);
  const composeFiles = fs.existsSync(cgroupOverridePath)
    ? `-f ${shellQuote(composePath)} -f ${shellQuote(cgroupOverridePath)}`
    : `-f ${shellQuote(composePath)}`;

  try {
    await execAsync(
      `docker compose --project-name ${shellQuote(projectName)} ${composeFiles} down --remove-orphans`,
      {
        timeout: 120000,
      },
    );
    logger.info({ tenantId, appName, subdomain }, 'Compose app stopped via IPC');
  } catch (err: any) {
    logger.warn(
      { tenantId, appName, subdomain, err: err.stderr || err.message },
      'Failed to stop compose app via IPC',
    );
  }
}

export function startIpcWatcher(deps: IpcDeps): void {
  if (ipcWatcherRunning) {
    logger.debug('IPC watcher already running, skipping duplicate start');
    return;
  }
  ipcWatcherRunning = true;

  const activeTenants = new Set<string>();

  const processOneGroup = async (
    sourceGroup: string,
    isMain: boolean,
    tenantId: string | undefined,
    registeredGroups: Record<string, RegisteredGroup>,
  ): Promise<void> => {
    const groupIpcDir = resolveGroupIpcPath(sourceGroup);
    const messagesDir = path.join(groupIpcDir, 'messages');
    const tasksDir = path.join(groupIpcDir, 'tasks');

    // Process messages from this group's IPC directory
    try {
      if (fs.existsSync(messagesDir)) {
        const messageFiles = fs
          .readdirSync(messagesDir)
          .filter((f) => f.endsWith('.json'));
        for (const file of messageFiles) {
          const filePath = path.join(messagesDir, file);
          try {
            const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
            if (data.type === 'message' && data.chatJid && data.text) {
              // Authorization: verify this group can send to this chatJid
              const targetGroup = registeredGroups[data.chatJid];
              // Cross-tenant check: non-main groups can send to any group within their tenant
              const sameTenant =
                targetGroup && targetGroup.tenantId === tenantId;
              if (isMain || sameTenant) {
                const tenant = tenantId ? getTenant(tenantId) : undefined;
                const showProgressMessages = isProgressMessagesEnabled(
                  tenant?.show_progress_messages,
                );
                const messageKind = data.kind === 'user_message' ? 'user_message' : 'progress';
                if (messageKind === 'progress' && !showProgressMessages) {
                  logger.info(
                    { chatJid: data.chatJid, sourceGroup, tenantId },
                    'IPC progress message suppressed by tenant setting',
                  );
                  continue;
                }
                await deps.sendMessage(data.chatJid, data.text);
                // Notify the task scheduler so it can suppress duplicate result delivery
                notifyTaskIpcMessage(data.chatJid);
                logger.info(
                  { chatJid: data.chatJid, sourceGroup },
                  'IPC message sent',
                );
              } else {
                logger.warn(
                  { chatJid: data.chatJid, sourceGroup, tenantId },
                  'Unauthorized IPC message attempt blocked',
                );
              }
            } else if (
              data.type === 'file' &&
              data.chatJid &&
              data.filePath
            ) {
              const targetGroup = registeredGroups[data.chatJid];
              const sameTenant =
                targetGroup && targetGroup.tenantId === tenantId;
              if (isMain || sameTenant) {
                // Resolve container path to host path.
                // Only /workspace/group/ has a known host translation — the SDK
                // copies files from other locations (e.g. /tmp) into
                // /workspace/group/.send-outbox/ before writing the IPC message.
                const hostPath = data.filePath.replace(
                  /^\/workspace\/group\//,
                  resolveGroupFolderPath(sourceGroup) + '/',
                );
                // Guard: if the resolved host path does not exist, notify the
                // agent rather than silently dropping the message.
                if (!fs.existsSync(hostPath)) {
                  logger.error(
                    { chatJid: data.chatJid, sourceGroup, hostPath },
                    'IPC file send: resolved host path does not exist — delivery skipped',
                  );
                  await deps.sendMessage(
                    data.chatJid,
                    'File send failed: file path could not be resolved on the host (' + data.filePath + '). Copy the file to /workspace/group/ first, then resend.',
                  );
                } else {
                  await deps.sendFile(
                    data.chatJid,
                    hostPath,
                    data.caption,
                  );
                  // Clean up SDK-staged outbox files and TTS-generated media.
                  if (
                    shouldCleanupGeneratedOutboundFile(hostPath) ||
                    hostPath.includes('/.send-outbox/')
                  ) {
                    try {
                      fs.rmSync(path.dirname(hostPath), { recursive: true, force: true });
                    } catch {
                      // Ignore cleanup failures.
                    }
                  }
                  logger.info(
                    { chatJid: data.chatJid, sourceGroup, file: hostPath },
                    'IPC file sent',
                  );
                }
              } else {
                logger.warn(
                  { chatJid: data.chatJid, sourceGroup, tenantId },
                  'Unauthorized IPC file send attempt blocked',
                );
              }
            } else if (data.type === 'lcm-compact' && tenantId) {
              // LCM PreCompact signal: run async compaction for this group
              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(sourceGroup, summarize).catch((err: Error) => {
                  logger.warn({ tenantId, sourceGroup, err: err?.message }, 'LCM PreCompact: compaction failed');
                });
              }
            } else if (data.type === 'lcm-search') {
              // LCM search: run search and write result file
              const resultFile = path.join(groupIpcDir, 'lcm-search-result.json');
              try {
                const pattern = typeof data.pattern === 'string' ? data.pattern : '';
                const hits = searchLcm(sourceGroup, pattern);
                fs.writeFileSync(resultFile, JSON.stringify({ done: true, hits }, null, 2));
              } catch (err: unknown) {
                fs.writeFileSync(resultFile, JSON.stringify({ done: true, error: (err as Error)?.message ?? String(err) }, null, 2));
              }
            } else if (data.type === 'lcm-expand') {
              // LCM expand: return source turns for a summary node
              const resultFile = path.join(groupIpcDir, 'lcm-expand-result.json');
              try {
                const summaryId = typeof data.summaryId === 'string' ? data.summaryId : '';
                const expanded = expandSummaryNode(sourceGroup, summaryId);
                fs.writeFileSync(resultFile, JSON.stringify({ done: true, ...expanded }, null, 2));
              } catch (err: unknown) {
                fs.writeFileSync(resultFile, JSON.stringify({ done: true, error: (err as Error)?.message ?? String(err) }, null, 2));
              }
            } else if (data.type === 'lcm-describe') {
              // LCM describe: stats or node description
              const resultFile = path.join(groupIpcDir, 'lcm-describe-result.json');
              try {
                const id = typeof data.id === 'string' ? data.id : undefined;
                const text = describeLcm(sourceGroup, id);
                fs.writeFileSync(resultFile, JSON.stringify({ done: true, text }, null, 2));
              } catch (err: unknown) {
                fs.writeFileSync(resultFile, JSON.stringify({ done: true, error: (err as Error)?.message ?? String(err) }, null, 2));
              }
            } else if (data.type === 'lcm-semantic-search') {
              // LCM semantic search: embed query, rank by cosine similarity
              const resultFile = path.join(groupIpcDir, 'lcm-semantic-search-result.json');
              const query = typeof data.query === 'string' ? data.query : '';
              const limit = typeof data.limit === 'number' ? data.limit : 5;
              semanticSearchLcm(sourceGroup, query, limit)
                .then((hits) => {
                  fs.writeFileSync(resultFile, JSON.stringify({ done: true, hits }, null, 2));
                })
                .catch((err: unknown) => {
                  fs.writeFileSync(resultFile, JSON.stringify({ done: true, error: (err as Error)?.message ?? String(err), hits: [] }, null, 2));
                });
            } else if (data.type === 'lcm-hybrid-search') {
              // LCM hybrid search: RRF merge of keyword + semantic results
              const resultFile = path.join(groupIpcDir, 'lcm-hybrid-search-result.json');
              const query = typeof data.query === 'string' ? data.query : '';
              const limit = typeof data.limit === 'number' ? data.limit : 5;
              hybridSearchLcm(sourceGroup, query, limit)
                .then((hits) => {
                  fs.writeFileSync(resultFile, JSON.stringify({ done: true, hits }, null, 2));
                })
                .catch((err: unknown) => {
                  fs.writeFileSync(resultFile, JSON.stringify({ done: true, error: (err as Error)?.message ?? String(err), hits: [] }, null, 2));
                });
            } else if (data.type === 'report' && tenantId) {
              if (REPORTS_CHANNEL_JID && data.text) {
                const header = 'Bug Report from ' + tenantId + ' (' + sourceGroup + ')';
                const reportText = header + '\n\n' + String(data.text);
                const channelId = REPORTS_CHANNEL_JID.replace(/^dc:/, '');
                const gaiaTenant = getTenant('gaia');
                const gaiaToken = gaiaTenant?.discord_bot_token;
                if (gaiaToken) {
                  const reportRes = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
                    method: 'POST',
                    headers: { 'Authorization': `Bot ${gaiaToken}`, 'Content-Type': 'application/json' },
                    body: JSON.stringify({ content: reportText.slice(0, 2000) }),
                  });
                  if (reportRes.ok) {
                    logger.info({ tenantId, sourceGroup }, 'IPC report sent to admin channel via Gaia bot');
                  } else {
                    const errBody = await reportRes.text().catch(() => '');
                    logger.error({ tenantId, sourceGroup, status: reportRes.status, errBody }, 'IPC report Discord API error — falling back to platform bot');
                    await deps.sendMessage(REPORTS_CHANNEL_JID, reportText.slice(0, 2000));
                  }
                } else {
                  await deps.sendMessage(REPORTS_CHANNEL_JID, reportText);
                  logger.info({ tenantId, sourceGroup }, 'IPC report sent to admin channel via platform bot (Gaia token unavailable)');
                }
              } else if (!REPORTS_CHANNEL_JID) {
                logger.warn({ tenantId }, 'IPC report received but NANOCLAW_REPORTS_CHANNEL not configured');
              }
            } else if ((data.type === 'compose-apply' || data.type === 'app-apply') && tenantId) {
              const tenant = getTenant(tenantId);
              const requestedServices = synthesizeAppApplyServices(data);
              if (requestedServices && tenant) {
                const portRange = getTenantPortRange(tenant);

                let anyServiceChanged = false;
                const deployedStaticServices: Array<{ subdomain: string }> = [];
                for (const svc of requestedServices) {
                  const svcType = svc.type || 'proxy';
                  if (svcType === 'static') {
                    const staticResult = registerStaticService(
                      tenantId,
                      sourceGroup,
                      groupIpcDir,
                      svc,
                    );
                    if (staticResult.success && staticResult.subdomain) {
                      deployedStaticServices.push({ subdomain: staticResult.subdomain });
                      anyServiceChanged = true;
                    }
                  } else {
                    if (data.type === 'app-apply') {
                      const chatJid = Object.keys(registeredGroups).find(
                        (jid) => registeredGroups[jid].folder === sourceGroup,
                      ) ?? '';
                      const result = await deployNerdctlProxyService(
                        tenantId,
                        sourceGroup,
                        groupIpcDir,
                        svc,
                        portRange,
                        { forceRebuild: data.force_rebuild === true, chatJid },
                      );
                      if (result.success) anyServiceChanged = true;
                    } else {
                      await deployProxyService(
                        tenantId,
                        sourceGroup,
                        groupIpcDir,
                        svc,
                        portRange,
                      );
                      anyServiceChanged = true;
                    }
                  }
                }

                if (!anyServiceChanged) {
                  logger.info({ tenantId }, `IPC ${data.type}: no services changed, skipping Caddyfile regeneration`);
                } else try {
                  await regenerateTenantCaddyfile(tenantId);
                  logger.info(
                    {
                      tenantId,
                      serviceCount: getTenantServices(tenantId).length,
                    },
                    'Caddyfile updated via IPC',
                  );
                  // Write hosting status for static services so tc app wait resolves
                  for (const { subdomain } of deployedStaticServices) {
                    writeHostingStatus(groupIpcDir, subdomain, {
                      ok: true,
                      subdomain,
                      url: 'https://' + subdomain + '.' + tenantId + '.devitltd.uk',
                    });
                  }
                  refreshHostingSnapshot(tenantId, groupIpcDir);
                } catch (caddyErr: any) {
                  logger.warn(
                    { tenantId, err: caddyErr.stderr || caddyErr.message },
                    `IPC ${data.type}: Caddy validation failed, rolled back`,
                  );
                  writeIpcError(
                    groupIpcDir,
                    tenantId,
                    sourceGroup,
                    data.type,
                    'Caddy validation failed while applying tenant services.',
                    {
                      stderr: caddyErr.stderr || caddyErr.message,
                    },
                  );
                }
              } else if (data.caddyContent) {
                logger.warn(
                  { tenantId },
                  'IPC compose-apply: raw caddyContent is no longer accepted, use services array',
                );
                writeIpcError(
                  groupIpcDir,
                  tenantId,
                  sourceGroup,
                  'compose-apply',
                  'raw caddyContent is not accepted. Use a "services" array instead.',
                  {
                    originalMessage: {
                      type: data.type,
                      hadCaddyContent: true,
                    },
                  },
                );
              } else {
                const missingReason = !tenant
                  ? 'tenant not found'
                  : 'missing or invalid "services" array or app name';
                logger.warn(
                  { tenantId, type: data.type, missingReason },
                  'IPC app-apply: rejecting message with missing services array',
                );
                const errorMsg = `${data.type} failed: ${missingReason}. The IPC message must include a valid "services" array or a valid "app" name. Invoke the treehouse-app-hosting skill for the correct format.`;
                writeIpcError(
                  groupIpcDir,
                  tenantId,
                  sourceGroup,
                  data.type as 'app-apply' | 'compose-apply',
                  errorMsg,
                  { originalMessage: { type: data.type } },
                );
                // Also write hosting status so tc app wait/status surfaces the error immediately
                const appNameForStatus = typeof data.app === 'string' && data.app ? data.app : 'unknown';
                writeHostingStatus(groupIpcDir, appNameForStatus, {
                  ok: false,
                  stage: 'validation',
                  error: errorMsg,
                });
              }
            } else if (
              (data.type === 'compose-remove' || data.type === 'compose-stop' || data.type === 'app-remove') &&
              tenantId
            ) {
              const storedServices = getTenantServices(tenantId);
              const requestedSubdomains: string[] = Array.isArray(data.subdomains)
                ? data.subdomains
                : data.subdomain
                  ? [data.subdomain]
                  : [];
              const requestedService =
                data.service != null
                  ? sanitizeAppLabel(String(data.service))
                  : data.app != null
                    ? sanitizeAppLabel(String(data.app))
                  : '';

              const subdomains = resolveServiceRemovalSubdomains(
                storedServices,
                requestedSubdomains,
                requestedService,
              );

              if (subdomains.length === 0) {
                logger.warn({ tenantId }, `IPC ${data.type}: no services specified`);
                writeIpcError(
                  groupIpcDir,
                  tenantId,
                  sourceGroup,
                  data.type,
                  'No app subdomains or service names were specified for removal.',
                );
              } else {
                const knownSubdomains = subdomains.filter((sd) =>
                  storedServices.some((entry) => entry.subdomain === sd),
                );
                if (knownSubdomains.length === 0) {
                  const known = storedServices.map((s) => `${s.subdomain} (name: ${s.config.name || s.subdomain})`).join(', ') || 'none';
                  logger.warn({ tenantId, subdomains }, `IPC ${data.type}: no matching registered services`);
                  writeIpcError(
                    groupIpcDir,
                    tenantId,
                    sourceGroup,
                    data.type,
                    `None of the specified subdomains match a registered service. Registered: ${known}. Use the "service" field to match by app name instead of subdomain.`,
                  );
                } else {
                for (const subdomain of knownSubdomains) {
                  const stored = storedServices.find(
                    (entry) => entry.subdomain === subdomain,
                  );
                  if (stored) {
                    const runtime = String(stored.config.runtime || '');
                    const appName = sanitizeAppLabel(
                      String(stored.config.name || subdomain),
                    );
                    if (runtime === 'nerdctl') {
                      await removeTenantNerdctlApp(tenantId, appName);
                    } else {
                      await stopStoredService(
                        tenantId,
                        subdomain,
                        stored.config,
                      );
                    }
                  }
                  deleteTenantService(tenantId, subdomain);
                  logger.info(
                    { tenantId, subdomain },
                    'Tenant service removed from DB',
                  );
                }

                try {
                  await regenerateTenantCaddyfile(tenantId);
                  logger.info(
                    {
                      tenantId,
                      remaining: getTenantServices(tenantId).length,
                    },
                    'Caddyfile updated after compose-remove',
                  );
                  refreshHostingSnapshot(tenantId, groupIpcDir);
                } catch (err: any) {
                  logger.error(
                    { tenantId, err: err.message },
                    `${data.type}: failed to update Caddy`,
                  );
                  writeIpcError(
                    groupIpcDir,
                    tenantId,
                    sourceGroup,
                    data.type,
                    'Caddy validation failed while removing tenant services.',
                    {
                      stderr: err.stderr || err.message,
                    },
                  );
                }
                }
              }
            } else if (data.type === 'app-restart' && tenantId) {
              const appName = sanitizeAppLabel(String(data.name || ''));
              if (!appName) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-restart', 'Missing or invalid app name.', {});
              } else {
                const services: string[] = Array.isArray(data.services)
                  ? data.services.map(String).filter(Boolean)
                  : [];
                try {
                  await restartTenantNerdctlApp(tenantId, appName, services.length ? services : undefined);
                  writeHostingStatus(groupIpcDir, appName, {
                    ok: true,
                    stage: 'restarted',
                    forceRecreated: true,
                    services: services.length ? services : 'all',
                  });
                  refreshHostingSnapshot(tenantId, groupIpcDir);
                  logger.info(
                    { tenantId, appName, services },
                    'IPC app-restart: containers force-recreated',
                  );
                } catch (err: any) {
                  // Auto-recovery: if the port is already allocated, force-remove
                  // all project containers and retry once before giving up.
                  if (String(err.message || err.stderr || '').includes('port is already allocated')) {
                    logger.warn({ tenantId, appName }, 'IPC app-restart: port conflict detected, cleaning containers and retrying');
                    try {
                      const projectName = `nanoclaw-${tenantId}-${appName}`;
                      await cleanTenantProjectContainers(tenantId, projectName);
                      await restartTenantNerdctlApp(tenantId, appName, services.length ? services : undefined);
                      writeHostingStatus(groupIpcDir, appName, {
                        ok: true,
                        stage: 'restarted',
                        forceRecreated: true,
                        services: services.length ? services : 'all',
                      });
                      refreshHostingSnapshot(tenantId, groupIpcDir);
                      logger.info({ tenantId, appName }, 'IPC app-restart: recovered after port conflict');
                    } catch (retryErr: any) {
                      writeHostingStatus(groupIpcDir, appName, {
                        ok: false,
                        stage: 'restart',
                        error: retryErr.message,
                        stderr: retryErr.stderr,
                      });
                      writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-restart', `Failed to restart "${appName}" after port-conflict recovery.`, {
                        appName,
                        services,
                        stderr: retryErr.stderr || retryErr.message,
                      });
                      logger.error({ tenantId, appName, err: retryErr.message }, 'IPC app-restart: failed after port-conflict retry');
                    }
                  } else {
                    writeHostingStatus(groupIpcDir, appName, {
                      ok: false,
                      stage: 'restart',
                      error: err.message,
                      stderr: err.stderr,
                    });
                    writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-restart', `Failed to restart "${appName}".`, {
                      appName,
                      services,
                      stderr: err.stderr || err.message,
                    });
                    logger.error({ tenantId, appName, err: err.message }, 'IPC app-restart: failed');
                  }
                }
              }
            } else if (data.type === 'app-auth' && tenantId) {
              const subdomain = sanitizeAppLabel(String(data.subdomain || ''));
              if (!subdomain) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-auth', 'Missing subdomain.', {});
              } else {
                const allServices = getTenantServices(tenantId);
                const existing = allServices.find((s) => s.subdomain === subdomain);
                if (!existing) {
                  writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: false, subdomain, error: 'No registered service found for this subdomain.' });
                } else if (data.remove) {
                  const updated = { ...existing.config, basicAuth: null };
                  upsertTenantService(tenantId, subdomain, updated);
                  try {
                    await regenerateTenantCaddyfile(tenantId);
                    writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: true, subdomain, action: 'removed' });
                    logger.info({ tenantId, subdomain }, 'IPC app-auth: basic auth removed');
                  } catch (err: any) {
                    writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: false, subdomain, error: err.message });
                    logger.error({ tenantId, subdomain, err: err.message }, 'IPC app-auth: Caddy regen failed');
                  }
                } else {
                  const user = String(data.user || '').replace(/[^a-zA-Z0-9_.-]/g, '');
                  const passwordHash = String(data.passwordHash || '');
                  const bcryptRegex = /^\$2[aby]\$\d+\$.{53}$/;
                  if (!user) {
                    writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: false, subdomain, error: 'Invalid user: must be alphanumeric.' });
                  } else if (!bcryptRegex.test(passwordHash)) {
                    writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: false, subdomain, error: 'Invalid passwordHash: must be a valid bcrypt hash.' });
                  } else {
                    const updated = { ...existing.config, basicAuth: { user, passwordHash } };
                    upsertTenantService(tenantId, subdomain, updated);
                    try {
                      await regenerateTenantCaddyfile(tenantId);
                      writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: true, subdomain, action: 'set', user });
                      logger.info({ tenantId, subdomain, user }, 'IPC app-auth: basic auth set');
                    } catch (err: any) {
                      writeHostingStatus(groupIpcDir, `app-auth-${subdomain}`, { ok: false, subdomain, error: err.message });
                      logger.error({ tenantId, subdomain, err: err.message }, 'IPC app-auth: Caddy regen failed');
                    }
                  }
                }
              }
            } else if (data.type === 'app-exec' && tenantId) {
              const appName = sanitizeAppLabel(String(data.app || ''));
              const service = String(data.service || '').replace(/[^a-zA-Z0-9_-]/g, '');
              const command: string[] = Array.isArray(data.command)
                ? data.command.map(String).filter(Boolean)
                : [];
              const resultKey = `${appName}-exec`;
              if (!appName || !service || command.length === 0) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-exec', 'app, service, and command are required.', { app: data.app, service: data.service });
              } else {
                try {
                  const { stdout, stderr } = await execTenantNerdctlContainer(tenantId, appName, service, command);
                  writeHostingStatus(groupIpcDir, resultKey, {
                    ok: true,
                    action: 'exec',
                    app: appName,
                    service,
                    command,
                    stdout: stdout || undefined,
                    stderr: stderr || undefined,
                  });
                  logger.info({ tenantId, appName, service, command }, 'IPC app-exec: complete');
                } catch (err: any) {
                  writeHostingStatus(groupIpcDir, resultKey, {
                    ok: false,
                    action: 'exec',
                    app: appName,
                    service,
                    command,
                    error: err.message,
                    stderr: err.stderr,
                  });
                  writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-exec', `Failed to exec in "${appName}/${service}".`, {
                    appName, service, command, stderr: err.stderr || err.message,
                  });
                  logger.error({ tenantId, appName, service, err: err.message }, 'IPC app-exec: failed');
                }
              }
            } else if (data.type === 'app-logs' && tenantId) {
              const appName = sanitizeAppLabel(String(data.app || ''));
              const service = String(data.service || '').replace(/[^a-zA-Z0-9_-]/g, '');
              const tail = Math.min(Math.max(Number(data.tail) || 50, 1), 1000);
              const resultKey = `${appName}-logs`;
              if (!appName || !service) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-logs', 'app and service are required.', { app: data.app, service: data.service });
              } else {
                try {
                  const { stdout } = await getTenantNerdctlContainerLogs(tenantId, appName, service, tail);
                  writeHostingStatus(groupIpcDir, resultKey, {
                    ok: true,
                    action: 'logs',
                    app: appName,
                    service,
                    tail,
                    logs: stdout,
                  });
                  logger.info({ tenantId, appName, service, tail }, 'IPC app-logs: complete');
                } catch (err: any) {
                  writeHostingStatus(groupIpcDir, resultKey, {
                    ok: false,
                    action: 'logs',
                    app: appName,
                    service,
                    error: err.message,
                    stderr: err.stderr,
                  });
                  writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-logs', `Failed to get logs for "${appName}/${service}".`, {
                    appName, service, stderr: err.stderr || err.message,
                  });
                  logger.error({ tenantId, appName, service, err: err.message }, 'IPC app-logs: failed');
                }
              }
            } else if (data.type === 'app-volume-remove' && tenantId) {
              const appName = sanitizeAppLabel(String(data.app || ''));
              const volumeName = String(data.volume || '').replace(/[^a-zA-Z0-9_-]/g, '');
              if (!appName || !volumeName) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-volume-remove', 'Missing app or volume name.', { app: data.app, volume: data.volume });
              } else {
                const resultKey = `${appName}-volumes`;
                try {
                  const { stdout, stderr } = await removeTenantNerdctlVolume(tenantId, appName, volumeName);
                  writeHostingStatus(groupIpcDir, resultKey, {
                    ok: true,
                    action: 'volume-removed',
                    app: appName,
                    volume: volumeName,
                    fullVolumeName: `nanoclaw-${tenantId}-${appName}-${volumeName}`,
                    stdout: stdout || undefined,
                    stderr: stderr || undefined,
                  });
                  logger.info({ tenantId, appName, volumeName }, 'IPC app-volume-remove: volume removed');
                } catch (err: any) {
                  writeHostingStatus(groupIpcDir, resultKey, {
                    ok: false,
                    action: 'volume-removed',
                    app: appName,
                    volume: volumeName,
                    error: err.message,
                    stderr: err.stderr,
                  });
                  writeIpcError(groupIpcDir, tenantId, sourceGroup, 'app-volume-remove', `Failed to remove volume "${volumeName}" for "${appName}".`, {
                    appName, volumeName, stderr: err.stderr || err.message,
                  });
                  logger.error({ tenantId, appName, volumeName, err: err.message }, 'IPC app-volume-remove: failed');
                }
              }
            } else if (data.type === 'app-volume-list' && tenantId) {
              const appName = sanitizeAppLabel(String(data.app || ''));
              const resultKey = appName ? `${appName}-volumes` : 'volumes';
              try {
                const volumes = await listTenantNerdctlVolumes(tenantId, appName || undefined);
                writeHostingStatus(groupIpcDir, resultKey, {
                  ok: true,
                  action: 'volume-list',
                  app: appName || 'all',
                  volumes: volumes ? volumes.split('\n').filter(Boolean) : [],
                });
                logger.info({ tenantId, appName }, 'IPC app-volume-list: complete');
              } catch (err: any) {
                writeHostingStatus(groupIpcDir, resultKey, {
                  ok: false,
                  action: 'volume-list',
                  error: err.message,
                });
                logger.error({ tenantId, appName, err: err.message }, 'IPC app-volume-list: failed');
              }
            } else if (data.type === 'domain-apply' && tenantId) {
              const rawDomain = String(data.domain || '').toLowerCase().trim();
              const target = String(data.target || 'dashboard').trim();
              const resultFile = path.join(groupIpcDir, 'domain-result.json');
              const writeResult = (ok: boolean, msg: string, extra?: object) => {
                fs.writeFileSync(
                  resultFile,
                  JSON.stringify({ ok, domain: rawDomain, message: msg, ...extra, timestamp: new Date().toISOString() }, null, 2),
                );
              };
              if (!rawDomain) {
                writeResult(false, 'domain is required');
              } else if (isBlockedCustomDomain(rawDomain)) {
                writeResult(false, `Domain "${rawDomain}" is not allowed — platform domains and IPs are reserved.`);
              } else {
                const existing = getCustomDomain(rawDomain);
                if (existing && existing.tenant_id !== tenantId) {
                  writeResult(false, `Domain "${rawDomain}" is already registered by another tenant.`);
                } else if (existing && existing.tenant_id === tenantId) {
                  writeResult(existing.status === 'active', `Domain "${rawDomain}" is already registered (status: ${existing.status}).`, {
                    verification_token: existing.verification_token,
                    txt_record: `_nanoclaw-verify.${rawDomain}`,
                    status: existing.status,
                  });
                } else {
                  try {
                    const row = addCustomDomain(tenantId, rawDomain, target);
                    writeResult(false, `Domain registered — add the DNS TXT record below to verify ownership, then wait up to 5 minutes.`, {
                      status: 'pending',
                      verification_token: row.verification_token,
                      txt_record: `_nanoclaw-verify.${rawDomain}`,
                      txt_value: row.verification_token,
                      target,
                    });
                    logger.info({ tenantId, domain: rawDomain, target }, 'IPC domain-apply: domain registered, awaiting DNS verification');
                  } catch (err: any) {
                    writeResult(false, `Failed to register domain: ${err.message}`);
                  }
                }
              }
            } else if (data.type === 'domain-remove' && tenantId) {
              const rawDomain = String(data.domain || '').toLowerCase().trim();
              const resultFile = path.join(groupIpcDir, 'domain-result.json');
              const writeResult = (ok: boolean, msg: string) => {
                fs.writeFileSync(
                  resultFile,
                  JSON.stringify({ ok, domain: rawDomain, message: msg, timestamp: new Date().toISOString() }, null, 2),
                );
              };
              if (!rawDomain) {
                writeResult(false, 'domain is required');
              } else {
                const existing = getCustomDomain(rawDomain);
                if (!existing) {
                  writeResult(false, `Domain "${rawDomain}" not found.`);
                } else if (existing.tenant_id !== tenantId) {
                  writeResult(false, `Domain "${rawDomain}" does not belong to this tenant.`);
                } else {
                  removeCustomDomain(existing.id, tenantId);
                  try {
                    await regenerateTenantCaddyfile(tenantId);
                  } catch (caddyErr: any) {
                    logger.warn({ tenantId, domain: rawDomain, err: caddyErr.message }, 'IPC domain-remove: Caddy regen failed');
                  }
                  writeResult(true, `Domain "${rawDomain}" removed.`);
                  logger.info({ tenantId, domain: rawDomain }, 'IPC domain-remove: domain removed');
                }
              }
            } else if (data.type === 'runtime-disk-usage' && tenantId) {
              try {
                const usage = await getTenantDiskUsage(tenantId);
                const resultFile = path.join(groupIpcDir, 'disk-usage.json');
                fs.writeFileSync(
                  resultFile,
                  JSON.stringify({ ...usage, timestamp: new Date().toISOString() }, null, 2),
                );
                logger.info({ tenantId }, 'IPC runtime-disk-usage: complete');
              } catch (err: any) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'runtime-disk-usage', `Disk usage failed: ${err.message}`);
                logger.error({ tenantId, err: err.message }, 'IPC runtime-disk-usage: failed');
              }
            } else if (data.type === 'runtime-prune' && tenantId) {
              const scope = String(data.scope || 'builder');
              if (scope === 'images-all') {
                // images-all (nerdctl image prune -a) is blocked for agents.
                // It removes ALL unused images including app images, which breaks app restarts.
                // Agents must use 'builder' (build cache) or 'images' (dangling only).
                writeIpcError(
                  groupIpcDir,
                  tenantId,
                  sourceGroup,
                  'runtime-prune',
                  'Scope "images-all" is not available. Use "builder" to free build cache or "images" to prune dangling images. Full image pruning is admin-only.',
                );
              } else if (scope !== 'builder' && scope !== 'images' && scope !== 'volumes' && scope !== 'all') {
                writeIpcError(
                  groupIpcDir,
                  tenantId,
                  sourceGroup,
                  'runtime-prune',
                  `Invalid scope "${scope}". Must be one of: builder, images, volumes, all.`,
                );
              } else {
                try {
                  const { stdout, stderr } = await pruneTenantRuntime(tenantId, scope as 'builder' | 'images' | 'volumes' | 'all');
                  const resultFile = path.join(groupIpcDir, 'prune-result.json');
                  fs.writeFileSync(
                    resultFile,
                    JSON.stringify({ scope, stdout, stderr, timestamp: new Date().toISOString() }, null, 2),
                  );
                  logger.info({ tenantId, scope, stdout }, 'IPC runtime-prune: complete');
                } catch (err: any) {
                  logger.error({ tenantId, scope, err: err.message }, 'IPC runtime-prune: failed');
                  writeIpcError(
                    groupIpcDir,
                    tenantId,
                    sourceGroup,
                    'runtime-prune',
                    `Prune failed: ${err.message}`,
                    { stderr: err.stderr || err.message },
                  );
                }
              }
            } else if (data.type === 'buildkit-reset' && tenantId) {
              try {
                const { stdout } = await resetTenantBuildkitStore(tenantId);
                const resultFile = path.join(groupIpcDir, 'buildkit-reset-result.json');
                fs.writeFileSync(
                  resultFile,
                  JSON.stringify({ stdout, timestamp: new Date().toISOString() }, null, 2),
                );
                logger.info({ tenantId }, 'IPC buildkit-reset: complete');
              } catch (err: any) {
                writeIpcError(
                  groupIpcDir,
                  tenantId,
                  sourceGroup,
                  'buildkit-reset',
                  `Buildkit reset failed: ${err.message}`,
                );
                logger.error({ tenantId, err: err.message }, 'IPC buildkit-reset: failed');
              }
            } else if (data.type === 'hosting-snapshot' && tenantId) {
              try {
                const tenant = getTenant(tenantId);
                const snapshot = await buildHostingSnapshot(tenant ?? undefined);
                const hostingFile = path.join(groupIpcDir, 'hosting.json');
                fs.writeFileSync(hostingFile, JSON.stringify(snapshot, null, 2));
                logger.debug({ tenantId, sourceGroup }, 'IPC hosting-snapshot: written');
              } catch (err: any) {
                logger.error({ tenantId, err: err.message }, 'IPC hosting-snapshot: failed');
              }
            } else if (data.type === 'runtime-reset' && tenantId) {
              const appName = typeof data.app === 'string' ? data.app.trim() : '';
              if (!appName || !/^[a-z0-9-]+$/.test(appName)) {
                writeIpcError(groupIpcDir, tenantId, sourceGroup, 'runtime-reset',
                  'runtime-reset requires an app name: { type: "runtime-reset", app: "<appname>" }');
              } else {
                try {
                  const { stdout, stderr } = await resetTenantRuntime(tenantId, appName);
                  const resultFile = path.join(groupIpcDir, 'runtime-reset-result.json');
                  fs.writeFileSync(
                    resultFile,
                    JSON.stringify({ app: appName, stdout, stderr, timestamp: new Date().toISOString() }, null, 2),
                  );
                  logger.info({ tenantId, appName }, 'IPC runtime-reset: complete');
                } catch (err: any) {
                  writeIpcError(
                    groupIpcDir,
                    tenantId,
                    sourceGroup,
                    'runtime-reset',
                    `Runtime reset failed: ${err.message}`,
                  );
                  logger.error({ tenantId, appName, err: err.message }, 'IPC runtime-reset: failed');
                }
              }
            }
            fs.unlinkSync(filePath);
          } catch (err) {
            logger.error(
              { file, sourceGroup, err },
              'Error processing IPC message',
            );
            const errorDir = path.join(groupIpcDir, 'errors');
            fs.mkdirSync(errorDir, { recursive: true });
            fs.renameSync(
              filePath,
              path.join(errorDir, `${sourceGroup.replace('/', '-')}-${file}`),
            );
          }
        }
      }
    } catch (err) {
      logger.error(
        { err, sourceGroup },
        'Error reading IPC messages directory',
      );
    }

    // Process tasks from this group's IPC directory
    try {
      if (fs.existsSync(tasksDir)) {
        const taskFiles = fs
          .readdirSync(tasksDir)
          .filter((f) => f.endsWith('.json'));
        for (const file of taskFiles) {
          const filePath = path.join(tasksDir, file);
          try {
            const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
            await processTaskIpc(data, sourceGroup, isMain, tenantId, deps);
            fs.unlinkSync(filePath);
          } catch (err) {
            logger.error(
              { file, sourceGroup, err },
              'Error processing IPC task',
            );
            const errorDir = path.join(groupIpcDir, 'errors');
            fs.mkdirSync(errorDir, { recursive: true });
            fs.renameSync(
              filePath,
              path.join(errorDir, `${sourceGroup.replace('/', '-')}-${file}`),
            );
          }
        }
      }
    } catch (err) {
      logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
    }
  };


  const processIpcFiles = async () => {
    const groupFolders = scanIpcFolders();
    const registeredGroups = deps.registeredGroups();

    // Build folder→isMain and folder→tenantId lookups from registered groups
    const folderIsMain = new Map<string, boolean>();
    const folderTenantId = new Map<string, string>();
    for (const group of Object.values(registeredGroups)) {
      if (group.isMain) folderIsMain.set(group.folder, true);
      if (group.tenantId) folderTenantId.set(group.folder, group.tenantId);
    }

    // Group folders by tenant: sequential within tenant, concurrent across tenants
    const byTenant = new Map<string, Array<{ sourceGroup: string; isMain: boolean; tenantId: string | undefined }>>();
    for (const sourceGroup of groupFolders) {
      const isMain = folderIsMain.get(sourceGroup) === true;
      // For composite folders (e.g. "koala/general--hook"), fall back to extracting tenantId
      // from the path prefix — hook groups aren't in registered_groups but still belong to a tenant
      const tenantId = folderTenantId.get(sourceGroup) ?? (sourceGroup.includes('/') ? sourceGroup.split('/')[0] : undefined);
      const key = tenantId ?? '__no_tenant__';
      if (!byTenant.has(key)) byTenant.set(key, []);
      byTenant.get(key)!.push({ sourceGroup, isMain, tenantId });
    }

    // Fire each tenant's group chain concurrently; skip if already in-flight
    for (const [key, groups] of byTenant) {
      if (!activeTenants.has(key)) {
        activeTenants.add(key);
        (async () => {
          for (const { sourceGroup, isMain, tenantId } of groups) {
            await processOneGroup(sourceGroup, isMain, tenantId, registeredGroups);
          }
        })()
          .catch((err) => logger.error({ err, tenantId: key }, 'IPC: error processing tenant groups'))
          .finally(() => activeTenants.delete(key));
      }
    }

    setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
  };

  processIpcFiles();
  logger.info('IPC watcher started (per-group namespaces)');
}

export async function processTaskIpc(
  data: {
    type: string;
    taskId?: string;
    id?: string;
    prompt?: string;
    schedule_type?: string;
    schedule_value?: string;
    context_mode?: string;
    groupFolder?: string;
    chatJid?: string;
    targetJid?: string;
    // For register_group
    jid?: string;
    name?: string;
    folder?: string;
    trigger?: string;
    requiresTrigger?: boolean;
    containerConfig?: RegisteredGroup['containerConfig'];
    tenantId?: string;
  },
  sourceGroup: string,
  isMain: boolean,
  sourceTenantId: string | undefined,
  deps: IpcDeps,
): Promise<void> {
  const registeredGroups = deps.registeredGroups();
  // Normalize SDK "id" field to "taskId"
  if (!data.taskId && data.id) data.taskId = data.id;

  switch (data.type) {
    case 'schedule_task': {
      const resolvedTargetJid = data.targetJid
        || Object.entries(registeredGroups).find(([, g]) => g.folder === (data.groupFolder ?? sourceGroup))?.[0];
      if (
        data.prompt &&
        data.schedule_type &&
        data.schedule_value &&
        resolvedTargetJid
      ) {
        const targetJid = resolvedTargetJid;
        const targetGroupEntry = registeredGroups[targetJid];

        if (!targetGroupEntry) {
          logger.warn(
            { targetJid },
            'Cannot schedule task: target group not registered',
          );
          break;
        }

        const targetFolder = targetGroupEntry.folder;

        // Authorization: non-main groups can only schedule for themselves
        if (!isMain && targetFolder !== sourceGroup) {
          logger.warn(
            { sourceGroup, targetFolder },
            'Unauthorized schedule_task attempt blocked',
          );
          break;
        }

        // Cross-tenant check
        if (!isMain && targetGroupEntry.tenantId !== sourceTenantId) {
          logger.warn(
            {
              sourceGroup,
              targetFolder,
              sourceTenantId,
              targetTenantId: targetGroupEntry.tenantId,
            },
            'Cross-tenant schedule_task attempt blocked',
          );
          break;
        }

        const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';

        let nextRun: string | null = null;
        if (scheduleType === 'cron') {
          try {
            const interval = CronExpressionParser.parse(data.schedule_value, {
              tz: TIMEZONE,
            });
            nextRun = interval.next().toISOString();
          } catch {
            logger.warn(
              { scheduleValue: data.schedule_value },
              'Invalid cron expression',
            );
            break;
          }
        } else if (scheduleType === 'interval') {
          const ms = parseInt(data.schedule_value, 10);
          if (isNaN(ms) || ms <= 0) {
            logger.warn(
              { scheduleValue: data.schedule_value },
              'Invalid interval',
            );
            break;
          }
          nextRun = new Date(Date.now() + ms).toISOString();
        } else if (scheduleType === 'once') {
          const scheduled = new Date(data.schedule_value);
          if (isNaN(scheduled.getTime())) {
            logger.warn(
              { scheduleValue: data.schedule_value },
              'Invalid timestamp',
            );
            break;
          }
          nextRun = scheduled.toISOString();
        }

        const taskId =
          data.taskId ||
          `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        const contextMode =
          data.context_mode === 'group' || data.context_mode === 'isolated'
            ? data.context_mode
            : 'isolated';
        createTask({
          id: taskId,
          group_folder: targetFolder,
          chat_jid: targetJid,
          prompt: data.prompt,
          schedule_type: scheduleType,
          schedule_value: data.schedule_value,
          context_mode: contextMode,
          next_run: nextRun,
          status: 'active',
          created_at: new Date().toISOString(),
          tenant_id: targetGroupEntry.tenantId,
        });
        deps.refreshTasksSnapshot(targetFolder);
        logger.info(
          { taskId, sourceGroup, targetFolder, contextMode },
          'Task created via IPC',
        );
      }
      break;
    }

    case 'pause_task':
      if (data.taskId) {
        const task = getTaskById(data.taskId);
        if (task && (isMain || task.group_folder === sourceGroup)) {
          updateTask(data.taskId, { status: 'paused' });
          deps.refreshTasksSnapshot(task.group_folder);
          logger.info(
            { taskId: data.taskId, sourceGroup },
            'Task paused via IPC',
          );
        } else {
          logger.warn(
            { taskId: data.taskId, sourceGroup },
            'Unauthorized task pause attempt',
          );
        }
      }
      break;

    case 'resume_task':
      if (data.taskId) {
        const task = getTaskById(data.taskId);
        if (task && (isMain || task.group_folder === sourceGroup)) {
          updateTask(data.taskId, { status: 'active' });
          deps.refreshTasksSnapshot(task.group_folder);
          logger.info(
            { taskId: data.taskId, sourceGroup },
            'Task resumed via IPC',
          );
        } else {
          logger.warn(
            { taskId: data.taskId, sourceGroup },
            'Unauthorized task resume attempt',
          );
        }
      }
      break;

    case 'cancel_task':
      if (data.taskId) {
        const task = getTaskById(data.taskId);
        if (task && (isMain || task.group_folder === sourceGroup)) {
          deleteTask(data.taskId);
          deps.refreshTasksSnapshot(task.group_folder);
          logger.info(
            { taskId: data.taskId, sourceGroup },
            'Task cancelled via IPC',
          );
        } else {
          logger.warn(
            { taskId: data.taskId, sourceGroup },
            'Unauthorized task cancel attempt',
          );
        }
      }
      break;

    case 'update_task':
      if (data.taskId) {
        const task = getTaskById(data.taskId);
        if (!task) {
          logger.warn(
            { taskId: data.taskId, sourceGroup },
            'Task not found for update',
          );
          break;
        }
        if (!isMain && task.group_folder !== sourceGroup) {
          logger.warn(
            { taskId: data.taskId, sourceGroup },
            'Unauthorized task update attempt',
          );
          break;
        }

        const updates: Parameters<typeof updateTask>[1] = {};
        if (data.prompt !== undefined) updates.prompt = data.prompt;
        if (data.schedule_type !== undefined)
          updates.schedule_type = data.schedule_type as
            | 'cron'
            | 'interval'
            | 'once';
        if (data.schedule_value !== undefined)
          updates.schedule_value = data.schedule_value;

        // Recompute next_run if schedule changed
        if (data.schedule_type || data.schedule_value) {
          const updatedTask = {
            ...task,
            ...updates,
          };
          if (updatedTask.schedule_type === 'cron') {
            try {
              const interval = CronExpressionParser.parse(
                updatedTask.schedule_value,
                { tz: TIMEZONE },
              );
              updates.next_run = interval.next().toISOString();
            } catch {
              logger.warn(
                { taskId: data.taskId, value: updatedTask.schedule_value },
                'Invalid cron in task update',
              );
              break;
            }
          } else if (updatedTask.schedule_type === 'interval') {
            const ms = parseInt(updatedTask.schedule_value, 10);
            if (!isNaN(ms) && ms > 0) {
              updates.next_run = new Date(Date.now() + ms).toISOString();
            }
          }
        }

        updateTask(data.taskId, updates);
        deps.refreshTasksSnapshot(task.group_folder);
        logger.info(
          { taskId: data.taskId, sourceGroup, updates },
          'Task updated via IPC',
        );
      }
      break;

    case 'refresh_groups':
      if (isMain) {
        logger.info(
          { sourceGroup },
          'Group metadata refresh requested via IPC',
        );
        await deps.syncGroups(true);
        const availableGroups = deps.getAvailableGroups();
        deps.writeGroupsSnapshot(
          sourceGroup,
          true,
          availableGroups,
          new Set(Object.keys(registeredGroups)),
        );
      } else {
        logger.warn(
          { sourceGroup },
          'Unauthorized refresh_groups attempt blocked',
        );
      }
      break;

    case 'register_group':
      if (!isMain) {
        logger.warn(
          { sourceGroup },
          'Unauthorized register_group attempt blocked',
        );
        break;
      }
      if (data.jid && data.name && data.folder) {
        if (!isValidGroupFolder(data.folder)) {
          logger.warn(
            { sourceGroup, folder: data.folder },
            'Invalid register_group request - unsafe folder name',
          );
          break;
        }
        // Use the source group's tenant for new registrations
        deps.registerGroup(data.jid, {
          name: data.name,
          folder: data.folder,
          added_at: new Date().toISOString(),
          containerConfig: data.containerConfig,
          requiresTrigger: data.requiresTrigger,
          tenantId: data.tenantId || sourceTenantId,
        });
      } else {
        logger.warn(
          { data },
          'Invalid register_group request - missing required fields',
        );
      }
      break;

    case 'update_group_settings': {
      if (data.requiresTrigger !== undefined) {
        const groups = deps.registeredGroups();
        // Resolve jid: prefer explicit jid field, fall back to groupFolder lookup.
        // Always validate that the resolved group belongs to the source group/tenant
        // — identity is authoritative from the IPC file path, not message content.
        const resolvedJid = data.jid
          || Object.entries(groups).find(([, g]) => g.folder === data.groupFolder)?.[0];
        if (resolvedJid) {
          const group = groups[resolvedJid];
          if (group) {
            const ownedBySource = group.folder === sourceGroup || group.tenantId === sourceTenantId;
            if (!ownedBySource) {
              logger.warn(
                { resolvedJid, sourceGroup, sourceTenantId },
                'update_group_settings: cross-tenant update blocked',
              );
            } else {
              const updated = { ...group, requiresTrigger: data.requiresTrigger as boolean };
              deps.registerGroup(resolvedJid, updated);
              deps.writeGroupSettingsSnapshot(group.folder, { requiresTrigger: data.requiresTrigger as boolean });
              logger.info(
                { jid: resolvedJid, requiresTrigger: data.requiresTrigger },
                'Group settings updated via agent',
              );
            }
          } else {
            logger.warn({ jid: resolvedJid }, 'update_group_settings: group not found');
          }
        } else {
          logger.warn({ groupFolder: data.groupFolder }, 'update_group_settings: could not resolve jid');
        }
      }
      break;
    }

    case 'hooks_set_enabled': {
      const hooksIpcDir = resolveGroupIpcPath(sourceGroup);
      const groups = deps.registeredGroups();
      const resolvedJid = data.jid
        || Object.entries(groups).find(([, g]) => g.folder === data.groupFolder)?.[0];
      if (resolvedJid) {
        const group = groups[resolvedJid];
        if (group) {
          const ownedBySource = group.folder === sourceGroup || group.tenantId === sourceTenantId;
          if (!ownedBySource) {
            logger.warn({ resolvedJid, sourceGroup, sourceTenantId }, 'hooks_set_enabled: cross-tenant update blocked');
          } else {
            setHooksEnabled(resolvedJid, (data as any).enabled === true);
            const cfg = getHooksConfig(resolvedJid);
            const resultFile = path.join(hooksIpcDir, 'hooks-result.json');
            fs.writeFileSync(resultFile, JSON.stringify({ ok: true, hooksToken: cfg?.hooksToken ?? null, hooksEnabled: cfg?.hooksEnabled ?? false, timestamp: new Date().toISOString() }, null, 2));
            logger.info({ jid: resolvedJid, enabled: (data as any).enabled }, 'Hooks enabled state updated via agent');
          }
        } else {
          logger.warn({ jid: resolvedJid }, 'hooks_set_enabled: group not found');
        }
      } else {
        logger.warn({ groupFolder: data.groupFolder }, 'hooks_set_enabled: could not resolve jid');
      }
      break;
    }

    case 'hooks_rotate': {
      const hooksIpcDir = resolveGroupIpcPath(sourceGroup);
      const groups = deps.registeredGroups();
      const resolvedJid = data.jid
        || Object.entries(groups).find(([, g]) => g.folder === data.groupFolder)?.[0];
      if (resolvedJid) {
        const group = groups[resolvedJid];
        if (group) {
          const ownedBySource = group.folder === sourceGroup || group.tenantId === sourceTenantId;
          if (!ownedBySource) {
            logger.warn({ resolvedJid, sourceGroup, sourceTenantId }, 'hooks_rotate: cross-tenant update blocked');
          } else {
            const newToken = rotateHooksToken(resolvedJid);
            const cfg = getHooksConfig(resolvedJid);
            const resultFile = path.join(hooksIpcDir, 'hooks-result.json');
            fs.writeFileSync(resultFile, JSON.stringify({ ok: true, hooksToken: newToken, hooksEnabled: cfg?.hooksEnabled ?? false, timestamp: new Date().toISOString() }, null, 2));
            logger.info({ jid: resolvedJid }, 'Hooks token rotated via agent');
          }
        } else {
          logger.warn({ jid: resolvedJid }, 'hooks_rotate: group not found');
        }
      } else {
        logger.warn({ groupFolder: data.groupFolder }, 'hooks_rotate: could not resolve jid');
      }
      break;
    }

    case 'hooks_get': {
      const hooksIpcDir = resolveGroupIpcPath(sourceGroup);
      const groups = deps.registeredGroups();
      const resolvedJid = data.jid
        || Object.entries(groups).find(([, g]) => g.folder === data.groupFolder)?.[0];
      if (resolvedJid) {
        const group = groups[resolvedJid];
        if (group) {
          const ownedBySource = group.folder === sourceGroup || group.tenantId === sourceTenantId;
          if (!ownedBySource) {
            logger.warn({ resolvedJid, sourceGroup, sourceTenantId }, 'hooks_get: cross-tenant read blocked');
          } else {
            const cfg = getHooksConfig(resolvedJid);
            const resultFile = path.join(hooksIpcDir, 'hooks-result.json');
            fs.writeFileSync(resultFile, JSON.stringify({ ok: true, hooksToken: cfg?.hooksToken ?? null, hooksEnabled: cfg?.hooksEnabled ?? false, timestamp: new Date().toISOString() }, null, 2));
          }
        } else {
          logger.warn({ jid: resolvedJid }, 'hooks_get: group not found');
        }
      } else {
        logger.warn({ groupFolder: data.groupFolder }, 'hooks_get: could not resolve jid');
      }
      break;
    }

    case 'hooks_agent':
    case 'hooks_wake':
    case 'hooks_invoke': {
      if (deps.onHookTask) {
        const requestId = typeof (data as any).requestId === 'string' ? (data as any).requestId : '';
        if (!requestId) {
          logger.warn({ type: data.type, sourceGroup }, 'hooks IPC task missing requestId');
          break;
        }
        deps.onHookTask(sourceGroup, {
          type: data.type as 'hooks_agent' | 'hooks_wake' | 'hooks_invoke',
          prompt: typeof (data as any).prompt === 'string' ? (data as any).prompt : undefined,
          tool: typeof (data as any).tool === 'string' ? (data as any).tool : undefined,
          args: (data as any).args,
          requestId,
        });
      } else {
        logger.warn({ type: data.type }, 'hooks IPC task received but no onHookTask handler registered');
      }
      break;
    }

    case 'creds_set': {
      if (!data.name || !(data as any).value || !sourceTenantId) {
        logger.warn({ sourceTenantId, sourceGroup }, 'IPC creds_set: missing required fields');
        break;
      }
      const tenantWorkspace = path.join(TENANT_HOME_BASE, sourceTenantId, 'workspace');
      const credsPath = path.join(tenantWorkspace, 'credentials.json');
      fs.mkdirSync(tenantWorkspace, { recursive: true });
      let creds: Array<{ name: string; value: string; description?: string }> = [];
      if (fs.existsSync(credsPath)) {
        try { creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8')); } catch { creds = []; }
      }
      const idx = creds.findIndex((c) => c.name === data.name);
      const entry: { name: string; value: string; description?: string } = {
        name: data.name!,
        value: (data as any).value,
        ...(((data as any).description != null) && { description: (data as any).description }),
      };
      if (idx >= 0) { creds[idx] = entry; } else { creds.push(entry); }
      const tempPath = `${credsPath}.tmp`;
      fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2));
      fs.renameSync(tempPath, credsPath);
      logger.info({ tenantId: sourceTenantId, credName: data.name }, 'IPC creds_set processed');
      break;
    }

    case 'creds_remove': {
      if (!data.name || !sourceTenantId) {
        logger.warn({ sourceTenantId, sourceGroup }, 'IPC creds_remove: missing required fields');
        break;
      }
      const tenantWorkspace = path.join(TENANT_HOME_BASE, sourceTenantId, 'workspace');
      const credsPath = path.join(tenantWorkspace, 'credentials.json');
      if (!fs.existsSync(credsPath)) {
        logger.warn({ tenantId: sourceTenantId, credName: data.name }, 'IPC creds_remove: credentials.json not found');
        break;
      }
      let creds: Array<{ name: string; value: string; description?: string }> = [];
      try { creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8')); } catch { break; }
      const filtered = creds.filter((c) => c.name !== data.name);
      if (filtered.length === creds.length) {
        logger.warn({ tenantId: sourceTenantId, credName: data.name }, 'IPC creds_remove: credential not found');
        break;
      }
      const tempPath = `${credsPath}.tmp`;
      fs.writeFileSync(tempPath, JSON.stringify(filtered, null, 2));
      fs.renameSync(tempPath, credsPath);
      logger.info({ tenantId: sourceTenantId, credName: data.name }, 'IPC creds_remove processed');
      break;
    }

    default:
      logger.warn({ type: data.type }, 'Unknown IPC task type');
  }
}
