import { Router, Request, Response } from 'express';
import { authenticateToken } from '../middleware/auth';
import { requireAdmin } from '../middleware/requireAdmin';
import { requireApproved } from '../middleware/requireApproved';
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'fs';
import { execFile, execFileSync, spawn } from 'child_process';
import path from 'path';
import { AgentRegistry, AgentProviderName } from '../agents';
import { AgentAbortError } from '../agents/AgentProvider.interface';
import { listProviderModels } from '../agents/providerModels';
import { getProviderCommandCatalog } from '../agents/providerCommandCatalog';
import { resolveExecApproval, ExecApprovalRequest } from '../agents/providers/OpenClawProvider';
import { appendNativeMessage, loadNativeSession, updateNativeSessionModel } from '../agents/providers/NativeSessionStore';
import { gatewayRpcCall, patchSessionModel, getSessionInfo, isGatewayTransportError, chatSend, createSession } from '../utils/openclawGatewayRpc';
import {
  sendApprovalDecision,
  injectChatMessage,
  steerSessionMessage,
  onApprovalRequest,
  onApprovalResolved,
  subscribeGatewaySessionMessages,
  isConnected as isPersistentWsConnected,
  reconnectNow as reconnectPersistentWs,
  type ExecApprovalRequest as PersistentApprovalRequest,
  type ExecApprovalResolved,
} from '../agents/providers/PersistentGatewayWs';
import { streamEventBus, type StreamEvent } from '../services/StreamEventBus';
import { verifyAccessToken, JwtPayload } from '../utils/jwt';
import { buildSignedDevice, getOrCreateDeviceKeys } from '../utils/deviceIdentity';
import { prisma } from '../config/database';
import { getOpenClawApiUrl } from '../config/openclaw';
import { shouldIsolateUser } from '../utils/workspaceScope';
import {
  extractTextFromContent as extractSanitizedText,
  isControlOnlyAssistantText,
  stripEnvelope,
  stripOpenClawReplyTags,
} from '../utils/chatText';
import { canAccessPortal, canUseInteractivePortal, isElevatedRole, isOwnerRole } from '../utils/authz';
import { hasGatewayToken, getGatewayToken } from '../utils/gatewayToken';
import { getOpenClawWsUrl } from '../config/openclaw';
import { isAllowedWebSocketOrigin } from '../utils/websocketOrigin';
import { buildOpenClawCliEnv, normalizePortalModelId } from '../utils/openclawCli';
// @ts-ignore - ws doesn't have type declarations in this project
import { WebSocketServer, WebSocket } from 'ws';
import type { Server as HttpServer, IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import { createHash } from 'crypto';

const DEBUG_GATEWAY_WS = process.env.DEBUG_GATEWAY_WS === '1';
const debugLog = (...args: unknown[]) => {
  if (DEBUG_GATEWAY_WS) console.log('[gateway]', ...args);
};

const router = Router();

// Allow WebSocket upgrade paths to bypass REST auth middleware.
// These paths are authenticated via cookies in the httpServer 'upgrade' handler.
router.get('/ws', (_req, res) => {
  // If we reach here, the request wasn't upgraded to WebSocket.
  // This happens with plain HTTP GET (not WS upgrade).
  res.status(426).json({ error: 'WebSocket upgrade required' });
});

router.get('/direct', (_req, res) => {
  res.status(426).json({ error: 'WebSocket upgrade required' });
});

router.use(authenticateToken, requireApproved);
const AGENTS_BASE = path.join(process.env.HOME || '/root', '.openclaw/agents');
const SESSIONS_DIR = path.join(AGENTS_BASE, 'main/sessions');
const GATEWAY_URL = getOpenClawApiUrl();
const OPENCLAW_DIST_DIR = '/usr/lib/node_modules/openclaw/dist';
const PORTAL_ROOT = path.resolve(__dirname, '../../..');
const OPENCLAW_COMPAT_HOTFIX_SCRIPT = path.join(PORTAL_ROOT, 'scripts', 'patch-openclaw-long-run-relay-hotfix.sh');
const GEMINI_CLI_TMP_DIR = path.join(process.env.HOME || '/root', '.gemini', 'tmp');
const GEMINI_CLI_PROVIDER = 'google-gemini-cli';
const GEMINI_CLI_TRANSCRIPT_INDEX_TTL_MS = 30000;
const MAIN_SESSION_LIST_CACHE_TTL_MS = 5000;
const MAIN_SESSION_DERIVED_TITLE_LIMIT = 80;
const MAINTENANCE_HISTORY_DIR = path.join(PORTAL_ROOT, 'backend', '.data', 'maintenance-history');
const MAINTENANCE_HISTORY_DEDUP_WINDOW_MS = 4000;
const maintenanceHistoryDedup = new Map<string, number>();

type OpenClawCliResult = { ok: boolean; stdout: string; stderr: string; error?: string };

function runOpenClawCli(args: string[], timeoutMs = 8000, extraEnv: NodeJS.ProcessEnv = {}): Promise<OpenClawCliResult> {
  return new Promise((resolve) => {
    execFile('openclaw', args, { env: { ...buildOpenClawCliEnv(), ...extraEnv }, timeout: timeoutMs }, (error, stdout, stderr) => {
      resolve({
        ok: !error,
        stdout: String(stdout || '').trim(),
        stderr: String(stderr || '').trim(),
        error: error ? (error as any)?.message || String(error) : undefined,
      });
    });
  });
}

function parseOpenClawVersion(raw: unknown): string | null {
  const text = String(raw || '').trim();
  const match = text.match(/OpenClaw\s+v?(\d{4}\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?)/i)
    || text.match(/\bv?(\d{4}\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?)\b/);
  return match?.[1] || null;
}

function parseJsonLoose(raw: string): any | null {
  try { return JSON.parse(raw); } catch { return null; }
}

function getOpenClawPackageMtimeMs(): number | null {
  const candidates = [
    '/usr/lib/node_modules/openclaw/package.json',
    '/usr/local/lib/node_modules/openclaw/package.json',
  ];
  for (const candidate of candidates) {
    try {
      if (existsSync(candidate)) return statSync(candidate).mtimeMs;
    } catch {}
  }
  return null;
}

function gatewayRecoveryEnv(): NodeJS.ProcessEnv {
  return {
    ...buildOpenClawCliEnv(),
    // Needed when the installed stable CLI is intentionally recovering from a
    // beta-written config after channel downgrade. OpenClaw gates destructive
    // downgrade actions unless this is explicit.
    OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: '1',
  };
}

function forceStartOpenClawGateway(): { ok: boolean; pid?: number; error?: string } {
  try {
    const logPath = '/root/.openclaw/logs/openclaw.log';
    const out = openSync(logPath, 'a');
    const child = spawn('openclaw', ['gateway', '--force', 'run'], {
      env: gatewayRecoveryEnv(),
      detached: true,
      stdio: ['ignore', out, out],
    });
    child.unref();
    closeSync(out);
    return { ok: true, pid: child.pid };
  } catch (err: any) {
    return { ok: false, error: err?.message || 'Failed to force-start OpenClaw gateway' };
  }
}

async function waitForGatewayVersionClear(timeoutMs = 12000) {
  const deadline = Date.now() + timeoutMs;
  let last: any = null;
  while (Date.now() < deadline) {
    await new Promise(resolve => setTimeout(resolve, 1000));
    last = await getOpenClawVersionStatus().catch((err: any) => ({
      installedVersion: null,
      runningVersion: null,
      latestVersion: null,
      updateChannel: null,
      mismatch: false,
      restartRecommended: false,
      reason: null,
      listenerPid: null,
      listenerStartedAt: null,
      installedPackageMtime: null,
      probeOk: false,
      probeError: err?.message || 'Version status check failed while waiting for gateway restart',
    }));
    if (last?.probeOk && !last?.restartRecommended) return last;
  }
  return last;
}

function getGatewayListenerProcess(): { pid: number | null; startedAt: string | null; startedAtMs: number | null } {
  try {
    const output = execFileSync('bash', ['-lc', "ss -ltnp 'sport = :18789' 2>/dev/null | tail -n +2 | head -n 1"], {
      env: buildOpenClawCliEnv(),
      timeout: 2500,
      encoding: 'utf8',
    }).trim();
    const pid = Number(output.match(/pid=(\d+)/)?.[1] || 0) || null;
    if (!pid) return { pid: null, startedAt: null, startedAtMs: null };

    const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
    const bootTimeRaw = readFileSync('/proc/stat', 'utf8').match(/^btime\s+(\d+)/m)?.[1];
    const afterCommand = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
    const startTicks = Number(afterCommand[19]); // proc stat field 22 after removing pid+comm
    const bootSeconds = Number(bootTimeRaw || 0);
    const ticksPerSecond = Number(execFileSync('getconf', ['CLK_TCK'], { timeout: 1000, encoding: 'utf8' }).trim()) || 100;
    const startedAtMs = bootSeconds && Number.isFinite(startTicks) ? Math.round((bootSeconds + startTicks / ticksPerSecond) * 1000) : null;
    return {
      pid,
      startedAt: startedAtMs ? new Date(startedAtMs).toISOString() : null,
      startedAtMs,
    };
  } catch {
    return { pid: null, startedAt: null, startedAtMs: null };
  }
}

async function getOpenClawVersionStatus() {
  const [cliVersionResult, updateStatusResult, probeResult] = await Promise.all([
    runOpenClawCli(['--version'], 4000),
    runOpenClawCli(['update', 'status', '--json', '--timeout', '3'], 9000),
    runOpenClawCli(['gateway', 'probe', '--json'], 9000),
  ]);

  const installedVersion = parseOpenClawVersion(cliVersionResult.stdout);
  const updateStatus = parseJsonLoose(updateStatusResult.stdout);
  const probe = parseJsonLoose(probeResult.stdout);
  const primaryTarget = Array.isArray(probe?.targets) ? probe.targets[0] : null;
  const runningVersion = parseOpenClawVersion(primaryTarget?.self?.version || probe?.self?.version);
  const listener = getGatewayListenerProcess();
  const installedPackageMtimeMs = getOpenClawPackageMtimeMs();
  const installedPackageMtime = installedPackageMtimeMs ? new Date(installedPackageMtimeMs).toISOString() : null;
  const probeError = primaryTarget?.connect?.error || probe?.warnings?.[0]?.message || probeResult.error || null;

  const exactVersionMismatch = Boolean(installedVersion && runningVersion && installedVersion !== runningVersion);
  const listenerOlderThanInstall = Boolean(
    listener.startedAtMs
    && installedPackageMtimeMs
    && listener.startedAtMs + 5000 < installedPackageMtimeMs
  );
  const protocolMismatch = String(probeError || '').toLowerCase().includes('protocol mismatch');
  const mismatch = exactVersionMismatch || (!runningVersion && listenerOlderThanInstall) || protocolMismatch;
  const reason = exactVersionMismatch
    ? `OpenClaw gateway is running ${runningVersion}, but ${installedVersion} is installed.`
    : protocolMismatch
      ? 'OpenClaw gateway protocol does not match the installed CLI; the gateway is probably still an older detached process.'
      : listenerOlderThanInstall
        ? 'OpenClaw gateway listener started before the installed package was updated.'
        : null;

  return {
    installedVersion,
    runningVersion,
    latestVersion: updateStatus?.availability?.latestVersion || updateStatus?.update?.registry?.latestVersion || null,
    updateChannel: updateStatus?.channel?.value || null,
    mismatch,
    restartRecommended: mismatch,
    reason,
    listenerPid: listener.pid,
    listenerStartedAt: listener.startedAt,
    installedPackageMtime,
    probeOk: Boolean(probe?.ok),
    probeError,
  };
}

let geminiCliTranscriptIndexCache: { at: number; index: Map<string, string> } | null = null;
let mainSessionListCache: { at: number; mtimeMs: number; size: number; sessions: any[] } | null = null;

function resolveOpenClawDistBundle(prefix: string | string[]): string | null {
  try {
    if (!existsSync(OPENCLAW_DIST_DIR)) return null;
    const prefixes = Array.isArray(prefix) ? prefix : [prefix];
    const matches = readdirSync(OPENCLAW_DIST_DIR)
      .filter((name) => name.endsWith('.js') && prefixes.some((candidate) => name.startsWith(candidate)))
      .map((name) => path.join(OPENCLAW_DIST_DIR, name))
      .sort((a, b) => {
        const sizeDiff = statSync(b).size - statSync(a).size;
        return sizeDiff !== 0 ? sizeDiff : path.basename(a).localeCompare(path.basename(b));
      });
    return matches[0] || null;
  } catch {
    return null;
  }
}

function resolveOpenClawExtensionImportedBundle(extensionRelativePath: string, prefix: string | string[]): string | null {
  try {
    const extensionPath = path.join(OPENCLAW_DIST_DIR, extensionRelativePath);
    if (!existsSync(extensionPath)) return null;
    const text = readFileSync(extensionPath, 'utf8');
    const prefixes = Array.isArray(prefix) ? prefix : [prefix];
    for (const candidate of prefixes) {
      const match = text.match(new RegExp(`${candidate}[^"']+\\.js`));
      if (!match) continue;
      const resolved = path.join(OPENCLAW_DIST_DIR, path.basename(match[0]));
      if (existsSync(resolved)) return resolved;
    }
  } catch {
    // ignore wrapper parse failures
  }
  return null;
}

function getOpenClawCompatibilityHotfixStatus() {
  const heartbeatEventsFilterPath = resolveOpenClawDistBundle('heartbeat-events-filter-');
  const heartbeatRunnerPath = resolveOpenClawDistBundle('heartbeat-runner-');
  const replyBundlePath = resolveOpenClawDistBundle(['get-reply-', 'reply-']);
  const claudeLiveSessionPath = resolveOpenClawDistBundle('claude-live-session-');
  const executeRuntimePath = resolveOpenClawDistBundle('execute.runtime-');
  const geminiCliBackendPath = resolveOpenClawExtensionImportedBundle('extensions/google/cli-backend.js', 'cli-backend-')
    || resolveOpenClawDistBundle('cli-backend-')
    || (existsSync(path.join(OPENCLAW_DIST_DIR, 'extensions/google/cli-backend.js'))
      ? path.join(OPENCLAW_DIST_DIR, 'extensions/google/cli-backend.js')
      : null);
  const scriptExists = existsSync(OPENCLAW_COMPAT_HOTFIX_SCRIPT);
  const issues: string[] = [];

  const heartbeatDetectorPath = heartbeatEventsFilterPath || heartbeatRunnerPath;
  const heartbeatDetectorText = heartbeatDetectorPath && existsSync(heartbeatDetectorPath)
    ? readFileSync(heartbeatDetectorPath, 'utf8')
    : '';
  const heartbeatRunnerText = heartbeatRunnerPath && existsSync(heartbeatRunnerPath)
    ? readFileSync(heartbeatRunnerPath, 'utf8')
    : '';
  const replyText = replyBundlePath && existsSync(replyBundlePath)
    ? readFileSync(replyBundlePath, 'utf8')
    : '';
  const claudeLiveSessionText = claudeLiveSessionPath && existsSync(claudeLiveSessionPath)
    ? readFileSync(claudeLiveSessionPath, 'utf8')
    : '';
  const executeRuntimeText = executeRuntimePath && existsSync(executeRuntimePath)
    ? readFileSync(executeRuntimePath, 'utf8')
    : '';
  const geminiCliBackendText = geminiCliBackendPath && existsSync(geminiCliBackendPath)
    ? readFileSync(geminiCliBackendPath, 'utf8')
    : '';

  const detectorPatched = heartbeatDetectorText.includes('return lower.includes("exec finished") || lower.includes("exec completed");')
    || heartbeatDetectorText.includes('return normalizeLowercaseStringOrEmpty(evt).includes("exec finished") || normalizeLowercaseStringOrEmpty(evt).includes("exec completed");')
    || heartbeatDetectorText.includes('return /^exec finished(?::|\\s*\\()/.test(normalized) || /^exec (completed|failed) \\([a-z0-9_-]{1,64}, (code -?\\d+|signal [^)]+)\\)( :: .*)?$/.test(normalized);')
    || (heartbeatDetectorText.includes('STRUCTURED_EXEC_COMPLETION_EVENT_RE')
      && heartbeatDetectorText.includes('^exec finished(?::|\\s*\\()'));
  const relayPatched = heartbeatRunnerText.includes('const isDirectWebchatSession =')
    && heartbeatRunnerText.includes('delivery.channel === "none" && isDirectWebchatSession');
  const replyPatched = replyText.includes('normalizedIncomingTo === "heartbeat" && params.persistedLastTo');
  const geminiCliPatched = geminiCliBackendText.includes('jsonlDialect: "gemini-stream-json"')
    && geminiCliBackendText.includes('"stream-json"');
  const geminiCliYoloPatched = geminiCliBackendText.includes('"--yolo",');
  const geminiParserText = claudeLiveSessionText || executeRuntimeText;
  const geminiParserPatched = geminiParserText.includes('function isGeminiCliProvider(providerId) {')
    && geminiParserText.includes('function parseGeminiCliStreamingRecord(params) {');
  const executeRuntimeWiringPatched = executeRuntimeText.includes('onToolEvent: (event) => {');
  const geminiRuntimePatched = geminiParserPatched && executeRuntimeWiringPatched;
  const relaySupported = Boolean(heartbeatRunnerPath) && Boolean(replyBundlePath);
  const geminiSupported = Boolean(executeRuntimePath) && Boolean(geminiCliBackendPath);

  if (!scriptExists) issues.push('Portal hotfix script is not installed.');
  if (!relaySupported && !geminiSupported) issues.push('Could not locate the OpenClaw runtime bundles targeted by the compatibility hotfix.');
  if ((heartbeatRunnerPath || replyBundlePath) && !relaySupported) issues.push('Could not locate both relay hotfix bundles (heartbeat runner and get-reply).');
  if ((claudeLiveSessionPath || executeRuntimePath || geminiCliBackendPath) && !geminiSupported) issues.push('Could not locate the Gemini CLI hotfix targets (execute runtime and google cli backend).');

  return {
    scriptExists,
    supported: scriptExists && issues.length === 0 && (relaySupported || geminiSupported),
    applied: (relaySupported ? detectorPatched && relayPatched && replyPatched : true)
      && (geminiSupported ? geminiCliPatched && geminiCliYoloPatched && geminiRuntimePatched : true),
    relaySupported,
    geminiSupported,
    detectorPatched,
    relayPatched,
    replyPatched,
    geminiCliPatched,
    geminiCliYoloPatched,
    geminiRuntimePatched,
    heartbeatRunner: heartbeatRunnerPath ? path.basename(heartbeatRunnerPath) : null,
    replyBundle: replyBundlePath ? path.basename(replyBundlePath) : null,
    executeRuntime: executeRuntimePath ? path.basename(executeRuntimePath) : null,
    geminiCliBackend: geminiCliBackendPath ? path.relative(OPENCLAW_DIST_DIR, geminiCliBackendPath) : null,
    issues,
  };
}

function restartOpenClawGatewayBySignal(): string {
  const output = execFileSync('pgrep', ['-f', 'openclaw.*gateway|gateway.*openclaw|/openclaw/dist/.*gateway|openclaw-gateway'], {
    encoding: 'utf8',
    timeout: 5000,
  });
  const pid = output.split(/\s+/).map((value) => value.trim()).find(Boolean);
  if (!pid) {
    throw new Error('No gateway PID found for signal fallback.');
  }
  process.kill(Number(pid), 'SIGUSR1');
  return `Gateway restart fallback sent via SIGUSR1 to PID ${pid}.`;
}

function hasSystemOpenClawGatewayService(): boolean {
  return [
    '/etc/systemd/system/openclaw-gateway.service',
    '/lib/systemd/system/openclaw-gateway.service',
    '/usr/lib/systemd/system/openclaw-gateway.service',
  ].some((servicePath) => existsSync(servicePath));
}

async function restartOpenClawGatewayBySystemService(): Promise<string> {
  const restartRun = await execFileText('systemctl', ['restart', 'openclaw-gateway'], 45000);
  await new Promise((resolve) => setTimeout(resolve, 3000));
  const output = [restartRun.stdout, restartRun.stderr].filter(Boolean).join('\n').trim();
  return output || 'Restarted openclaw-gateway via systemd system service.';
}

function isOpenClawGatewayServiceUnavailable(detail: string): boolean {
  return /Gateway service disabled\.|systemd user services are unavailable|systemd not installed|systemctl is-enabled unavailable|Failed to connect to bus: No medium found/i.test(detail);
}

async function restartOpenClawGateway(): Promise<string> {
  const systemdAvailable = existsSync('/run/systemd/system') && existsSync('/bin/systemctl');
  if (!systemdAvailable) {
    const signalMessage = restartOpenClawGatewayBySignal();
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return signalMessage;
  }

  try {
    const cliRun = await execFileText('openclaw', ['gateway', 'restart'], 45000);
    const cliOutput = [cliRun.stdout, cliRun.stderr].filter(Boolean).join('\n').trim();

    if (isOpenClawGatewayServiceUnavailable(cliOutput)) {
      if (hasSystemOpenClawGatewayService()) return await restartOpenClawGatewayBySystemService();
      const signalMessage = restartOpenClawGatewayBySignal();
      await new Promise((resolve) => setTimeout(resolve, 3000));
      return [cliOutput, signalMessage].filter(Boolean).join('\n');
    }

    await new Promise((resolve) => setTimeout(resolve, 3000));
    return cliOutput;
  } catch (err: any) {
    const detail = String(err?.message || err || '').trim();
    if (!isOpenClawGatewayServiceUnavailable(detail)) throw err;
    if (hasSystemOpenClawGatewayService()) return await restartOpenClawGatewayBySystemService();
    const signalMessage = restartOpenClawGatewayBySignal();
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return [detail, signalMessage].filter(Boolean).join('\n');
  }
}

async function execFileText(command: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
  return await new Promise((resolve, reject) => {
    execFile(command, args, {
      timeout,
      encoding: 'utf8',
      env: buildOpenClawCliEnv(),
      maxBuffer: 1024 * 1024 * 4,
    }, (error, stdout, stderr) => {
      const normalizedStdout = String(stdout || '').trim();
      const normalizedStderr = String(stderr || '').trim();
      if (error) {
        const detail = normalizedStderr || normalizedStdout || error.message || `${command} failed`;
        reject(new Error(detail));
        return;
      }
      resolve({ stdout: normalizedStdout, stderr: normalizedStderr });
    });
  });
}

/**
 * Resolve sessions directory for a given session key.
 * Session keys follow the pattern `agent:<agentId>:...`
 * Falls back to main agent if pattern doesn't match.
 */
function resolveSessionsDir(sessionKey?: string): string {
  if (!sessionKey) return SESSIONS_DIR;
  const match = sessionKey.match(/^agent:([a-zA-Z0-9_-]+):/);
  if (!match) return SESSIONS_DIR;
  const agentId = match[1];
  const agentDir = path.join(AGENTS_BASE, agentId, 'sessions');
  if (existsSync(agentDir)) return agentDir;
  return SESSIONS_DIR;
}

function normalizePortalNewSessionAlias(rawSession: unknown): string {
  const session = typeof rawSession === 'string' ? rawSession.trim() : '';
  if (!session) return '';
  if (session.startsWith('portal-new-')) return session.replace(/^portal-/, '');
  if (!session.startsWith('agent:')) return session;

  const parts = session.split(':');
  if (parts.length < 3) return session;

  const agentId = parts[1]?.trim() || 'main';
  const sessionName = parts.slice(2).join(':').trim();
  if (!sessionName.startsWith('portal-new-')) return session;
  return `agent:${agentId}:${sessionName.replace(/^portal-/, '')}`;
}

function resolveOpenClawSessionKey(rawSession: unknown, user?: Pick<JwtPayload, 'role'> | null): string {
  const session = normalizePortalNewSessionAlias(rawSession);
  if (session.startsWith('agent:')) return session;

  const isOwnerMainAlias = isOwnerRole(user?.role)
    && (!session || session === 'main');
  if (isOwnerMainAlias) return 'agent:main:main';
  if (session.startsWith('new-')) return `agent:main:portal-${session}`;

  return session;
}

function normalizeOpenClawAgentList(rawAgents: unknown): any[] {
  if (Array.isArray(rawAgents)) return rawAgents;
  if (rawAgents && typeof rawAgents === 'object') {
    return Object.entries(rawAgents as Record<string, any>).map(([id, value]) => ({
      id,
      ...(value && typeof value === 'object' ? value : {}),
    }));
  }
  return [];
}

function normalizeGatewayModelId(rawModel: unknown): string | undefined {
  if (typeof rawModel === 'string') {
    const trimmed = rawModel.trim();
    return trimmed || undefined;
  }

  if (!rawModel) return undefined;

  if (Array.isArray(rawModel)) {
    for (const entry of rawModel) {
      const normalized = normalizeGatewayModelId(entry);
      if (normalized) return normalized;
    }
    return undefined;
  }

  if (typeof rawModel !== 'object') return undefined;

  const record = rawModel as Record<string, any>;
  const provider = typeof record.provider === 'string' ? record.provider.trim() : '';
  const directModel = normalizeGatewayModelId(record.model);
  if (provider && directModel && !directModel.includes('/')) {
    return `${provider}/${directModel}`;
  }

  const candidates = [
    record.primary,
    record.currentModel,
    record.defaultModel,
    record.id,
    record.name,
    record.fallbacks,
    directModel,
  ];

  for (const candidate of candidates) {
    const normalized = normalizeGatewayModelId(candidate);
    if (normalized) return normalized;
  }

  return undefined;
}

function isSandboxProjectAgentIdForUser(agentId: string, user: JwtPayload): boolean {
  const normalized = String(agentId || '').trim();
  if (!normalized) return false;
  return normalized.startsWith(`portal-${user.userId.slice(0, 8)}-`);
}

function isSandboxProjectSessionKeyForUser(sessionKey: string, user: JwtPayload): boolean {
  const normalized = String(sessionKey || '').trim();
  if (!normalized || !user?.sandboxEnabled) return false;

  const match = normalized.match(/^agent:([^:]+):(portal-[^:]+)$/);
  if (!match) return false;

  const [, agentId, sessionId] = match;
  if (!isSandboxProjectAgentIdForUser(agentId, user)) return false;
  return sessionId.startsWith(`portal-${user.userId}-`);
}

function assertGatewaySessionAccess(sessionKey: string, user: JwtPayload, options?: { providerName?: AgentProviderName | string | undefined }): void {
  if (isElevatedRole(user.role)) return;

  const providerName = String(options?.providerName || 'OPENCLAW').trim().toUpperCase();
  if (providerName !== 'OPENCLAW') {
    throw new Error('Admin access required');
  }

  if (isSandboxProjectSessionKeyForUser(sessionKey, user)) return;
  throw new Error('Admin access required');
}

function normalizeProviderName(input: unknown): AgentProviderName {
  const normalized = String(input || 'OPENCLAW').trim().toUpperCase();
  return (normalized || 'OPENCLAW') as AgentProviderName;
}

function isNativeSessionPlaceholder(rawSession: unknown): boolean {
  const session = typeof rawSession === 'string' ? rawSession.trim() : '';
  return !session || session === 'main' || session.startsWith('new-') || session.startsWith('agent:');
}

function getOwnedNativeSession(providerName: AgentProviderName, userId: string, rawSession: unknown) {
  if (isNativeSessionPlaceholder(rawSession)) return null;
  const sessionId = typeof rawSession === 'string' ? rawSession.trim() : '';
  if (!sessionId) return null;
  const session = loadNativeSession(providerName, sessionId);
  if (!session || session.userId !== userId) return null;
  return session;
}

function humanizeProviderError(providerName: AgentProviderName, rawMessage: string): string {
  const message = String(rawMessage || '').trim();
  if (!message) return 'The agent failed to respond.';

  if (/not logged in|please run \/login/i.test(message)) {
    return providerName === 'CLAUDE_CODE'
      ? 'Claude Code is installed on the server but not logged in yet. Run /login in Claude Code, then try again.'
      : 'This provider is installed on the server but not logged in yet. Complete CLI login, then try again.';
  }

  if (/GEMINI_API_KEY|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA|Auth method/i.test(message)) {
    return 'Gemini CLI is installed but not authenticated on the server. Configure Gemini auth (for example GEMINI_API_KEY) and try again.';
  }

  if (/failed to connect to websocket: HTTP error: 500 Internal Server Error, url: wss:\/\/api\.openai\.com\/v1\/responses/i.test(message)) {
    return 'Codex could not reach the OpenAI Responses service from this server. Check Codex authentication/networking and try again.';
  }

  if (/ECONNREFUSED|connect ECONNREFUSED|gateway.*not connected|Cannot connect to OpenClaw gateway/i.test(message)) {
    return providerName === 'OPENCLAW'
      ? 'OpenClaw is reconnecting right now. Give it a few seconds, then try again.'
      : 'The agent backend is temporarily unavailable. Give it a few seconds, then try again.';
  }

  return message;
}

function readConfigPath(source: any, pathStr: string): any {
  const parts = String(pathStr || '').split('.').map((part) => part.trim()).filter(Boolean);
  let cursor = source;
  for (const part of parts) {
    if (!cursor || typeof cursor !== 'object') return undefined;
    cursor = cursor[part];
  }
  return cursor;
}

function writeConfigPath(target: any, pathStr: string, value: any): void {
  const parts = String(pathStr || '').split('.').map((part) => part.trim()).filter(Boolean);
  if (!parts.length) return;
  let cursor = target;
  for (let i = 0; i < parts.length - 1; i += 1) {
    const key = parts[i];
    if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) {
      cursor[key] = {};
    }
    cursor = cursor[key];
  }
  cursor[parts[parts.length - 1]] = value;
}

function deleteConfigPath(target: any, pathStr: string): void {
  const parts = String(pathStr || '').split('.').map((part) => part.trim()).filter(Boolean);
  if (!parts.length) return;
  let cursor = target;
  for (let i = 0; i < parts.length - 1; i += 1) {
    const key = parts[i];
    if (!cursor[key] || typeof cursor[key] !== 'object') return;
    cursor = cursor[key];
  }
  delete cursor[parts[parts.length - 1]];
}

function readOpenClawAgentsFromDisk(): any[] {
  if (!existsSync(AGENTS_BASE)) return [];

  return readdirSync(AGENTS_BASE)
    .filter((entry) => {
      if (!entry || entry.startsWith('portal-')) return false;
      const agentRoot = path.join(AGENTS_BASE, entry);
      return statSync(agentRoot).isDirectory();
    })
    .map((entry) => {
      const modelsPath = path.join(AGENTS_BASE, entry, 'agent', 'models.json');
      let defaultModel: string | undefined;
      try {
        if (existsSync(modelsPath)) {
          const parsed = JSON.parse(readFileSync(modelsPath, 'utf-8'));
          defaultModel = parsed?.currentModel || parsed?.defaultModel || parsed?.model;
        }
      } catch {}

      return {
        id: entry,
        workspace: path.join(AGENTS_BASE, entry, 'workspace'),
        defaultModel,
      };
    });
}

async function listOpenClawAgentsForSelector(): Promise<any[]> {
  const rpcResult = await Promise.race([
    gatewayRpcCall('agents.list', {}, 1500),
    new Promise<{ ok: false; error: string }>((resolve) => setTimeout(() => resolve({ ok: false, error: 'Gateway RPC timeout' }), 1600)),
  ]);
  const fromRpc = rpcResult.ok ? normalizeOpenClawAgentList(rpcResult.data?.agents) : [];
  const fallback = readOpenClawAgentsFromDisk();
  const merged = new Map<string, any>();

  for (const source of [fallback, fromRpc]) {
    for (const agent of source) {
      const id = String(agent?.id || agent?.name || '').trim();
      if (!id) continue;
      merged.set(id, {
        ...(merged.get(id) || {}),
        ...agent,
        id,
      });
    }
  }

  if (!merged.has('main')) {
    merged.set('main', { id: 'main' });
  }

  return Array.from(merged.values()).map((agent) => ({
    ...agent,
    model: normalizeGatewayModelId(agent?.model ?? agent?.defaultModel ?? agent?.currentModel),
    defaultModel: normalizeGatewayModelId(agent?.defaultModel),
    currentModel: normalizeGatewayModelId(agent?.currentModel),
  }));
}

/* ─── Helpers ──────────────────────────────────────────────────────────── */

function extractText(content: any): string {
  return extractSanitizedText(content);
}

function defaultCompactionNoticeText(compactionMeta?: any): string {
  const signal = String(compactionMeta?.phase || compactionMeta?.status || '').trim().toLowerCase();
  if (signal === 'start' || signal === 'started' || signal === 'compacting' || signal === 'compaction_start') {
    return 'Compacting context…';
  }
  if (compactionMeta?.completed === false || signal === 'incomplete' || signal === 'did_not_complete') {
    return 'Context maintenance finished.';
  }
  return 'Context compacted';
}

function extractCompactionNoticeText(content: any, compactionMeta?: any): string {
  const text = extractText(content);
  return text || defaultCompactionNoticeText(compactionMeta);
}

function isCompactionNoticeText(text: unknown): boolean {
  const normalized = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '';
  if (!normalized) return false;
  if (!/(?:\b(context compacted|compaction (?:complete(?:d)?|finished|in progress|started|incomplete|did not complete|skipped)|compacting context|context maintenance(?: in progress| finished| complete(?:d)?)?|auto-compaction|preparing context maintenance|preparing compaction|memory flush(?: started| complete(?:d)?| in progress)?|heartbeat check (?:started|complete(?:d)?)|heartbeat_ok)\b|(?:^|[^\w])compacted\s*\()/i.test(normalized)) return false;

  const marker = normalized.replace(/^[^\p{L}\p{N}]+/u, '').trim();
  return [
    /^context compacted\.?$/i,
    /^context maintenance (?:in progress|finished|complete(?:d)?)\.?$/i,
    /^compacting context[.…]*$/i,
    /^preparing (?:context maintenance|compaction)[.…]*$/i,
    /^memory flush(?: started| complete(?:d)?| in progress)?[.…]*$/i,
    /^heartbeat check (?:started|complete(?:d)?)[.…]*$/i,
    /^heartbeat_ok\.?$/i,
    /^compacted\s*\([^)]{1,80}\)(?:\s*[•-]\s*context\b.*)?$/i,
    /^compaction (?:complete(?:d)?|finished|in progress|started|incomplete|did not complete)\.?$/i,
    /^compaction skipped(?::.*)?$/i,
  ].some((pattern) => pattern.test(marker));
}


function maintenanceHistoryPathForSession(sessionKey: string): string {
  const digest = createHash('sha256').update(sessionKey || 'main').digest('hex').slice(0, 32);
  return path.join(MAINTENANCE_HISTORY_DIR, `${digest}.jsonl`);
}

function defaultMaintenanceNoticeText(evt: StreamEvent): string {
  if (evt.type === 'compaction_start') return 'Compacting context…';
  if (evt.type === 'compaction_end') return evt.completed === false ? 'Context maintenance finished.' : 'Context compacted';
  return evt.maintenanceKind === 'maintenance' ? 'Context maintenance in progress…' : 'Context maintenance finished.';
}

function buildMaintenanceHistoryMarker(sessionKey: string, evt: StreamEvent): any | null {
  if (!sessionKey) return null;
  const text = typeof evt.content === 'string' && evt.content.trim()
    ? evt.content.trim()
    : defaultMaintenanceNoticeText(evt);
  if (!text || !isCompactionNoticeText(text)) return null;

  const isCompaction = evt.type === 'compaction_start'
    || (evt.type === 'compaction_end' && evt.completed !== false && evt.maintenanceKind !== 'maintenance');
  const timestamp = new Date().toISOString();
  const markerId = `maintenance-${createHash('sha256').update(`${sessionKey}:${timestamp}:${evt.type}:${text}`).digest('hex').slice(0, 24)}`;
  return {
    id: markerId,
    role: 'system',
    content: text,
    provenance: isCompaction ? 'compaction' : 'hidden-history-artifact',
    timestamp,
    maintenanceKind: isCompaction ? 'compaction' : 'maintenance',
    __portal: {
      kind: isCompaction ? 'compaction' : 'maintenance',
      source: 'stream-event',
      eventType: evt.type,
    },
  };
}

function recordMaintenanceHistoryMarker(sessionKey: string, evt: StreamEvent): void {
  const marker = buildMaintenanceHistoryMarker(sessionKey, evt);
  if (!marker) return;
  const dedupKey = `${sessionKey}:${marker.maintenanceKind}:${marker.content}`;
  const now = Date.now();
  const last = maintenanceHistoryDedup.get(dedupKey) || 0;
  if (now - last < MAINTENANCE_HISTORY_DEDUP_WINDOW_MS) return;
  maintenanceHistoryDedup.set(dedupKey, now);

  try {
    mkdirSync(MAINTENANCE_HISTORY_DIR, { recursive: true });
    appendFileSync(maintenanceHistoryPathForSession(sessionKey), JSON.stringify(marker) + '\n', 'utf8');
  } catch (err: any) {
    console.warn('[gateway-maintenance-history] Failed to record maintenance marker:', err?.message || err);
  }
}

function readMaintenanceHistoryMarkers(sessionKey: string, limit = 200): any[] {
  if (!sessionKey || limit <= 0) return [];
  const filePath = maintenanceHistoryPathForSession(sessionKey);
  if (!existsSync(filePath)) return [];

  try {
    const lines = readFileSync(filePath, 'utf8').split('\n').filter((line) => line.trim()).slice(-Math.max(limit * 2, limit));
    return lines
      .map((line) => {
        try { return JSON.parse(line); } catch { return null; }
      })
      .filter((entry) => entry?.role === 'system' && typeof entry?.content === 'string' && entry.content.trim())
      .slice(-Math.max(limit, 1));
  } catch (err: any) {
    console.warn('[gateway-maintenance-history] Failed to read maintenance markers:', err?.message || err);
    return [];
  }
}

function mergeMaintenanceHistoryMarkers(sessionKey: string, messages: any[], limit = 200): any[] {
  const markers = readMaintenanceHistoryMarkers(sessionKey, limit);
  if (markers.length === 0) return messages;

  const seenIds = new Set<string>();
  const combined = [...messages, ...markers]
    .filter((message) => {
      const id = typeof message?.id === 'string' ? message.id : '';
      if (id && seenIds.has(id)) return false;
      if (id) seenIds.add(id);
      return true;
    })
    .sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp));
  return combined.slice(-Math.max(limit, 1));
}

function sanitizeHistoryText(text: string): string {
  return stripOpenClawReplyTags(stripEnvelope(text || '')).replace(/\r\n/g, '\n').trim();
}

function normalizeGeminiModelId(rawModel: unknown): string | undefined {
  const normalized = normalizeGatewayModelId(rawModel);
  if (!normalized) return undefined;
  if (normalized.includes('/')) return normalized;
  return `${GEMINI_CLI_PROVIDER}/${normalized}`;
}

function resolveSessionRegistryEntry(sessionKey: string, sessionsDir = SESSIONS_DIR): any | null {
  return resolveSessionRegistryEntries(sessionKey, sessionsDir)[0] || null;
}

function getSessionKeyLookupVariants(sessionKey: string): string[] {
  const variants = new Set<string>();
  const normalized = String(sessionKey || '').trim();
  if (!normalized) return [];

  const add = (value: string) => {
    const trimmed = String(value || '').trim();
    if (trimmed) variants.add(trimmed);
  };

  add(normalized);
  add(normalizePortalNewSessionAlias(normalized));

  const agentMatch = normalized.match(/^agent:([^:]+):(.+)$/);
  if (agentMatch) {
    const [, agentId, sessionName] = agentMatch;
    if (sessionName.startsWith('new-')) add(`agent:${agentId}:portal-${sessionName}`);
    if (sessionName.startsWith('portal-new-')) add(`agent:${agentId}:${sessionName.replace(/^portal-/, '')}`);
  } else {
    if (normalized.startsWith('new-')) add(`portal-${normalized}`);
    if (normalized.startsWith('portal-new-')) add(normalized.replace(/^portal-/, ''));
  }

  return Array.from(variants);
}

function resolveSessionRegistryEntries(sessionKey: string, sessionsDir = SESSIONS_DIR): any[] {
  const sessionsFile = path.join(sessionsDir, 'sessions.json');
  if (!existsSync(sessionsFile)) return [];

  try {
    const data = JSON.parse(readFileSync(sessionsFile, 'utf-8'));
    const sessions = (Array.isArray(data.sessions) && data.sessions.length === 0) ? data : (data.sessions || data);
    const variants = new Set(getSessionKeyLookupVariants(sessionKey));
    const entries: any[] = [];

    if (typeof sessions === 'object' && !Array.isArray(sessions)) {
      for (const key of variants) {
        if (sessions[key]) entries.push(sessions[key]);
      }
      return entries;
    }

    if (Array.isArray(sessions)) {
      return sessions.filter((session: any) => {
        const key = String(session?.key || session?.sessionKey || session?.id || '').trim();
        return key && variants.has(key);
      });
    }
  } catch {
    return [];
  }

  return [];
}

function resolveGeminiCliBindingSessionId(entry: any): string | null {
  const bindingSessionId = typeof entry?.cliSessionBindings?.[GEMINI_CLI_PROVIDER]?.sessionId === 'string'
    ? entry.cliSessionBindings[GEMINI_CLI_PROVIDER].sessionId.trim()
    : '';
  if (bindingSessionId) return bindingSessionId;

  const legacySessionId = typeof entry?.cliSessionIds?.[GEMINI_CLI_PROVIDER] === 'string'
    ? entry.cliSessionIds[GEMINI_CLI_PROVIDER].trim()
    : '';
  return legacySessionId || null;
}

function walkGeminiCliTranscriptFiles(dirPath: string, results: string[], depth = 0): void {
  if (depth > 6 || !existsSync(dirPath)) return;

  let entries: any[] = [];
  try {
    entries = readdirSync(dirPath, { withFileTypes: true }) as any[];
  } catch {
    return;
  }

  for (const entry of entries) {
    const fullPath = path.join(dirPath, entry.name);
    if (entry.isDirectory?.()) {
      walkGeminiCliTranscriptFiles(fullPath, results, depth + 1);
      continue;
    }
    if (!entry.isFile?.() || (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))) continue;
    if (!fullPath.includes(`${path.sep}chats${path.sep}`)) continue;
    results.push(fullPath);
  }
}

function mergeGeminiCliTranscriptRecord(existing: any, next: any): any {
  if (!existing) return next;
  return {
    ...existing,
    ...next,
    timestamp: next?.timestamp || existing?.timestamp,
    type: next?.type || existing?.type,
    model: next?.model || existing?.model,
    content: typeof next?.content === 'string'
      ? (next.content || existing?.content || '')
      : (next?.content ?? existing?.content),
    thoughts: Array.isArray(next?.thoughts) && next.thoughts.length > 0
      ? next.thoughts
      : (existing?.thoughts || []),
    toolCalls: Array.isArray(next?.toolCalls) && next.toolCalls.length > 0
      ? next.toolCalls
      : (existing?.toolCalls || []),
    tokens: next?.tokens || existing?.tokens,
  };
}

function loadGeminiCliTranscript(filePath: string): { sessionId: string; messages: any[] } | null {
  try {
    const raw = readFileSync(filePath, 'utf-8');
    if (!raw.trim()) return null;

    if (filePath.endsWith('.jsonl')) {
      const records = raw
        .split(/\r?\n/)
        .map((line) => line.trim())
        .filter(Boolean)
        .map((line) => {
          try {
            return JSON.parse(line);
          } catch {
            return null;
          }
        })
        .filter(Boolean) as any[];

      if (records.length === 0) return null;

      const header = records.find((record) => typeof record?.sessionId === 'string');
      const sessionId = typeof header?.sessionId === 'string' ? header.sessionId.trim() : '';
      if (!sessionId) return null;

      const order: string[] = [];
      const byId = new Map<string, any>();
      for (const record of records) {
        const type = typeof record?.type === 'string' ? record.type.trim().toLowerCase() : '';
        if (!type) continue;
        const id = typeof record?.id === 'string' ? record.id.trim() : '';
        if (!id) continue;
        if (!byId.has(id)) order.push(id);
        byId.set(id, mergeGeminiCliTranscriptRecord(byId.get(id), record));
      }

      return {
        sessionId,
        messages: order.map((id) => byId.get(id)).filter(Boolean),
      };
    }

    const parsed = JSON.parse(raw);
    const sessionId = typeof parsed?.sessionId === 'string' ? parsed.sessionId.trim() : '';
    if (!sessionId) return null;
    const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
    return { sessionId, messages };
  } catch {
    return null;
  }
}

function getGeminiCliTranscriptIndex(): Map<string, string> {
  const now = Date.now();
  if (geminiCliTranscriptIndexCache && (now - geminiCliTranscriptIndexCache.at) < GEMINI_CLI_TRANSCRIPT_INDEX_TTL_MS) {
    return geminiCliTranscriptIndexCache.index;
  }

  const index = new Map<string, string>();
  const files: string[] = [];
  walkGeminiCliTranscriptFiles(GEMINI_CLI_TMP_DIR, files);

  for (const filePath of files) {
    const loaded = loadGeminiCliTranscript(filePath);
    const sessionId = loaded?.sessionId || '';
    if (!sessionId) continue;

    const existing = index.get(sessionId);
    if (!existing) {
      index.set(sessionId, filePath);
      continue;
    }

    try {
      const existingMtime = statSync(existing).mtimeMs;
      const nextMtime = statSync(filePath).mtimeMs;
      if (nextMtime >= existingMtime) index.set(sessionId, filePath);
    } catch {
      index.set(sessionId, filePath);
    }
  }

  geminiCliTranscriptIndexCache = { at: now, index };
  return index;
}

function resolveGeminiCliTranscriptPath(cliSessionId: string): string | null {
  if (!cliSessionId) return null;
  return getGeminiCliTranscriptIndex().get(cliSessionId) || null;
}

function extractGeminiCliText(content: unknown): string {
  if (typeof content === 'string') return sanitizeHistoryText(content);
  if (Array.isArray(content)) {
    const joined = content
      .map((part: any) => typeof part?.text === 'string' ? part.text : '')
      .filter(Boolean)
      .join('\n');
    return sanitizeHistoryText(joined);
  }
  return '';
}

function extractGeminiCliToolResult(rawResult: unknown): string {
  const parts: string[] = [];

  const pushValue = (value: unknown) => {
    if (typeof value === 'string') {
      const normalized = value.trim();
      if (normalized) parts.push(normalized);
      return;
    }
    if (value && typeof value === 'object') {
      try {
        parts.push(JSON.stringify(value, null, 2));
      } catch {
        // ignore unserializable objects
      }
    }
  };

  if (Array.isArray(rawResult)) {
    for (const entry of rawResult) {
      const response = entry?.functionResponse?.response;
      if (response && typeof response === 'object' && typeof response.output === 'string') {
        pushValue(response.output);
        continue;
      }
      pushValue(response);
      pushValue(entry?.text);
    }
  } else {
    pushValue(rawResult);
  }

  return parts.join('\n\n').trim();
}

function readGeminiCliImportedMessages(cliSessionId: string, limit = 200): any[] {
  const transcriptPath = resolveGeminiCliTranscriptPath(cliSessionId);
  if (!transcriptPath) return [];

  try {
    const loaded = loadGeminiCliTranscript(transcriptPath);
    const rawMessages = Array.isArray(loaded?.messages) ? loaded.messages : [];
    const importedMessages: any[] = [];

    for (const message of rawMessages) {
      const type = typeof message?.type === 'string' ? message.type.trim().toLowerCase() : '';
      const timestamp = message?.timestamp;
      const messageId = typeof message?.id === 'string' ? message.id : `gemini-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

      if (type === 'user') {
        const text = extractGeminiCliText(message?.content);
        if (!text || isHiddenHistoryArtifactText(text)) continue;
        importedMessages.push({
          id: messageId,
          role: 'user',
          content: text,
          timestamp,
          provenance: 'gemini-cli-import',
        });
        continue;
      }

      if (type !== 'gemini' && type !== 'model') continue;

      const content = extractGeminiCliText(message?.content);
      const thinkingContent = sanitizeHistoryText(
        Array.isArray(message?.thoughts)
          ? message.thoughts
              .map((thought: any) => typeof thought?.description === 'string'
                ? thought.description
                : (typeof thought?.text === 'string' ? thought.text : ''))
              .filter(Boolean)
              .join('\n\n')
          : '',
      );
      const toolCalls = Array.isArray(message?.toolCalls)
        ? message.toolCalls
            .map((toolCall: any) => {
              const toolName = typeof toolCall?.name === 'string' ? toolCall.name.trim() : '';
              if (!toolName) return null;
              const endedAt = toHistoryTimestampMs(toolCall?.timestamp || timestamp);
              return {
                id: typeof toolCall?.id === 'string' && toolCall.id.trim() ? toolCall.id : `${messageId}-tool-${toolName}`,
                name: toolName,
                arguments: toolCall?.args,
                startedAt: endedAt,
                endedAt,
                result: extractGeminiCliToolResult(toolCall?.result),
                status: String(toolCall?.status || '').toLowerCase() === 'error' ? 'error' : 'done',
              };
            })
            .filter(Boolean)
        : [];

      if (!content && !thinkingContent && toolCalls.length === 0) continue;
      if (content && isHiddenHistoryArtifactText(content)) continue;

      importedMessages.push({
        id: messageId,
        role: 'assistant',
        content,
        timestamp,
        model: normalizeGeminiModelId(message?.model),
        thinkingContent: thinkingContent || undefined,
        toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
        provenance: 'gemini-cli-import',
      });
    }

    return importedMessages.slice(-limit);
  } catch {
    return [];
  }
}

function hasMeaningfulConversationTurns(messages: any[]): boolean {
  return messages.some((message) => {
    if (!message || typeof message !== 'object') return false;
    if (message.role === 'user' && typeof message.content === 'string' && message.content.trim()) return true;
    if (message.role !== 'assistant') return false;
    if (Array.isArray(message.toolCalls) && message.toolCalls.length > 0) return true;
    if (typeof message.thinkingContent === 'string' && message.thinkingContent.trim()) return true;
    if (typeof message.content !== 'string') return false;
    const normalized = message.content.trim();
    return Boolean(normalized) && !/^Model set to /i.test(normalized);
  });
}

function getLatestMeaningfulConversationTimestamp(messages: any[]): number {
  return getLatestMeaningfulConversationMarker(messages)?.timestamp || 0;
}

function getLatestMeaningfulConversationMarker(messages: any[]): { role: 'user' | 'assistant'; timestamp: number; content: string } | null {
  let latest: { role: 'user' | 'assistant'; timestamp: number; content: string } | null = null;
  const consider = (role: 'user' | 'assistant', timestamp: number, content: string) => {
    if (!latest || timestamp > latest.timestamp) latest = { role, timestamp, content };
  };
  for (const message of messages) {
    if (!message || typeof message !== 'object') continue;
    const timestampMs = toHistoryTimestampMs(message?.timestamp);
    if (!timestampMs) continue;

    if (message.role === 'user' && typeof message.content === 'string' && message.content.trim()) {
      consider('user', timestampMs, message.content.trim());
      continue;
    }

    if (message.role !== 'assistant') continue;
    if (Array.isArray(message.toolCalls) && message.toolCalls.length > 0) {
      consider('assistant', timestampMs, typeof message.content === 'string' ? message.content.trim() : 'tool call');
      continue;
    }
    if (typeof message.thinkingContent === 'string' && message.thinkingContent.trim()) {
      consider('assistant', timestampMs, typeof message.content === 'string' ? message.content.trim() : message.thinkingContent.trim());
      continue;
    }
    if (typeof message.content !== 'string') continue;
    const normalized = message.content.trim();
    if (normalized && !/^Model set to /i.test(normalized) && !isCompactionNoticeText(normalized)) consider('assistant', timestampMs, normalized);
  }
  return latest;
}

function readSessionMessagesEnhancedForSessionKey(sessionKey: string, limit = 200, sessionsDir = SESSIONS_DIR): any[] {
  const localMessages = readBestOpenClawSessionMessagesForSessionKey(sessionKey, limit, sessionsDir);

  const geminiCliSessionIds = resolveSessionRegistryEntries(sessionKey, sessionsDir)
    .map((entry) => resolveGeminiCliBindingSessionId(entry))
    .filter((value, index, all): value is string => Boolean(value) && all.indexOf(value) === index);
  if (geminiCliSessionIds.length === 0) return mergeMaintenanceHistoryMarkers(sessionKey, localMessages, limit);

  const importedMessages = geminiCliSessionIds
    .flatMap((cliSessionId) => readGeminiCliImportedMessages(cliSessionId, limit))
    .sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp))
    .slice(-Math.max(limit, 1));
  if (importedMessages.length === 0) return mergeMaintenanceHistoryMarkers(sessionKey, localMessages, limit);

  const localLatestTimestamp = getLatestMeaningfulConversationTimestamp(localMessages);
  if (!hasMeaningfulConversationTurns(localMessages) || !localLatestTimestamp) {
    const combined = [...localMessages, ...importedMessages]
      .sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp));
    return mergeMaintenanceHistoryMarkers(sessionKey, combined.slice(-Math.max(limit, 1)), limit);
  }

  const importedTail = importedMessages.filter((message) => toHistoryTimestampMs(message?.timestamp) > localLatestTimestamp);
  if (importedTail.length === 0) return mergeMaintenanceHistoryMarkers(sessionKey, localMessages, limit);

  const combined = [...localMessages, ...importedTail]
    .sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp));

  return mergeMaintenanceHistoryMarkers(sessionKey, combined.slice(-Math.max(limit, 1)), limit);
}

async function recoverRecentOpenClawAssistantReply(
  sessionKey: string,
  startedAtMs: number,
  options?: { waitMs?: number; pollMs?: number },
): Promise<{ content: string; model: string | null } | null> {
  const waitMs = Math.max(500, options?.waitMs ?? 8000);
  const pollMs = Math.max(200, options?.pollMs ?? 500);
  const deadline = Date.now() + waitMs;
  const sessionsDir = resolveSessionsDir(sessionKey);
  let explicitSessionId = '';

  try {
    const info = await getSessionInfo(sessionKey);
    explicitSessionId = typeof info?.data?.sessionId === 'string' ? info.data.sessionId.trim() : '';
  } catch {}

  while (Date.now() <= deadline) {
    let messages = readSessionMessagesEnhancedForSessionKey(sessionKey, 30, sessionsDir);
    if ((!messages || messages.length === 0) && explicitSessionId) {
      messages = readSessionMessagesEnhanced(explicitSessionId, 30, sessionsDir);
    }
    const recovered = [...messages]
      .reverse()
      .find((entry) => {
        if (entry?.role !== 'assistant') return false;
        const content = typeof entry?.content === 'string' ? entry.content.trim() : '';
        if (!content) return false;
        const ts = Date.parse(String(entry?.timestamp || ''));
        return Number.isFinite(ts) && ts >= (startedAtMs - 1000);
      });

    if (recovered) {
      return {
        content: recovered.content,
        model: normalizeGatewayModelId(recovered.model) || null,
      };
    }

    await new Promise((resolve) => setTimeout(resolve, pollMs));
  }

  return null;
}

function summarizeSessionLabelFromMessages(params: { sessionKey: string; sessionId: string; messages: any[] }) {
  const { sessionKey, sessionId, messages } = params;
  const fallbackTitle = humanizeSessionKey(sessionKey, sessionId);

  let firstUserText = '';
  let firstUserRawText = '';
  let assistantPreview = '';

  for (const message of messages) {
    if (!message || typeof message !== 'object') continue;
    const role = typeof message.role === 'string' ? message.role : '';
    const rawText = typeof message.content === 'string' ? message.content : extractText(message.content);
    const text = sanitizeHistoryText(rawText).replace(/\s+/g, ' ').trim();
    if (!text && !rawText) continue;
    if (!firstUserText && role === 'user') {
      firstUserText = text;
      firstUserRawText = rawText;
    }
    if (!assistantPreview && role === 'assistant' && text && !/^Model set to /i.test(text)) assistantPreview = text;
    if (firstUserText && assistantPreview) break;
  }

  const senderLabel = extractSenderLabel(firstUserRawText);
  const cleanedPrompt = cleanSessionTitleCandidate(firstUserText);
  const titleSource = cleanedPrompt || senderLabel || fallbackTitle;

  return {
    title: titleSource.length > 72 ? `${titleSource.slice(0, 69).trimEnd()}…` : titleSource,
    preview: assistantPreview
      ? (assistantPreview.length > 120 ? `${assistantPreview.slice(0, 117).trimEnd()}…` : assistantPreview)
      : undefined,
    isMainSession: sessionKey === 'agent:main:main' || sessionId === 'main',
  };
}

function isHiddenHistoryArtifactText(text: string): boolean {
  const normalized = String(text || '').trim();
  if (!normalized) return false;

  return [
    /^System \(untrusted\):/i,
    /^An async command you ran earlier has completed\./i,
    /^Read HEARTBEAT\.md if it exists/i,
    /^HEARTBEAT_OK$/i,
    /^Heartbeat check complete(?:d)?\.?$/i,
    /^Pre-compaction memory flush\./i,
    /^Memory flush complete(?:d)?\.?$/i,
    /<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/i,
    /Handle the result internally\./i,
    /Sender \(untrusted metadata\):/i,
    /Conversation info \(untrusted metadata\):/i,
  ].some((pattern) => pattern.test(normalized));
}

function summarizeHiddenHistoryArtifactText(text: string): string | null {
  const normalized = String(text || '').trim();
  if (!normalized) return null;

  if (/<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/i.test(normalized) && /\[Internal task completion event\]/i.test(normalized)) {
    const sourceMatch = normalized.match(/^source:\s*(.+)$/im);
    const source = sourceMatch?.[1]?.trim().toLowerCase() || '';
    if (source === 'subagent') return 'Delegated task completed';
    if (source) return 'Background task completed';
    return 'Background work completed';
  }

  if (/^An async command you ran earlier has completed\./i.test(normalized)) {
    return 'Earlier async command completed';
  }

  if (/^Read HEARTBEAT\.md if it exists/i.test(normalized)) {
    return 'Heartbeat check started';
  }

  if (/^HEARTBEAT_OK$/i.test(normalized) || /^Heartbeat check complete(?:d)?\.?$/i.test(normalized)) {
    return 'Heartbeat check completed';
  }

  if (/^Pre-compaction memory flush\./i.test(normalized)) {
    return 'Memory flush started';
  }

  if (/^Memory flush complete(?:d)?\.?$/i.test(normalized)) {
    return 'Memory flush completed';
  }

  return null;
}

function humanizeSessionKey(sessionKey: string, sessionId: string): string {
  if (sessionKey === 'agent:main:main' || sessionId === 'main') return 'Main session';

  const parts = sessionKey.split(':');
  const slug = parts.slice(2).join(':') || sessionId;
  const normalized = slug.replace(/^portal-[a-f0-9]{8}-/i, '');

  const newSessionMatch = normalized.match(/^(?:portal-)?new-(\d{13,})$/i);
  if (newSessionMatch) {
    const timestamp = Number(newSessionMatch[1]);
    const date = new Date(timestamp);
    if (!Number.isNaN(date.getTime())) {
      return `New chat · ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
    }
    return 'New chat';
  }

  if (/^blip-analysis(?:-\d+)?$/i.test(normalized)) {
    return normalized.replace(/-/g, ' ');
  }

  if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(normalized)
      && !/^openai$/i.test(normalized)
      && !/^portal-\d+$/i.test(slug)
      && !/^[a-f0-9-]{24,}$/i.test(normalized)) {
    return normalized.replace(/[-_]+/g, ' ');
  }

  return `Session ${sessionId.slice(0, 8)}`;
}

function extractSenderLabel(text: string): string {
  if (!text || !text.includes('Sender (untrusted metadata)')) return '';
  const match = text.match(/```json\s*([\s\S]*?)\s*```/i);
  if (!match) return '';
  try {
    const parsed = JSON.parse(match[1]);
    const candidates = [parsed?.label, parsed?.name, parsed?.username, parsed?.id]
      .map((value) => typeof value === 'string' ? value.trim() : '')
      .filter(Boolean);
    return candidates[0] || '';
  } catch {
    return '';
  }
}

function cleanSessionTitleCandidate(text: string): string {
  return text
    .replace(/^System:\s*/i, '')
    .replace(/^\[[^\]]+\]\s*/, '')
    .replace(/^A new session was started.*$/i, '')
    .replace(/\s+/g, ' ')
    .trim();
}

function isUsableSessionTitle(title: string): boolean {
  if (!title) return false;
  // Long prompts are still useful session labels once truncated by the caller.
  // Rejecting them here made real portal-created chats fall back to
  // "New chat · date/time", which is exactly the multitasking-hostile case.
  if (/^you are\s/i.test(title)) return false;
  if (/^system:/i.test(title)) return false;
  if (/^sender\s*\(/i.test(title)) return false;
  if (/^conversation info\s*\(/i.test(title)) return false;
  return true;
}

function summarizeSessionLabel(params: { sessionKey: string; sessionId: string; lines: string[] }) {
  const { sessionKey, sessionId, lines } = params;
  const fallbackTitle = humanizeSessionKey(sessionKey, sessionId);

  let firstUserText = '';
  let firstUserRawText = '';
  let assistantPreview = '';

  for (const line of lines) {
    try {
      const entry = JSON.parse(line);
      if (entry.type !== 'message' || !entry.message) continue;
      const role = entry.message.role as string;
      const rawText = typeof entry.message.content === 'string'
        ? entry.message.content
        : Array.isArray(entry.message.content)
          ? entry.message.content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('\n')
          : '';
      const text = extractText(entry.message.content)
        .replace(/\s+/g, ' ')
        .trim();
      if (!text && !rawText) continue;
      if (!firstUserText && role === 'user') {
        firstUserText = text;
        firstUserRawText = rawText;
      }
      if (!assistantPreview && role === 'assistant' && text) assistantPreview = text;
      if (firstUserText && assistantPreview) break;
    } catch {
      // ignore malformed lines
    }
  }

  const senderLabel = extractSenderLabel(firstUserRawText);
  const cleanedPrompt = cleanSessionTitleCandidate(firstUserText);

  const titleSource = isUsableSessionTitle(cleanedPrompt)
    ? cleanedPrompt
    : senderLabel || fallbackTitle;

  return {
    title: titleSource.length > 72 ? `${titleSource.slice(0, 69).trimEnd()}…` : titleSource,
    preview: assistantPreview
      ? (assistantPreview.length > 120 ? `${assistantPreview.slice(0, 117).trimEnd()}…` : assistantPreview)
      : undefined,
    isMainSession: sessionKey === 'agent:main:main' || sessionId === 'main',
  };
}

function summarizeTaskText(raw: unknown, max = 220): string | null {
  const text = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '';
  if (!text) return null;
  return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
}

function pickTaskSummaryCandidate(session: any): string | null {
  return summarizeTaskText(
    session?.preview
      ?? session?.lastMessagePreview
      ?? session?.summary
      ?? session?.origin?.preview
      ?? session?.origin?.summary,
  );
}

function pickTaskPromptCandidate(session: any): string | null {
  return summarizeTaskText(
    session?.origin?.task
      ?? session?.origin?.message
      ?? session?.origin?.label
      ?? session?.displayName
      ?? session?.summary,
    240,
  );
}

function extractTaskHistorySummary(messages: any[]): string | null {
  if (!Array.isArray(messages) || messages.length === 0) return null;
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    const msg = messages[i];
    if (!msg || msg.role === 'user') continue;
    const text = summarizeTaskText(extractText(msg.content));
    if (text) return text;
  }
  return null;
}

function extractTaskHistoryInsights(messages: any[]): { prompt: string | null; summary: string | null; detail: string | null; error: string | null } {
  if (!Array.isArray(messages) || messages.length === 0) {
    return { prompt: null, summary: null, detail: null, error: null };
  }

  let prompt: string | null = null;
  let latestAssistant: string | null = null;
  let latestNonUser: string | null = null;
  let error: string | null = null;

  for (const msg of messages) {
    if (!msg) continue;
    const text = extractText(msg.content);
    const summaryText = summarizeTaskText(text, 260);
    const detailText = summarizeTaskText(text, 900);
    if (!summaryText && !detailText) continue;

    const role = typeof msg.role === 'string' ? msg.role : '';
    if (!prompt && role === 'user' && summaryText) {
      prompt = summaryText;
      continue;
    }

    if (role === 'assistant' && summaryText) {
      latestAssistant = summaryText;
    }

    if (role !== 'user' && detailText) {
      latestNonUser = detailText;
    }

    if (!error && /\b(error|failed|exception)\b/i.test(detailText || '')) {
      error = summarizeTaskText(detailText, 320);
    }
  }

  return {
    prompt,
    summary: latestAssistant ?? latestNonUser,
    detail: latestNonUser,
    error,
  };
}

function readLastJsonlLines(filePath: string, maxLines: number): { lines: string[]; hitStart: boolean } {
  if (!existsSync(filePath) || maxLines <= 0) return { lines: [], hitStart: true };

  const stat = statSync(filePath);
  if (!stat.size) return { lines: [], hitStart: true };

  const fd = openSync(filePath, 'r');
  const chunkSize = 64 * 1024;
  let position = stat.size;
  let text = '';
  let newlineCount = 0;
  let hitStart = false;

  try {
    while (position > 0 && newlineCount <= maxLines) {
      const bytesToRead = Math.min(chunkSize, position);
      position -= bytesToRead;
      const buffer = Buffer.allocUnsafe(bytesToRead);
      const bytesRead = readSync(fd, buffer, 0, bytesToRead, position);
      if (bytesRead <= 0) break;
      for (let i = 0; i < bytesRead; i++) {
        if (buffer[i] === 10) newlineCount++;
      }
      text = buffer.subarray(0, bytesRead).toString('utf-8') + text;
    }
    hitStart = position === 0;
  } finally {
    closeSync(fd);
  }

  const lines = text.split('\n').filter((line) => line.trim());
  return { lines: lines.slice(-maxLines), hitStart };
}

function readFirstJsonlLines(filePath: string, maxLines: number, maxBytes = 256 * 1024): string[] {
  if (!existsSync(filePath) || maxLines <= 0 || maxBytes <= 0) return [];

  const stat = statSync(filePath);
  if (!stat.size) return [];

  const fd = openSync(filePath, 'r');
  const buffer = Buffer.allocUnsafe(Math.min(stat.size, maxBytes));
  let bytesRead = 0;
  try {
    bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
  } finally {
    closeSync(fd);
  }

  if (bytesRead <= 0) return [];
  return buffer
    .subarray(0, bytesRead)
    .toString('utf-8')
    .split('\n')
    .filter((line) => line.trim())
    .slice(0, maxLines);
}

function deriveSessionLabelFromSessionFile(sessionKey: string, sessionId: string, sessionsDir = SESSIONS_DIR): { title?: string; preview?: string; isMainSession?: boolean } | null {
  const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
  const lines = readFirstJsonlLines(filePath, 80);
  if (!lines.length) return null;
  const summary = summarizeSessionLabel({ sessionKey, sessionId, lines });
  if (!summary?.title || !isUsableSessionTitle(summary.title)) return null;
  return summary;
}

function readRecentSessionMessages<T>(params: {
  sessionId: string;
  limit: number;
  sessionsDir?: string;
  parseLine: (line: string) => T | null;
  minRawLineWindow?: number;
  growthFactor?: number;
}): T[] {
  const {
    sessionId,
    limit,
    sessionsDir = SESSIONS_DIR,
    parseLine,
    minRawLineWindow = 200,
    growthFactor = 4,
  } = params;

  const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
  if (!existsSync(filePath) || limit <= 0) return [];

  const fileSize = statSync(filePath).size;
  if (fileSize <= 2 * 1024 * 1024) {
    const allLines = readFileSync(filePath, 'utf-8').split('\n').filter((line) => line.trim());
    const parsed: T[] = [];
    for (const line of allLines) {
      const message = parseLine(line);
      if (message) parsed.push(message);
    }
    return parsed.slice(-limit);
  }

  const rawWindowFloor = Math.max(limit, minRawLineWindow);
  let rawLineWindow = Math.max(rawWindowFloor, limit * growthFactor);
  let hitStart = false;

  while (true) {
    const { lines, hitStart: reachedStart } = readLastJsonlLines(filePath, rawLineWindow);
    hitStart = reachedStart;
    const parsed: T[] = [];

    for (const line of lines) {
      const message = parseLine(line);
      if (message) parsed.push(message);
    }

    if (parsed.length >= limit || hitStart) {
      return parsed.slice(-limit);
    }

    rawLineWindow *= 2;
  }
}

/** Legacy text-only history reader (kept for backward compat) */
async function readSessionMessages(sessionId: string, limit = 100, sessionsDir = SESSIONS_DIR): Promise<any[]> {
  return readRecentSessionMessages({
    sessionId,
    limit,
    sessionsDir,
    parseLine: (line) => {
      try {
        const entry = JSON.parse(line);
        if (entry.type !== 'message' || !entry.message) return null;
        const role = entry.message.role;
        if (role !== 'user' && role !== 'assistant') return null;
        const text = extractText(entry.message.content);
        if (!text) return null;
        if (role === 'assistant' && isControlOnlyAssistantText(text)) return null;
        return { id: entry.id, role, content: text, timestamp: entry.timestamp };
      } catch {
        return null;
      }
    },
  });
}

/**
 * Enhanced history reader — includes tool calls and tool results from JSONL.
 */
function readSessionMessagesEnhanced(sessionId: string, limit = 200, sessionsDir = SESSIONS_DIR): any[] {
  const messages = readRecentSessionMessages({
    sessionId,
    limit,
    sessionsDir,
    parseLine: (line) => {
      try {
        const entry = JSON.parse(line);
        if (entry.type === 'compaction') {
          const compactionMeta = typeof entry.__openclaw === 'object' && entry.__openclaw
            ? entry.__openclaw
            : {};
          return {
            id: entry.id,
            role: 'system',
            content: extractCompactionNoticeText(null, compactionMeta),
            timestamp: entry.timestamp,
            __openclaw: {
              ...compactionMeta,
              kind: 'compaction',
              id: compactionMeta.id || entry.id,
            },
          };
        }

        if (entry.type !== 'message' || !entry.message) return null;
        const compactionMeta = entry.message?.__openclaw ?? entry.__openclaw;
        if (compactionMeta?.kind === 'compaction') {
          return {
            id: entry.id,
            role: 'system',
            content: extractCompactionNoticeText(entry.message.content, compactionMeta),
            timestamp: entry.timestamp,
            __openclaw: compactionMeta,
          };
        }
        const role = entry.message.role;
        const content = entry.message.content;
        const executedModel = normalizeGatewayModelId(
          entry.message.model
          ?? entry.message.modelId
          ?? entry.message.model_id
          ?? entry.message.actualModel
          ?? entry.message.executedModel
          ?? entry.message.metadata?.model
          ?? entry.message.providerResponse?.model
          ?? entry.model
          ?? entry.modelId
          ?? entry.actualModel
          ?? entry.executedModel
          ?? entry.metadata?.model
          ?? entry.providerResponse?.model,
        );

        if (role === 'user') {
          const text = extractText(content);
          if (!text) return null;
          if (isHiddenHistoryArtifactText(text)) {
            const summary = summarizeHiddenHistoryArtifactText(text);
            if (!summary) return null;
            return {
              id: entry.id,
              role: 'system',
              content: summary,
              provenance: 'hidden-history-artifact',
              timestamp: entry.timestamp,
            };
          }
          return { id: entry.id, role: 'user', content: text, timestamp: entry.timestamp };
        }

        if (role === 'assistant') {
          if (Array.isArray(content)) {
            const toolCalls: any[] = [];
            const thinkingBlocks: string[] = [];
            // Track text blocks and where tool calls appear so we can separate
            // narration (text before tools) from the final response (text after tools).
            const allBlocks: { type: 'text' | 'tool'; text?: string }[] = [];
            for (const block of content) {
              if (block.type === 'text' && block.text) {
                allBlocks.push({ type: 'text', text: block.text });
              } else if (block.type === 'thinking' && (typeof block.thinking === 'string' || typeof block.text === 'string')) {
                thinkingBlocks.push(typeof block.thinking === 'string' ? block.thinking : block.text);
              } else if (block.type === 'toolCall' && block.name) {
                toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments });
                allBlocks.push({ type: 'tool' });
              }
            }
            const hasToolCalls = toolCalls.length > 0;

            // Build segments array: all text blocks with their position relative to tools.
            // This allows the frontend to reconstruct the streaming timeline on history load.
            const segments: { text: string; position: 'before' | 'after' | 'between' }[] = [];
            let lastToolSeen = false;
            let toolCount = 0;
            for (const block of allBlocks) {
              if (block.type === 'tool') {
                lastToolSeen = true;
                toolCount++;
              } else if (block.type === 'text' && block.text) {
                const position = !lastToolSeen ? 'before' :
                                 (toolCount === toolCalls.length ? 'after' : 'between');
                const segmentText = extractSanitizedText(block.text);
                if (segmentText && !isHiddenHistoryArtifactText(segmentText)) {
                  segments.push({ text: segmentText, position });
                }
              }
            }

            // For display content, join all text blocks (streaming shows them inline anyway)
            const allText = allBlocks
              .filter(b => b.type === 'text')
              .map(b => b.text!)
              .join('\n');
            const text = extractSanitizedText(allText);
            const thinkingContent = extractSanitizedText(thinkingBlocks.join('\n'));
            const hasVisibleText = Boolean(text)
              && !isControlOnlyAssistantText(text)
              && !isHiddenHistoryArtifactText(text);
            const hasVisibleThinking = Boolean(thinkingContent) && !isHiddenHistoryArtifactText(thinkingContent);
            if (!hasVisibleText && !hasVisibleThinking && !hasToolCalls) return null;

            return {
              id: entry.id,
              role: 'assistant',
              content: hasVisibleText ? text : '',
              model: executedModel,
              thinkingContent: hasVisibleThinking ? thinkingContent : undefined,
              toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
              // Include segments for frontend to reconstruct graduated timeline
              segments: hasToolCalls && segments.length > 0 ? segments : undefined,
              timestamp: entry.timestamp,
            };
          }

          const text = extractText(content);
          if (!text || isControlOnlyAssistantText(text) || isHiddenHistoryArtifactText(text)) return null;
          return { id: entry.id, role: 'assistant', content: text, model: executedModel, timestamp: entry.timestamp };
        }

        if (role === 'toolResult') {
          return {
            id: entry.id,
            role: 'toolResult',
            toolCallId: entry.message.toolCallId,
            toolName: entry.message.toolName,
            content: extractText(content),
            timestamp: entry.timestamp,
          };
        }

        return null;
      } catch {
        return null;
      }
    },
  });

  return hydrateHistoryToolCalls(messages);
}

function toHistoryTimestampMs(value: unknown): number {
  if (typeof value === 'number' && Number.isFinite(value)) return value;
  if (typeof value === 'string') {
    const parsed = Date.parse(value);
    if (Number.isFinite(parsed)) return parsed;
  }
  return Date.now();
}

function buildHistoryToolCallFromResult(toolResult: any, fallbackBaseId: string, index: number) {
  const ts = toHistoryTimestampMs(toolResult?.timestamp);
  const toolName = typeof toolResult?.toolName === 'string' && toolResult.toolName.trim()
    ? toolResult.toolName.trim()
    : 'tool';

  return {
    id: typeof toolResult?.toolCallId === 'string' && toolResult.toolCallId.trim()
      ? toolResult.toolCallId
      : `${fallbackBaseId}-tool-${index + 1}`,
    name: toolName,
    arguments: undefined,
    startedAt: ts,
    endedAt: ts,
    status: 'done',
    result: typeof toolResult?.content === 'string' ? toolResult.content : '',
  };
}

function mergePendingToolResultsIntoAssistant(assistant: any, pendingToolResults: any[]): any {
  const existingToolCalls = Array.isArray(assistant?.toolCalls) ? [...assistant.toolCalls] : [];
  const fallbackBaseId = assistant?.id || pendingToolResults[0]?.id || 'history';

  for (let index = 0; index < pendingToolResults.length; index += 1) {
    const toolResult = pendingToolResults[index];
    const synthesized = buildHistoryToolCallFromResult(toolResult, fallbackBaseId, index);

    const matchIndex = existingToolCalls.findIndex((toolCall: any) => {
      if (!toolCall || typeof toolCall !== 'object') return false;
      if (toolResult?.toolCallId && toolCall.id === toolResult.toolCallId) return true;
      return toolResult?.toolName
        && toolCall.name === toolResult.toolName
        && !toolCall.result;
    });

    if (matchIndex >= 0) {
      existingToolCalls[matchIndex] = {
        ...existingToolCalls[matchIndex],
        startedAt: typeof existingToolCalls[matchIndex].startedAt === 'number'
          ? existingToolCalls[matchIndex].startedAt
          : synthesized.startedAt,
        endedAt: synthesized.endedAt,
        status: 'done',
        result: synthesized.result,
      };
      continue;
    }

    existingToolCalls.push(synthesized);
  }

  if (existingToolCalls.length === 0) return assistant;

  return {
    ...assistant,
    toolCalls: existingToolCalls,
  };
}

function synthesizeAssistantFromPendingToolResults(pendingToolResults: any[]): any {
  const lastResult = pendingToolResults[pendingToolResults.length - 1];
  const fallbackBaseId = lastResult?.id || `history-tool-${Date.now()}`;

  return {
    id: `synthetic-${fallbackBaseId}`,
    role: 'assistant',
    content: '',
    timestamp: lastResult?.timestamp,
    toolCalls: pendingToolResults.map((toolResult: any, index: number) => (
      buildHistoryToolCallFromResult(toolResult, fallbackBaseId, index)
    )),
  };
}

function hydrateHistoryToolCalls(messages: any[]): any[] {
  const hydrated: any[] = [];
  let pendingToolResults: any[] = [];

  const flushPendingToolResults = () => {
    if (pendingToolResults.length === 0) return;
    hydrated.push(synthesizeAssistantFromPendingToolResults(pendingToolResults));
    pendingToolResults = [];
  };

  for (const message of messages) {
    if (!message) continue;

    if (message.role === 'toolResult') {
      pendingToolResults.push(message);
      continue;
    }

    if (message.role === 'assistant' && pendingToolResults.length > 0) {
      hydrated.push(mergePendingToolResultsIntoAssistant(message, pendingToolResults));
      pendingToolResults = [];
      continue;
    }

    flushPendingToolResults();
    hydrated.push(message);
  }

  flushPendingToolResults();
  return hydrated;
}

function augmentDirectHistoryPayload(payload: any, sessionKey: string, limit = 200): any {
  if (!payload || !Array.isArray(payload.messages) || !sessionKey) return payload;

  try {
    const sessionsDir = resolveSessionsDir(sessionKey);
    const enhancedMessages = readSessionMessagesEnhancedForSessionKey(sessionKey, limit, sessionsDir);
    if (enhancedMessages.length === 0) return payload;

    const compactionMessages = enhancedMessages
      .filter((message) => message?.role === 'system' && (message?.__openclaw?.kind === 'compaction' || isCompactionNoticeText(message?.content)))
      .map((message) => ({
        id: message.id,
        role: 'system',
        content: extractCompactionNoticeText(message?.content, message?.__openclaw),
        timestamp: message.timestamp,
        __openclaw: message.__openclaw || { kind: 'compaction', id: message.id },
      }));

    if (compactionMessages.length === 0) return payload;

    const seenIds = new Set<string>();
    const combined = [...payload.messages, ...compactionMessages]
      .filter((message) => {
        const messageId = typeof message?.id === 'string' ? message.id : '';
        if (messageId && seenIds.has(messageId)) return false;
        if (messageId) seenIds.add(messageId);
        return true;
      })
      .sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp));

    return {
      ...payload,
      messages: combined.slice(-Math.max(limit, 1)),
    };
  } catch (err) {
    console.warn('[gateway-direct] Failed to augment chat.history payload:', err);
    return payload;
  }
}


function addSessionFileCandidate(candidates: string[], seen: Set<string>, sessionId: unknown, sessionsDir: string): void {
  const normalized = typeof sessionId === 'string' ? sessionId.trim() : '';
  if (!normalized || seen.has(normalized)) return;
  if (!existsSync(path.join(sessionsDir, `${normalized}.jsonl`))) return;
  seen.add(normalized);
  candidates.push(normalized);
}

function findTrajectorySessionFileIdsForSessionKey(sessionKey: string, sessionsDir = SESSIONS_DIR): string[] {
  const variants = new Set(getSessionKeyLookupVariants(sessionKey));
  if (variants.size === 0 || !existsSync(sessionsDir)) return [];

  const candidates: string[] = [];
  const seen = new Set<string>();
  let entries: any[] = [];
  try {
    entries = readdirSync(sessionsDir, { withFileTypes: true }) as any[];
  } catch {
    return [];
  }

  for (const entry of entries) {
    if (!entry.isFile?.() || !entry.name.endsWith('.trajectory.jsonl')) continue;
    const filePath = path.join(sessionsDir, entry.name);
    let raw = '';
    try {
      raw = readFileSync(filePath, 'utf-8');
    } catch {
      continue;
    }
    if (![...variants].some((key) => raw.includes(key))) continue;

    for (const line of raw.split(/\r?\n/)) {
      if (!line || ![...variants].some((key) => line.includes(key))) continue;
      try {
        const parsed = JSON.parse(line);
        const key = typeof parsed?.sessionKey === 'string' ? parsed.sessionKey.trim() : '';
        if (!key || !variants.has(key)) continue;
        addSessionFileCandidate(candidates, seen, parsed?.sessionId, sessionsDir);
        addSessionFileCandidate(candidates, seen, parsed?.data?.sessionId, sessionsDir);
      } catch {
        continue;
      }
    }
  }

  return candidates;
}

function findSessionFileIdsForSessionKey(sessionKey: string, sessionsDir = SESSIONS_DIR): string[] {
  const candidates: string[] = [];
  const seen = new Set<string>();

  for (const entry of resolveSessionRegistryEntries(sessionKey, sessionsDir)) {
    addSessionFileCandidate(candidates, seen, entry?.sessionId || entry?.id, sessionsDir);
  }

  for (const key of getSessionKeyLookupVariants(sessionKey)) {
    addSessionFileCandidate(candidates, seen, key, sessionsDir);
    const parts = key.split(':');
    if (parts.length >= 3) addSessionFileCandidate(candidates, seen, parts.slice(2).join(':'), sessionsDir);
  }

  for (const sessionId of findTrajectorySessionFileIdsForSessionKey(sessionKey, sessionsDir)) {
    addSessionFileCandidate(candidates, seen, sessionId, sessionsDir);
  }

  return candidates;
}

/** Resolve a session key to its JSONL file id */
function resolveSessionFileId(sessionKey: string, sessionsDir = SESSIONS_DIR): string | null {
  return findSessionFileIdsForSessionKey(sessionKey, sessionsDir)[0] || null;
}

function mapTrajectoryRuntimeMessage(rawMessage: any, fallbackId: string, fallbackTimestamp: unknown): any | null {
  if (!rawMessage || typeof rawMessage !== 'object') return null;
  const role = rawMessage.role;
  const timestamp = rawMessage.timestamp || fallbackTimestamp;
  const id = typeof rawMessage.id === 'string' && rawMessage.id.trim()
    ? rawMessage.id.trim()
    : (typeof rawMessage.responseId === 'string' && rawMessage.responseId.trim() ? rawMessage.responseId.trim() : fallbackId);

  if (role === 'user') {
    const text = extractText(rawMessage.content);
    if (!text || isHiddenHistoryArtifactText(text)) return null;
    return { id, role: 'user', content: text, timestamp, provenance: 'trajectory-recovery' };
  }

  if (role === 'assistant') {
    const content = rawMessage.content;
    const executedModel = normalizeGatewayModelId(rawMessage.model ?? rawMessage.modelId ?? rawMessage.actualModel);

    if (Array.isArray(content)) {
      const toolCalls: any[] = [];
      const thinkingBlocks: string[] = [];
      const textBlocks: string[] = [];

      for (const block of content) {
        if (block?.type === 'text' && typeof block.text === 'string') textBlocks.push(block.text);
        if (block?.type === 'thinking' && (typeof block.thinking === 'string' || typeof block.text === 'string')) {
          thinkingBlocks.push(typeof block.thinking === 'string' ? block.thinking : block.text);
        }
        if (block?.type === 'toolCall' && block.name) {
          toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments });
        }
      }

      const text = extractSanitizedText(textBlocks.join('\n'));
      const thinkingContent = extractSanitizedText(thinkingBlocks.join('\n'));
      const hasVisibleText = Boolean(text) && !isControlOnlyAssistantText(text) && !isHiddenHistoryArtifactText(text);
      const hasVisibleThinking = Boolean(thinkingContent) && !isHiddenHistoryArtifactText(thinkingContent);
      if (!hasVisibleText && !hasVisibleThinking && toolCalls.length === 0) return null;

      return {
        id,
        role: 'assistant',
        content: hasVisibleText ? text : '',
        model: executedModel,
        thinkingContent: hasVisibleThinking ? thinkingContent : undefined,
        toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
        timestamp,
        provenance: 'trajectory-recovery',
      };
    }

    const text = extractText(content);
    if (!text || isControlOnlyAssistantText(text) || isHiddenHistoryArtifactText(text)) return null;
    return { id, role: 'assistant', content: text, model: executedModel, timestamp, provenance: 'trajectory-recovery' };
  }

  if (role === 'toolResult') {
    return {
      id,
      role: 'toolResult',
      toolCallId: rawMessage.toolCallId,
      toolName: rawMessage.toolName,
      content: extractText(rawMessage.content),
      timestamp,
      provenance: 'trajectory-recovery',
    };
  }

  return null;
}

function readTrajectoryMessagesForSessionKey(sessionKey: string, limit = 200, sessionsDir = SESSIONS_DIR): any[] {
  const variants = new Set(getSessionKeyLookupVariants(sessionKey));
  if (variants.size === 0 || !existsSync(sessionsDir)) return [];

  const rawMessages: any[] = [];
  let entries: any[] = [];
  try {
    entries = readdirSync(sessionsDir, { withFileTypes: true }) as any[];
  } catch {
    return [];
  }

  for (const entry of entries) {
    if (!entry.isFile?.() || !entry.name.endsWith('.trajectory.jsonl')) continue;
    const filePath = path.join(sessionsDir, entry.name);
    let raw = '';
    try {
      raw = readFileSync(filePath, 'utf-8');
    } catch {
      continue;
    }
    const variantList = Array.from(variants);
    if (!variantList.some((key) => raw.includes(key))) continue;

    for (const line of raw.split(/\r?\n/)) {
      if (!line || !variantList.some((key) => line.includes(key))) continue;
      try {
        const parsed = JSON.parse(line);
        const key = typeof parsed?.sessionKey === 'string' ? parsed.sessionKey.trim() : '';
        if (!key || !variants.has(key)) continue;
        const snapshot = Array.isArray(parsed?.data?.messagesSnapshot) ? parsed.data.messagesSnapshot : [];
        snapshot.forEach((message: any, index: number) => {
          const mapped = mapTrajectoryRuntimeMessage(message, `trajectory-${parsed.runId || entry.name}-${index}`, message?.timestamp || parsed.ts);
          if (mapped) rawMessages.push(mapped);
        });
      } catch {
        continue;
      }
    }
  }

  const seen = new Set<string>();
  const deduped = rawMessages
    .sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp))
    .filter((message) => {
      const toolNames = Array.isArray(message?.toolCalls) ? message.toolCalls.map((tool: any) => tool?.name || '').join(',') : '';
      const key = [message?.role || '', toHistoryTimestampMs(message?.timestamp), message?.content || '', message?.toolName || '', toolNames].join('::');
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });

  return hydrateHistoryToolCalls(deduped).slice(-Math.max(limit, 1));
}

function readBestOpenClawSessionMessagesForSessionKey(sessionKey: string, limit = 200, sessionsDir = SESSIONS_DIR): any[] {
  const candidates = findSessionFileIdsForSessionKey(sessionKey, sessionsDir);
  const seen = new Set<string>();
  const combined: any[] = [];

  const pushMessage = (message: any) => {
    const toolNames = Array.isArray(message?.toolCalls) ? message.toolCalls.map((tool: any) => tool?.name || '').join(',') : '';
    const contentKey = [message?.role || '', toHistoryTimestampMs(message?.timestamp), message?.content || '', message?.toolName || '', toolNames].join('::');
    const messageId = typeof message?.id === 'string' ? message.id.trim() : '';
    const key = contentKey || messageId;
    if (key && seen.has(key)) return;
    if (key) seen.add(key);
    combined.push(message);
  };

  for (const sessionId of candidates) {
    const messages = readSessionMessagesEnhanced(sessionId, limit, sessionsDir);
    for (const message of messages) pushMessage(message);
  }

  for (const message of readTrajectoryMessagesForSessionKey(sessionKey, limit, sessionsDir)) {
    pushMessage(message);
  }

  combined.sort((a, b) => toHistoryTimestampMs(a?.timestamp) - toHistoryTimestampMs(b?.timestamp));
  return combined.slice(-Math.max(limit, 1));
}

const PROVENANCE: Record<string, string> = {
  OPENCLAW: 'via OpenClaw',
  CLAUDE_CODE: 'via Claude CLI',
  CODEX: 'via Codex CLI',
  AGENT_ZERO: 'via Agent Zero',
};

/* ─── REST Routes (kept as fallback) ───────────────────────────────────── */

router.get('/status', authenticateToken, async (_req: Request, res: Response) => {
  try {
    const probe = await fetch(`${GATEWAY_URL}/`, { signal: AbortSignal.timeout(3000) });
    if (probe.ok) {
      res.json({ connected: true, ok: true, status: 'ok', message: 'Gateway is running' });
    } else {
      res.json({ connected: false, ok: false, status: 'error', message: `Gateway returned ${probe.status}` });
    }
  } catch (err: any) {
    res.json({ connected: false, ok: false, status: 'error', message: err.message || 'Gateway unreachable' });
  }
});

// Dashboard health check — includes connectivity + config validation
router.get('/health', authenticateToken, async (_req: Request, res: Response) => {
  try {
    const wsConnected = isPersistentWsConnected();
    const probe = await fetch(`${GATEWAY_URL}/`, { signal: AbortSignal.timeout(3000) }).then(r => r.ok).catch(() => false);
    // Gateway is reachable if the HTTP probe passes OR the persistent WS is up
    const gatewayReachable = probe;
    // Chat-ready requires the authenticated persistent WS — HTTP probe alone is NOT sufficient
    // (the probe just hits the gateway's web UI, not the authenticated WS channel)
    const chatReady = wsConnected;
    // Overall "connected" = gateway process is reachable (for dashboard display)
    const connected = wsConnected || probe;

    // If connected, also check if models are configured
    let modelsConfigured = false;
    let modelCount = 0;
    const issues: string[] = [];
    const openclawVersion = await getOpenClawVersionStatus().catch((err: any) => ({
      installedVersion: null,
      runningVersion: null,
      latestVersion: null,
      updateChannel: null,
      mismatch: false,
      restartRecommended: false,
      reason: null,
      listenerPid: null,
      listenerStartedAt: null,
      installedPackageMtime: null,
      probeOk: false,
      probeError: err?.message || 'Version status check failed',
    }));

    if (openclawVersion.restartRecommended && openclawVersion.reason) {
      issues.push(openclawVersion.reason);
    }

    if (connected) {
      try {
        const models = await listProviderModels('OPENCLAW');
        modelCount = models.length;
        modelsConfigured = modelCount > 0;
      } catch { /* model catalog may be temporarily unavailable — treat as unknown */ }

      if (!modelsConfigured) {
        issues.push('No AI models configured. Run "openclaw onboard" on the server to set up API keys.');
      }

      // Gateway is reachable but persistent WS is not authenticated — chat won't work
      if (!wsConnected) {
        issues.push('Gateway is reachable but the real-time connection failed. Agent chat may not work. Try restarting the portal service.');
        // Try to reconnect in background
        reconnectPersistentWs();
      }
    } else {
      if (!hasGatewayToken()) {
        issues.push('Gateway token not configured. Run "openclaw onboard" to set up, or re-run the installer.');
      } else {
        issues.push('Cannot reach OpenClaw gateway. Check that the openclaw-gateway service is running and the gateway token matches.');
        // Token exists but WS not connected — try to reconnect in background
        // This handles the case where `openclaw onboard` changed the token after portal started
        reconnectPersistentWs();
      }
    }

    const ok = chatReady && modelsConfigured && !openclawVersion.restartRecommended;
    res.json({ ok, connected, wsConnected, chatReady, gatewayReachable, modelsConfigured, modelCount, issues, openclawVersion });
  } catch {
    res.json({ ok: false, connected: false, wsConnected: false, gatewayReachable: false, modelsConfigured: false, modelCount: 0, issues: ['Health check failed'], openclawVersion: null });
  }
});

// Restart the OpenClaw gateway process. Used when the installed package changed
// but the detached listener is still the older in-memory runtime.
router.post('/restart', authenticateToken, requireAdmin, async (_req: Request, res: Response) => {
  try {
    const before = await getOpenClawVersionStatus().catch(() => null);
    const restart = await runOpenClawCli(['gateway', 'restart'], 45000, gatewayRecoveryEnv());
    let forcedStart: { ok: boolean; pid?: number; error?: string } | null = null;
    let after = await waitForGatewayVersionClear(7000);

    // Some customer-like installs run OpenClaw as a detached listener instead of
    // an enabled service. In that mode `gateway restart` may report success-ish
    // instructions but leave the stale port owner alive. Fall back to the same
    // recovery path an operator would use: force-kill the stale listener and run
    // the installed gateway binary detached.
    if (!after || after.restartRecommended || !after.probeOk) {
      forcedStart = forceStartOpenClawGateway();
      after = await waitForGatewayVersionClear(12000);
    }

    reconnectPersistentWs();
    const ok = Boolean(after && !after.restartRecommended && after.probeOk);

    res.status(ok ? 200 : 500).json({
      ok,
      restarted: ok,
      forcedStart,
      message: ok
        ? 'OpenClaw gateway restarted.'
        : (forcedStart?.error || restart.stderr || restart.error || 'OpenClaw gateway restart failed'),
      before,
      after,
      stdout: restart.stdout.slice(-4000),
      stderr: restart.stderr.slice(-4000),
    });
  } catch (err: any) {
    res.status(500).json({ ok: false, restarted: false, error: err?.message || 'OpenClaw gateway restart failed' });
  }
});

// Force reconnect the persistent WS to the OpenClaw gateway.
// Useful when the initial connection failed (timing race, token update, etc.)
router.post('/reconnect', authenticateToken, requireAdmin, async (_req: Request, res: Response) => {
  try {
    if (isPersistentWsConnected()) {
      res.json({ ok: true, wasConnected: true, message: 'Already connected' });
      return;
    }
    reconnectPersistentWs();
    // Wait up to 8s for connection to establish
    for (let i = 0; i < 16; i++) {
      await new Promise(r => setTimeout(r, 500));
      if (isPersistentWsConnected()) {
        res.json({ ok: true, wasConnected: false, message: 'Reconnected successfully' });
        return;
      }
    }
    res.json({ ok: false, wasConnected: false, message: 'Reconnect attempt timed out. Check gateway service and token.' });
  } catch (err: any) {
    res.status(500).json({ ok: false, error: err.message });
  }
});

router.get('/compatibility-hotfix', authenticateToken, requireAdmin, async (_req: Request, res: Response) => {
  try {
    const status = getOpenClawCompatibilityHotfixStatus();
    res.json({
      ok: true,
      ...status,
      note: status.applied
        ? 'Portal compatibility hotfixes are already present in the installed OpenClaw bundles.'
        : 'Installer/update usually auto-apply this temporary patch on affected installs. Use it as a fallback after a separate OpenClaw upgrade or if the compatibility markers are missing; it will restart the OpenClaw gateway.',
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to inspect compatibility hotfix status', detail: err.message });
  }
});

router.post('/compatibility-hotfix/apply', authenticateToken, requireAdmin, async (_req: Request, res: Response) => {
  try {
    const before = getOpenClawCompatibilityHotfixStatus();
    if (!before.scriptExists) {
      res.status(500).json({ error: 'Portal hotfix script is missing from this install.' });
      return;
    }
    if (!before.supported) {
      res.status(500).json({ error: 'This OpenClaw install does not expose the runtime bundles expected by the compatibility hotfix.', status: before });
      return;
    }

    const patchRun = await execFileText('bash', [OPENCLAW_COMPAT_HOTFIX_SCRIPT, OPENCLAW_DIST_DIR], 30000);
    const restartOutput = await restartOpenClawGateway();
    const after = getOpenClawCompatibilityHotfixStatus();

    res.json({
      ok: after.applied,
      alreadyApplied: before.applied,
      status: after,
      patchOutput: [patchRun.stdout, patchRun.stderr].filter(Boolean).join('\n'),
      restartOutput,
      message: after.applied
        ? 'Compatibility hotfix applied and OpenClaw gateway restarted.'
        : 'Hotfix command ran, but the expected patch markers were not detected afterward.',
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to apply compatibility hotfix', detail: err.message });
  }
});

router.get('/providers', authenticateToken, async (_req: Request, res: Response) => {
  try {
    res.json({ providers: AgentRegistry.listProviders() });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to list providers', detail: err.message });
  }
});

router.get('/models', authenticateToken, async (req: Request, res: Response) => {
  try {
    const providerName = ((req.query.provider as string) || 'OPENCLAW').trim().toUpperCase() as AgentProviderName;
    const providerInfo = AgentRegistry.listProviders().find((p) => p.name === providerName);
    if (!providerInfo) {
      res.status(400).json({ error: `Unknown provider: ${providerName}` });
      return;
    }

    const models = await listProviderModels(providerName);
    res.json({
      provider: providerName,
      capabilities: providerInfo.capabilities,
      models,
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to list models', detail: err.message });
  }
});

router.get('/commands', authenticateToken, async (req: Request, res: Response) => {
  try {
    const providerName = ((req.query.provider as string) || 'OPENCLAW').trim().toUpperCase() as AgentProviderName;
    const providerInfo = AgentRegistry.listProviders().find((p) => p.name === providerName);
    if (!providerInfo) {
      res.status(400).json({ error: `Unknown provider: ${providerName}` });
      return;
    }

    const commands = await getProviderCommandCatalog(providerName);
    res.json({
      provider: providerName,
      capabilities: providerInfo.capabilities,
      commands,
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to list commands', detail: err.message });
  }
});

router.get('/sessions', authenticateToken, requireAdmin, async (req: Request, res: Response) => {
  try {
    const providerName = (req.query.provider as AgentProviderName) || undefined;
    if (providerName) {
      try {
        const provider = AgentRegistry.get(providerName);
        const sessions = await provider.listSessions(req.user!.userId);
        res.json({ sessions });
        return;
      } catch (err: any) {
        console.warn(`[gateway] Provider ${providerName} listSessions failed: ${err.message}`);
      }
    }

    // Support agentId filter — defaults to all agents for full visibility
    const agentId = req.query.agentId as string | undefined;
    const sanitizedAgentId = agentId ? agentId.replace(/[^a-zA-Z0-9_-]/g, '') : null;

    // For 'main' (or no agentId = show main), use the canonical session registry from sessions.json.
    // The 'main' agent is not in agents.list so the CLI won't enumerate it reliably.
    const isAdmin = isElevatedRole(req.user!.role);
    const isSandboxIsolated = shouldIsolateUser(req.user!);
    const isMainAgent = !sanitizedAgentId || sanitizedAgentId === 'main';

    if (!isAdmin && isSandboxIsolated && isMainAgent) {
      const provider = AgentRegistry.get('OPENCLAW');
      const sessions = await provider.listSessions(req.user!.userId);
      res.json({ sessions });
      return;
    }
    if (isMainAgent) {
      const sessionsDir = path.join(process.env.HOME || '/root', '.openclaw/agents/main/sessions');
      const sessionsFile = path.join(sessionsDir, 'sessions.json');
      try {
        const cutoffMs = isOwnerRole(req.user!.role) ? 0 : Date.now() - 1440 * 60 * 1000;
        if (existsSync(sessionsFile)) {
          const stat = statSync(sessionsFile);
          if (
            mainSessionListCache
            && Date.now() - mainSessionListCache.at < MAIN_SESSION_LIST_CACHE_TTL_MS
            && mainSessionListCache.mtimeMs === stat.mtimeMs
            && mainSessionListCache.size === stat.size
          ) {
            res.json({ sessions: mainSessionListCache.sessions });
            return;
          }

          const raw = JSON.parse(readFileSync(sessionsFile, 'utf-8'));
          const source = Array.isArray(raw?.sessions) ? raw.sessions : raw;
          const entries: Array<[string, any]> = Array.isArray(source)
            ? (source
                .map((meta: any) => [String(meta?.key || meta?.sessionKey || meta?.sessionId || meta?.id || '').trim(), meta] as [string, any])
                .filter(([sessionKey]) => Boolean(sessionKey)))
            : Object.entries(source || {}) as Array<[string, any]>;
          const sessions: any[] = [];
          const activeCutoffMs = Date.now() - 5 * 60 * 1000;

          for (const [rawSessionKey, meta] of entries) {
            const sessionKey = String(rawSessionKey || meta?.key || meta?.sessionKey || '').trim();
            if (!sessionKey) continue;

            const sessionId = String(meta?.sessionId || meta?.id || '').trim();
            if (!sessionId) continue;

            const createdAt = Number(meta?.sessionStartedAt || meta?.startedAt || meta?.createdAt || meta?.updatedAt || Date.now());
            const lastActivityAt = Number(meta?.lastInteractionAt || meta?.updatedAt || meta?.endedAt || createdAt || Date.now());
            if (lastActivityAt && lastActivityAt < cutoffMs) continue;

            const fallbackTitle = humanizeSessionKey(sessionKey, sessionId);
            const label = typeof meta?.label === 'string' ? meta.label.trim() : '';
            const title = label || fallbackTitle;
            const previewParts = [
              typeof meta?.status === 'string' ? meta.status.trim() : '',
              typeof meta?.model === 'string' ? meta.model.trim() : '',
            ].filter(Boolean);

            sessions.push({
              key: sessionKey,
              sessionId,
              id: sessionId,
              agentId: 'main',
              status: lastActivityAt > activeCutoffMs ? 'active' : 'idle',
              createdAt,
              lastActivityAt,
              updatedAt: lastActivityAt,
              title,
              preview: previewParts.join(' • '),
              isMainSession: sessionKey === 'agent:main:main',
              _hasExplicitTitle: Boolean(label),
            });
          }

          sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
          for (const session of sessions.slice(0, MAIN_SESSION_DERIVED_TITLE_LIMIT)) {
            if (session._hasExplicitTitle) continue;
            const derived = deriveSessionLabelFromSessionFile(session.key, session.sessionId, sessionsDir);
            if (!derived?.title) continue;
            session.title = derived.title;
            if (derived.preview) session.preview = derived.preview;
            if (typeof derived.isMainSession === 'boolean') session.isMainSession = derived.isMainSession;
          }
          for (const session of sessions) delete session._hasExplicitTitle;
          mainSessionListCache = { at: Date.now(), mtimeMs: stat.mtimeMs, size: stat.size, sessions };
          res.json({ sessions });
          return;
        }
      } catch (err: any) {
        mainSessionListCache = null;
        // fall through to CLI path
      }
    }

    const args = ['sessions', '--json', '--active', '1440'];
    if (sanitizedAgentId && sanitizedAgentId !== 'main') {
      args.push('--agent', sanitizedAgentId);
    } else {
      args.push('--all-agents');
    }

    try {
      const output = execFileSync('openclaw', args, { timeout: 10000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], env: buildOpenClawCliEnv() }) as string;
      const parsed = JSON.parse(output.trim());
      // --all-agents returns { agents: { id: { sessions: [...] } } } — flatten
      if (parsed.agents && !parsed.sessions) {
        const allSessions: any[] = [];
        for (const agent of Object.values(parsed.agents) as any[]) {
          if (agent.sessions && Array.isArray(agent.sessions)) {
            allSessions.push(...agent.sessions);
          }
        }
        // Sort by updatedAt descending
        allSessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
        res.json({ sessions: allSessions });
      } else {
        res.json(parsed);
      }
      return;
    } catch {}

    // Final fallback: sessions.json file
    const fallbackAgent = sanitizedAgentId || 'main';
    const agentSessionsDir = path.join(process.env.HOME || '/root', `.openclaw/agents/${fallbackAgent}/sessions`);
    const sessionsFile = path.join(agentSessionsDir, 'sessions.json');
    if (existsSync(sessionsFile)) {
      res.json(JSON.parse(readFileSync(sessionsFile, 'utf-8')));
      return;
    }
    res.json({ sessions: [] });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to list sessions', detail: err.message });
  }
});

const USAGE_STATS_CACHE_TTL_MS = 15000;
const usageStatsCache = new Map<string, { at: number; payload: any }>();
const usageStatsInflight = new Map<string, Promise<any>>();

async function getUsageStatsSnapshot(selectedAgent: string) {
  const cacheKey = selectedAgent || '__all__';
  const now = Date.now();
  const cached = usageStatsCache.get(cacheKey);
  if (cached && (now - cached.at) < USAGE_STATS_CACHE_TTL_MS) {
    return cached.payload;
  }

  const existing = usageStatsInflight.get(cacheKey);
  if (existing) return existing;

  const promise = (async () => {
    const { execSync } = require('child_process');

    let sessions: any[] = [];
    try {
      const sessionsResult = await gatewayRpcCall('sessions.list', {}, 10000);
      if (sessionsResult.ok && Array.isArray(sessionsResult.data?.sessions)) {
        sessions = sessionsResult.data.sessions;
      } else if (sessionsResult.ok && sessionsResult.data?.agents && typeof sessionsResult.data.agents === 'object') {
        sessions = Object.values(sessionsResult.data.agents).flatMap((agent: any) => Array.isArray(agent?.sessions) ? agent.sessions : []);
      }
    } catch {
      // continue with CLI fallback
    }

    if (!sessions.length) {
      try {
        const agentsDir = path.join(process.env.HOME || '/root', '.openclaw/agents');
        const collected: any[] = [];
        if (existsSync(agentsDir)) {
          for (const agentId of readdirSync(agentsDir)) {
            const sessionsFile = path.join(agentsDir, agentId, 'sessions', 'sessions.json');
            if (!existsSync(sessionsFile)) continue;
            const raw = JSON.parse(readFileSync(sessionsFile, 'utf-8'));
            const source = Array.isArray(raw?.sessions) ? raw.sessions : raw;
            const entries = Array.isArray(source) ? source : Object.values(source || {});
            for (const session of entries as any[]) {
              if (!session) continue;
              collected.push({ ...session, agentId: session.agentId || agentId });
            }
          }
        }
        sessions = collected;
      } catch {
        // continue with CLI fallback
      }
    }

    if (!sessions.length) {
      try {
        const sessionsRaw = execSync('openclaw sessions --json --all-agents 2>/dev/null', { timeout: 30000, encoding: 'utf-8', env: buildOpenClawCliEnv() });
        const parsed = JSON.parse(sessionsRaw.trim());
        if (Array.isArray(parsed.sessions)) {
          sessions = parsed.sessions;
        } else if (parsed.agents && typeof parsed.agents === 'object') {
          sessions = Object.values(parsed.agents).flatMap((agent: any) => Array.isArray(agent?.sessions) ? agent.sessions : []);
        }
      } catch {
        // continue with empty sessions
      }
    }

    let cronJobs: any[] = [];
    try {
      const cronsRaw = execSync('openclaw cron list --json 2>/dev/null', { timeout: 10000, encoding: 'utf-8', env: buildOpenClawCliEnv() });
      const parsed = JSON.parse(cronsRaw.trim());
      cronJobs = parsed.jobs || [];
    } catch {
      // continue with empty crons
    }

    const agentFilteredSessions = selectedAgent
      ? sessions.filter((s: any) => (s?.agentId || 'main') === selectedAgent)
      : sessions;
    const agentFilteredCrons = selectedAgent
      ? cronJobs.filter((j: any) => (j?.agentId || j?.agent || 'main') === selectedAgent)
      : cronJobs;

    const totalSessions = agentFilteredSessions.length;
    const activeSessions = agentFilteredSessions.filter((s: any) => {
      const lastMs = s.lastActivityMs || s.updatedAt || s.updatedAtMs || 0;
      return now - lastMs < 3600000;
    }).length;

    const modelCounts: Record<string, number> = {};
    agentFilteredSessions.forEach((s: any) => {
      const model = normalizeGatewayModelId(s.model ?? s.defaultModel) || 'unknown';
      modelCounts[model] = (modelCounts[model] || 0) + 1;
    });
    const modelBreakdown = Object.entries(modelCounts)
      .map(([model, count]) => ({ model, sessions: count }))
      .sort((a, b) => b.sessions - a.sessions);

    const recentSessions = agentFilteredSessions
      .sort((a: any, b: any) => (b.lastActivityMs || b.updatedAt || 0) - (a.lastActivityMs || a.updatedAt || 0))
      .slice(0, 20)
      .map((s: any) => ({
        key: s.sessionKey || s.key || s.id,
        agent: s.agentId || 'main',
        model: normalizeGatewayModelId(s.model ?? s.defaultModel) || 'unknown',
        lastActivity: s.lastActivityMs || s.updatedAt || s.updatedAtMs,
        turns: s.turns || 0,
      }));

    const payload = {
      totalSessions,
      activeSessions,
      cronJobs: agentFilteredCrons.length,
      activeCrons: agentFilteredCrons.filter((j: any) => j.enabled !== false).length,
      modelBreakdown,
      recentSessions,
    };

    usageStatsCache.set(cacheKey, { at: Date.now(), payload });
    return payload;
  })();

  usageStatsInflight.set(cacheKey, promise);
  try {
    return await promise;
  } finally {
    usageStatsInflight.delete(cacheKey);
  }
}

// GET /api/gateway/usage-stats — aggregates session and cron data for usage dashboard
router.get('/usage-stats', authenticateToken, requireAdmin, async (req: Request, res: Response) => {
  try {
    const selectedAgent =
      (typeof req.query.agent === 'string' && req.query.agent.trim())
      || (typeof req.query.agentId === 'string' && req.query.agentId.trim())
      || '';

    res.json(await getUsageStatsSnapshot(selectedAgent));
  } catch (err: any) {
    console.error('[gateway] usage-stats error:', err);
    res.status(500).json({ error: err.message || 'Failed to get usage stats' });
  }
});

const TASKS_ROUTE_CACHE_TTL_MS = 10000;
let tasksRouteCache: { at: number; payload: any } | null = null;
let tasksRouteInflight: Promise<any> | null = null;

function normalizeGatewayTaskStatus(status: unknown, endedAt?: unknown): 'running' | 'done' | 'failed' | 'unknown' {
  const normalized = typeof status === 'string' ? status.toLowerCase() : '';
  if (['done', 'completed', 'success', 'succeeded'].includes(normalized)) return 'done';
  if (['failed', 'error', 'errored', 'cancelled', 'canceled'].includes(normalized)) return 'failed';
  if (['running', 'active', 'pending', 'queued', 'starting'].includes(normalized)) return 'running';
  if (endedAt) return 'done';
  return 'unknown';
}

function mapOpenClawLedgerTask(task: any) {
  const id = String(task?.taskId || task?.id || task?.runId || task?.sourceId || '').trim();
  if (!id) return null;
  const kind = String(task?.kind || task?.runtime || '').toLowerCase() || 'task';
  const status = normalizeGatewayTaskStatus(task?.status, task?.endedAt);

  return {
    id,
    name: summarizeTaskText(task?.title ?? task?.name ?? task?.label ?? task?.message ?? id, 120) || id,
    status,
    model: normalizeGatewayModelId(task?.model ?? task?.modelId ?? task?.modelProvider) || 'unknown',
    kind: kind.includes('cron') ? 'cron' : kind.includes('subagent') ? 'subagent' : kind,
    createdAt: task?.createdAt ?? task?.startedAt,
    updatedAt: task?.updatedAt ?? task?.endedAt ?? task?.startedAt,
    duration: task?.runtimeMs ?? (typeof task?.startedAt === 'number' && typeof task?.endedAt === 'number' ? task.endedAt - task.startedAt : undefined),
    summary: summarizeTaskText(task?.summary ?? task?.terminalSummary ?? task?.finalSummary ?? task?.result),
    prompt: summarizeTaskText(task?.prompt ?? task?.message ?? task?.title, 240),
    detail: summarizeTaskText(task?.detail ?? task?.terminalSummary ?? task?.error, 500),
    parentSession: task?.ownerKey ?? task?.parentSession ?? task?.sessionKey ?? null,
    error: status === 'failed' ? summarizeTaskText(task?.error ?? task?.terminalSummary ?? 'Task failed') : null,
  };
}

function collapseKeyForTaskSession(session: any) {
  const key = String(session?.key || session?.sessionKey || '');
  const cronRunMatch = key.match(/^(agent:[^:]+:cron:[^:]+):run:[^:]+$/);
  if (cronRunMatch) return cronRunMatch[1];
  return key;
}

function mapOpenClawTaskSession(session: any) {
  const status = normalizeGatewayTaskStatus(session.status, session.endedAt);
  return {
    id: collapseKeyForTaskSession(session) || session.key || session.sessionKey || session.id || 'unknown',
    name: session.displayName || session.origin?.label || session.key?.split(':').pop() || 'Task',
    status,
    model: normalizeGatewayModelId(session.model) || session.modelProvider || 'unknown',
    kind: String(session.kind || session.key || session.sessionKey || '').includes('cron') ? 'cron' : 'subagent',
    createdAt: session.startedAt,
    updatedAt: session.endedAt || session.updatedAt,
    duration: session.runtimeMs,
    summary: pickTaskSummaryCandidate(session),
    prompt: pickTaskPromptCandidate(session),
    detail: null,
    parentSession: session.origin?.from || null,
    error: status === 'failed' ? summarizeTaskText(session.error || 'Task failed') : null,
  };
}

async function fetchTasksSnapshot() {
  const [sessionsResult, ledgerResult] = await Promise.all([
    gatewayRpcCall('sessions.list', {}, 30000),
    gatewayRpcCall('tasks.list', { limit: 100 }, 15000).catch((err: any) => ({ ok: false, error: err?.message || String(err) })),
  ]);

  if (!sessionsResult.ok || !sessionsResult.data?.sessions) {
    throw new Error(sessionsResult.error || 'Gateway unavailable');
  }

  const sessions = Array.isArray(sessionsResult.data.sessions) ? sessionsResult.data.sessions : [];
  const taskSessions = sessions.filter((s: any) => {
    const key = s.key || s.sessionKey || '';
    const kind = String(s.kind || '').toLowerCase();
    return kind === 'subagent' || kind === 'cron' || key.includes(':subagent:') || key.includes(':cron:');
  });

  const collapsedTaskSessions = new Map<string, any>();
  for (const session of taskSessions) {
    const collapseKey = collapseKeyForTaskSession(session);
    const existing = collapsedTaskSessions.get(collapseKey);
    if (!existing) {
      collapsedTaskSessions.set(collapseKey, session);
      continue;
    }

    const existingStatus = String(existing?.status || '').toLowerCase();
    const sessionStatus = String(session?.status || '').toLowerCase();
    const existingRunning = !existing?.endedAt && existingStatus !== 'done' && existingStatus !== 'error';
    const sessionRunning = !session?.endedAt && sessionStatus !== 'done' && sessionStatus !== 'error';
    const existingTime = Number(existing?.endedAt || existing?.updatedAt || existing?.startedAt || 0);
    const sessionTime = Number(session?.endedAt || session?.updatedAt || session?.startedAt || 0);

    if ((!existingRunning && sessionRunning) || (existingRunning === sessionRunning && sessionTime >= existingTime)) {
      collapsedTaskSessions.set(collapseKey, session);
    }
  }

  const tasksById = new Map<string, any>();
  for (const task of Array.from(collapsedTaskSessions.values()).map(mapOpenClawTaskSession)) {
    tasksById.set(task.id, task);
  }

  if (ledgerResult.ok && Array.isArray((ledgerResult as any).data?.tasks)) {
    for (const task of (ledgerResult as any).data.tasks.map(mapOpenClawLedgerTask).filter(Boolean)) {
      tasksById.set(task.id, task);
    }
  }

  const tasks = Array.from(tasksById.values());

  tasks.sort((a: any, b: any) => {
    const aTime = Number(a.updatedAt || a.createdAt || 0);
    const bTime = Number(b.updatedAt || b.createdAt || 0);
    return bTime - aTime;
  });

  return {
    ok: true,
    tasks,
    fetchedAt: new Date().toISOString(),
    source: ledgerResult.ok ? 'tasks.list+sessions.list' : 'sessions.list',
    warning: ledgerResult.ok ? undefined : (ledgerResult as any).error || 'OpenClaw tasks.list unavailable; showing session-derived tasks only',
  };
}

// GET /api/gateway/tasks — Query OpenClaw gateway for task/subagent state
router.get('/tasks', requireAdmin, async (_req: Request, res: Response) => {
  const now = Date.now();
  if (tasksRouteCache && now - tasksRouteCache.at < TASKS_ROUTE_CACHE_TTL_MS) {
    res.json(tasksRouteCache.payload);
    return;
  }

  try {
    if (!tasksRouteInflight) {
      tasksRouteInflight = fetchTasksSnapshot().finally(() => {
        tasksRouteInflight = null;
      });
    }
    const payload = await tasksRouteInflight;
    tasksRouteCache = { at: Date.now(), payload };
    res.json(payload);
  } catch (err: any) {
    console.error('[gateway] tasks error:', err);
    if (tasksRouteCache) {
      res.json({ ...tasksRouteCache.payload, stale: true, warning: err.message || 'Tasks temporarily unavailable' });
      return;
    }
    res.json({ ok: false, tasks: [], stale: true, warning: err.message || 'Tasks temporarily unavailable' });
  }
});

router.get('/session-info', authenticateToken, async (req: Request, res: Response) => {
  try {
    const sessionKey = resolveOpenClawSessionKey(req.query.session as string, req.user);
    assertGatewaySessionAccess(sessionKey, req.user!);
    let result = await getSessionInfo(sessionKey);
    if ((!result.ok || !result.data) && sessionKey.includes(':new-')) {
      const created = await createSession(sessionKey);
      if (created.ok) {
        result = await getSessionInfo(created.key || sessionKey);
      }
    }
    if (!result.ok) {
      // Distinguish gateway transport failures (timeout, WS error) from "session not found"
      const status = isGatewayTransportError(result.error) ? 502 : 404;
      res.status(status).json({ error: result.error || 'Session not found' });
      return;
    }
    res.json({ session: result.data });
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to get session info', detail: err.message });
  }
});

router.post('/session-create', authenticateToken, requireApproved, async (req: Request, res: Response) => {
  try {
    const providerName = (typeof req.body?.provider === 'string' ? req.body.provider.trim().toUpperCase() : 'OPENCLAW') as AgentProviderName;
    if (providerName !== 'OPENCLAW') {
      res.status(400).json({ error: 'Session creation only supported for OPENCLAW provider' });
      return;
    }

    const rawSession = typeof req.body?.session === 'string' ? req.body.session.trim() : '';
    if (!rawSession) {
      res.status(400).json({ error: 'session required' });
      return;
    }

    const sessionKey = resolveOpenClawSessionKey(rawSession, req.user);
    assertGatewaySessionAccess(sessionKey, req.user!, { providerName });

    const created = await createSession(sessionKey);
    if (!created.ok) {
      const status = isGatewayTransportError(created.error) ? 502 : 400;
      res.status(status).json({ error: created.error || 'Failed to create session' });
      return;
    }

    const info = await getSessionInfo(created.key || sessionKey);
    res.json({ ok: true, key: created.key || sessionKey, session: info.ok ? info.data : null });
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to create session', detail: err.message });
  }
});

router.post('/session-model', authenticateToken, requireApproved, async (req: Request, res: Response) => {
  try {
    const providerName = (typeof req.body?.provider === 'string' ? req.body.provider.trim().toUpperCase() : 'OPENCLAW') as AgentProviderName;
    const model = normalizeRequestedModel(providerName, typeof req.body?.model === 'string' ? req.body.model.trim() : '');

    if (!model) {
      res.status(400).json({ error: 'model required' });
      return;
    }

    if (providerName === 'OPENCLAW') {
      if (!model.includes('/')) {
        res.status(400).json({ error: 'model must include provider prefix' });
        return;
      }
      const rawSession = typeof req.body?.session === 'string' ? req.body.session.trim() : '';
      const sessionKey = resolveOpenClawSessionKey(rawSession, req.user);
      assertGatewaySessionAccess(sessionKey, req.user!, { providerName });
      const isConcreteSession = sessionKey.startsWith('agent:');
      if (!isConcreteSession) {
        res.status(409).json({ error: 'No concrete OpenClaw session selected', code: 'NO_CONCRETE_SESSION' });
        return;
      }

      let info = await getSessionInfo(sessionKey);
      if ((!info.ok || !info.data) && sessionKey.includes(':new-')) {
        const created = await createSession(sessionKey);
        if (created.ok) {
          info = await getSessionInfo(created.key || sessionKey);
        }
      }
      if (!info.ok || !info.data) {
        const status = isGatewayTransportError(info.error) ? 502 : 404;
        res.status(status).json({ error: info.error || 'Session not found' });
        return;
      }

      const patched = await patchSessionModel(sessionKey, model);
      if (!patched.ok) {
        res.status(502).json({ error: patched.error || 'Failed to patch session model' });
        return;
      }

      const refreshed = await getSessionInfo(sessionKey);
      res.json({ ok: true, session: refreshed.ok ? refreshed.data : info.data, resolved: patched.resolved || null });
      return;
    }

    if (!isElevatedRole(req.user!.role)) {
      res.status(403).json({ error: 'Admin access required' });
      return;
    }

    const rawSession = typeof req.body?.session === 'string' ? req.body.session.trim() : '';
    if (!rawSession || rawSession === 'main' || rawSession.startsWith('new-')) {
      res.status(409).json({ error: 'No concrete native session selected', code: 'NO_CONCRETE_SESSION' });
      return;
    }

    const nativeSession = loadNativeSession(providerName, rawSession);
    if (!nativeSession || nativeSession.userId !== req.user!.userId) {
      res.status(404).json({ error: 'Session not found' });
      return;
    }

    const updated = updateNativeSessionModel(providerName, rawSession, model);
    res.json({
      ok: true,
      session: updated ? {
        sessionId: updated.sessionId,
        model: updated.model,
        modelProvider: providerName.toLowerCase(),
        metadata: updated.metadata || {},
      } : null,
      resolved: updated ? { modelProvider: providerName.toLowerCase(), model } : null,
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to patch session model', detail: err.message });
  }
});

/**
 * Patch session settings (thinking level, fast mode, etc.)
 * Only works for OPENCLAW provider with concrete sessions.
 */
router.post('/session-patch', authenticateToken, requireApproved, async (req: Request, res: Response) => {
  try {
    const providerName = (typeof req.body?.provider === 'string' ? req.body.provider.trim().toUpperCase() : 'OPENCLAW') as AgentProviderName;
    const rawSession = typeof req.body?.session === 'string' ? req.body.session.trim() : '';
    const settings = typeof req.body?.settings === 'object' && req.body.settings !== null ? req.body.settings : {};

    if (!rawSession) {
      res.status(400).json({ error: 'Session required' });
      return;
    }

    // Only OPENCLAW supports session patching via gateway RPC
    if (providerName !== 'OPENCLAW') {
      res.status(400).json({ error: 'Session patching only supported for OPENCLAW provider' });
      return;
    }

    const sessionKey = resolveOpenClawSessionKey(rawSession, req.user);
    assertGatewaySessionAccess(sessionKey, req.user!, { providerName });

    const isConcreteSession = sessionKey.startsWith('agent:');
    if (!isConcreteSession) {
      res.status(409).json({ error: 'No concrete OpenClaw session selected', code: 'NO_CONCRETE_SESSION' });
      return;
    }

    const thinking = typeof settings.thinking === 'string' ? settings.thinking.trim().toLowerCase() : '';
    const model = typeof settings.model === 'string' ? settings.model.trim() : '';
    const rawFastMode = settings.fastMode;

    const patch: Record<string, any> = { key: sessionKey };

    if (thinking) {
      const allowedThinking = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'adaptive']);
      if (!allowedThinking.has(thinking)) {
        res.status(400).json({ error: `Unsupported thinking level: ${thinking}` });
        return;
      }
      patch.thinkingLevel = thinking;
    }

    if (typeof rawFastMode === 'boolean' || rawFastMode === null) {
      patch.fastMode = rawFastMode;
    } else if (typeof rawFastMode === 'string') {
      const normalizedFastMode = rawFastMode.trim().toLowerCase();
      if (normalizedFastMode === 'on' || normalizedFastMode === 'true') {
        patch.fastMode = true;
      } else if (normalizedFastMode === 'off' || normalizedFastMode === 'false') {
        patch.fastMode = false;
      } else if (normalizedFastMode === 'inherit' || normalizedFastMode === 'default' || normalizedFastMode === 'auto' || normalizedFastMode === 'null') {
        patch.fastMode = null;
      } else if (normalizedFastMode) {
        res.status(400).json({ error: `Unsupported fast mode value: ${rawFastMode}` });
        return;
      }
    }

    if (model) {
      patch.model = model;
    }

    if (Object.keys(patch).length === 1) {
      res.json({ ok: true, session: null });
      return;
    }

    const result = await gatewayRpcCall('sessions.patch', patch);
    if (!result.ok) {
      res.status(502).json({ error: result.error || 'Failed to patch session' });
      return;
    }

    res.json({ ok: true, session: result.data || null });
  } catch (err: any) {
    console.error('[gateway] session-patch error:', err);
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to patch session', detail: err.message });
  }
});

router.get('/config-path', authenticateToken, requireApproved, async (req: Request, res: Response) => {
  try {
    if (!isElevatedRole(req.user!.role)) {
      res.status(403).json({ error: 'Admin access required' });
      return;
    }
    const pathStr = typeof req.query?.path === 'string' ? req.query.path.trim() : '';
    if (!pathStr) {
      res.status(400).json({ error: 'path required' });
      return;
    }
    const cfgResult = await gatewayRpcCall('config.get', {});
    if (!cfgResult.ok) {
      res.status(502).json({ error: cfgResult.error || 'config.get failed' });
      return;
    }
    const config = cfgResult.data?.config || cfgResult.data?.parsed || {};
    res.json({ ok: true, path: pathStr, value: readConfigPath(config, pathStr) });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to read config path', detail: err.message });
  }
});

router.post('/config-path', authenticateToken, requireApproved, async (req: Request, res: Response) => {
  try {
    if (!isElevatedRole(req.user!.role)) {
      res.status(403).json({ error: 'Admin access required' });
      return;
    }
    const pathStr = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
    if (!pathStr) {
      res.status(400).json({ error: 'path required' });
      return;
    }
    const cfgResult = await gatewayRpcCall('config.get', {});
    if (!cfgResult.ok) {
      res.status(502).json({ error: cfgResult.error || 'config.get failed' });
      return;
    }
    const config = cfgResult.data?.config || cfgResult.data?.parsed || {};
    const updated = JSON.parse(JSON.stringify(config || {}));
    const nextValue = req.body?.value;
    if (nextValue === null || nextValue === undefined || nextValue === '') {
      deleteConfigPath(updated, pathStr);
    } else {
      writeConfigPath(updated, pathStr, nextValue);
    }
    const patchResult = await gatewayRpcCall('config.patch', {
      raw: JSON.stringify(updated),
      baseHash: cfgResult.data?.hash || '',
    }, 15000);
    if (!patchResult.ok) {
      res.status(502).json({ error: patchResult.error || 'config.patch failed' });
      return;
    }
    res.json({ ok: true, path: pathStr, value: readConfigPath(updated, pathStr) });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to patch config path', detail: err.message });
  }
});

interface ParsedPortalSlashCommand {
  command: string;
  raw: string;
  args: string[];
  rest: string;
}

function parsePortalSlashCommand(rawMessage: string): ParsedPortalSlashCommand | null {
  const raw = String(rawMessage || '').trim();
  if (!raw.startsWith('/')) return null;
  const match = raw.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
  if (!match) return null;
  return {
    command: `/${match[1].toLowerCase()}`,
    raw,
    args: match[2] ? match[2].trim().split(/\s+/).filter(Boolean) : [],
    rest: match[2] ? match[2].trim() : '',
  };
}

function nextNativePortalMessageId(prefix: string): string {
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

function formatCommandCatalogText(commands: Awaited<ReturnType<typeof getProviderCommandCatalog>>): string {
  if (!commands.length) return 'No slash commands are exposed for this provider in the portal.';
  const grouped = new Map<string, typeof commands>();
  for (const entry of commands) {
    const category = entry.category || 'General';
    grouped.set(category, [...(grouped.get(category) || []), entry]);
  }
  return Array.from(grouped.entries())
    .map(([category, items]) => [
      `${category}:`,
      ...items.map((item) => `- ${item.command}${item.argsHint ? ` ${item.argsHint}` : ''} — ${item.description}`),
    ].join('\n'))
    .join('\n\n');
}

function describeModelSelectionMode(mode?: string): string {
  if (mode === 'launch') return 'per chat (new session needed after changes)';
  if (mode === 'session') return 'live in the current session';
  return 'not supported';
}

function describeModelCatalog(capabilities: any, models: Array<{ id: string }> = []): string {
  if (capabilities?.canEnumerateModels || models.length > 0) {
    if (capabilities?.modelCatalogKind === 'declared') return `declared catalog${models.length ? ` (${models.length} shown in portal)` : ''}`;
    return `runtime catalog${models.length ? ` (${models.length} shown in portal)` : ''}`;
  }
  return capabilities?.supportsCustomModelInput === false
    ? 'no model picker exposed'
    : 'manual model entry only (/model <id>)';
}

function formatProviderCapabilitySummary(params: {
  providerInfo: ReturnType<typeof AgentRegistry.listProviders>[number] | undefined;
  providerDisplayName: string;
  commandCount: number;
  models?: Array<{ id: string }>;
  sessionId?: string;
  currentModel?: string | null;
}): string {
  const providerInfo = params.providerInfo;
  const capabilities = (providerInfo?.capabilities ?? {}) as Partial<ReturnType<typeof AgentRegistry.listProviders>[number]['capabilities']>;
  const lines = [
    `${params.providerDisplayName} portal capabilities`,
    params.sessionId ? `- session: ${params.sessionId}` : null,
    `- provider: ${providerInfo?.name || params.providerDisplayName}`,
    params.currentModel !== undefined ? `- model: ${params.currentModel || 'default'}` : null,
    `- slash commands: ${params.commandCount || 0} exposed`,
    `- model switching: ${capabilities.supportsModelSelection ? describeModelSelectionMode(capabilities.modelSelectionMode) : 'not supported'}`,
    `- model catalog: ${describeModelCatalog(capabilities, params.models || [])}`,
    `- custom model input: ${capabilities.supportsCustomModelInput === false ? 'no' : 'yes'}`,
    `- session history: ${capabilities.supportsHistory ? 'yes' : 'no'}`,
    `- session list: ${capabilities.supportsSessionList ? 'yes' : 'no'}`,
    `- exec approvals: ${capabilities.supportsExecApproval ? 'yes' : 'no'}`,
    `- follow-ups while running: ${capabilities.supportsInTurnSteering ? 'live FYI / steer' : capabilities.supportsQueuedFollowUps === false ? 'not supported' : 'queued for next turn'}`,
    `- transport: ${capabilities.requiresGateway ? 'gateway' : 'native CLI'}`,
    `- adapter: ${capabilities.adapterFamily || 'unknown'}${capabilities.adapterKey ? ` (${capabilities.adapterKey})` : ''}`,
    `- installed: ${providerInfo?.installed ? 'yes' : 'no'}`,
    `- version: ${providerInfo?.version || 'unknown'}`,
  ].filter(Boolean);
  return lines.join('\n');
}

function formatCommandCatalogWithSummary(params: {
  providerInfo: ReturnType<typeof AgentRegistry.listProviders>[number] | undefined;
  providerDisplayName: string;
  commands: Awaited<ReturnType<typeof getProviderCommandCatalog>>;
  models?: Array<{ id: string }>;
  currentModel?: string | null;
  sessionId?: string;
}): string {
  const summary = formatProviderCapabilitySummary({
    providerInfo: params.providerInfo,
    providerDisplayName: params.providerDisplayName,
    commandCount: params.commands.length,
    models: params.models,
    currentModel: params.currentModel,
    sessionId: params.sessionId,
  });
  return `${summary}\n\n${formatCommandCatalogText(params.commands)}`;
}

async function handleNativePortalSlashCommand(params: {
  providerName: AgentProviderName;
  providerDisplayName: string;
  userId: string;
  userEmail: string;
  sessionId: string;
  requestedModel?: string;
  message: string;
}): Promise<{ handled: boolean; sessionId: string; content?: string; metadata?: Record<string, unknown> }> {
  const parsed = parsePortalSlashCommand(params.message);
  if (!parsed) return { handled: false, sessionId: params.sessionId };

  const session = loadNativeSession(params.providerName, params.sessionId);
  if (!session) {
    return { handled: true, sessionId: params.sessionId, content: `Error: Session not found (${params.sessionId})` };
  }

  const appendExchange = (content: string, metadata?: Record<string, unknown>) => {
    appendNativeMessage(session, {
      id: nextNativePortalMessageId('user'),
      role: 'user',
      content: parsed.raw,
      timestamp: new Date().toISOString(),
    });
    appendNativeMessage(session, {
      id: nextNativePortalMessageId('assistant'),
      role: 'assistant',
      content,
      timestamp: new Date().toISOString(),
    });
    return { handled: true, sessionId: session.sessionId, content, metadata };
  };

  switch (parsed.command) {
    case '/help':
    case '/commands': {
      const providerInfo = AgentRegistry.listProviders().find((entry) => entry.name === params.providerName);
      const [commands, models] = await Promise.all([
        getProviderCommandCatalog(params.providerName),
        listProviderModels(params.providerName),
      ]);
      return appendExchange(formatCommandCatalogWithSummary({
        providerInfo,
        providerDisplayName: params.providerDisplayName,
        commands,
        models,
        currentModel: session.model || null,
        sessionId: session.sessionId,
      }), { command: parsed.command, model: session.model || null, commandCount: commands.length });
    }
    case '/status': {
      const providerInfo = AgentRegistry.listProviders().find((entry) => entry.name === params.providerName);
      const [commands, models] = await Promise.all([
        getProviderCommandCatalog(params.providerName),
        listProviderModels(params.providerName),
      ]);
      return appendExchange(formatProviderCapabilitySummary({
        providerInfo,
        providerDisplayName: params.providerDisplayName,
        commandCount: commands.length,
        models,
        currentModel: session.model || null,
        sessionId: session.sessionId,
      }), { command: parsed.command, model: session.model || null, commandCount: commands.length });
    }
    case '/model': {
      if (!parsed.rest) {
        return appendExchange(`Current model: ${session.model || 'default'}`, { command: parsed.command, model: session.model || null });
      }
      const normalized = normalizeRequestedModel(params.providerName, parsed.rest);
      const updated = updateNativeSessionModel(params.providerName, session.sessionId, normalized);
      return appendExchange(`Model set to ${normalized}`, { command: parsed.command, model: updated?.model || normalized });
    }
    case '/models': {
      const targetProvider = (parsed.args[0] || params.providerName).trim().toUpperCase() as AgentProviderName;
      const providerInfo = AgentRegistry.listProviders().find((entry) => entry.name === targetProvider);
      if (!providerInfo) return appendExchange(`Unknown provider: ${targetProvider}`, { command: parsed.command });
      const models = await listProviderModels(targetProvider);
      const heading = `${providerInfo.displayName} models`;
      if (models.length > 0) {
        const lines = [heading, ...models.slice(0, 40).map((model) => `- ${model.id}${model.alias ? ` (${model.alias})` : ''}`)];
        return appendExchange(lines.join('\n'), { command: parsed.command, provider: targetProvider, count: models.length });
      }
      const fallback = providerInfo.capabilities.canEnumerateModels
        ? 'No models were returned from the current runtime.'
        : 'This provider does not expose a model catalog; enter a model id manually with /model <id>.';
      return appendExchange(`${heading}\n${fallback}`, { command: parsed.command, provider: targetProvider, count: 0 });
    }
    case '/new':
    case '/reset': {
      const newSessionId = await AgentRegistry.get(params.providerName).startSession(params.userId, {
        model: params.requestedModel ? normalizeRequestedModel(params.providerName, params.requestedModel) : session.model,
        metadata: { requestedBy: params.userEmail },
      });
      const content = `Started a new ${params.providerDisplayName} session.`;
      const newSession = loadNativeSession(params.providerName, newSessionId);
      if (newSession) {
        appendNativeMessage(newSession, {
          id: nextNativePortalMessageId('user'),
          role: 'user',
          content: parsed.raw,
          timestamp: new Date().toISOString(),
        });
        appendNativeMessage(newSession, {
          id: nextNativePortalMessageId('assistant'),
          role: 'assistant',
          content,
          timestamp: new Date().toISOString(),
        });
      }
      return { handled: true, sessionId: newSessionId, content, metadata: { command: parsed.command, reset: true } };
    }
    default:
      return { handled: false, sessionId: params.sessionId };
  }
}

router.get('/history', authenticateToken, async (req: Request, res: Response) => {
  try {
    const sessionKey = resolveOpenClawSessionKey(req.query.session as string, req.user);
    const limit = parseInt(req.query.limit as string) || 100;
    const afterId = req.query.after as string;
    const providerName = req.query.provider as AgentProviderName | undefined;
    const enhanced = req.query.enhanced === '1';

    assertGatewaySessionAccess(sessionKey, req.user!, { providerName });

    // For non-OPENCLAW providers use the provider abstraction
    if (providerName && providerName !== 'OPENCLAW') {
      try {
        const nativeSession = getOwnedNativeSession(providerName, req.user!.userId, req.query.session);
        if (!nativeSession) {
          res.json({ messages: [], sessionId: typeof req.query.session === 'string' ? req.query.session : '' });
          return;
        }
        const provider = AgentRegistry.get(providerName);
        const messages = await provider.getHistory(nativeSession.sessionId);
        res.json({ messages, sessionId: nativeSession.sessionId });
        return;
      } catch (err: any) {
        console.warn(`[gateway] Provider ${providerName} getHistory failed: ${err.message}`);
      }
    }

    // OPENCLAW (and default): resolve directly from JSONL, with Gemini CLI import fallback
    const sessionsDir = resolveSessionsDir(sessionKey);
    const fileId = resolveSessionFileId(sessionKey, sessionsDir);
    const sessionId = fileId || sessionKey;
    let messages = enhanced
      ? readSessionMessagesEnhancedForSessionKey(sessionKey, limit, sessionsDir)
      : await readSessionMessages(sessionId, limit, sessionsDir);

    if (!fileId && messages.length === 0) {
      res.json({ messages: [] });
      return;
    }

    if (afterId) {
      const idx = messages.findIndex((m: any) => m.id === afterId);
      if (idx >= 0) messages = messages.slice(idx + 1);
    }

    const activeStream = providerName === 'OPENCLAW'
      ? (() => {
          const info = streamEventBus.getStreamStatus(sessionKey);
          if (!info) return { active: false };
          const latestText = info.phase === 'streaming'
            ? (info.latestText || streamEventBus.getLatestText(sessionKey) || '')
            : '';
          return {
            active: true,
            phase: info.phase,
            toolName: info.toolName || null,
            toolCalls: Array.isArray(info.toolCalls) ? info.toolCalls : [],
            statusText: info.statusText || null,
            provenance: info.provenance || null,
            model: info.model || null,
            compactionPhase: info.compactionPhase || 'idle',
            content: latestText,
            runId: info.runId || null,
          };
        })()
      : null;

    res.json({ messages, sessionId, activeStream });
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to get history', detail: err.message });
  }
});

// POST /api/gateway/send — SSE streaming (kept as fallback)
router.post('/send', authenticateToken, requireApproved, async (req: Request, res: Response) => {
  const { message, session = 'main', provider: providerName, model: requestedModel, agentId } = req.body;
  if (!message) { res.status(400).json({ error: 'message required' }); return; }

  const wantStream = req.query.stream === '1' || req.headers.accept === 'text/event-stream';

  try {
    const provider = providerName
      ? AgentRegistry.get(providerName as AgentProviderName)
      : AgentRegistry.getDefault();
    const provenance = PROVENANCE[provider.providerName] || `via ${provider.displayName}`;

    const clientSession = typeof session === 'string' && session.trim().length > 0 ? session.trim() : '';
    let sessionId: string;
    if (provider.providerName === 'OPENCLAW') {
      const useCanonicalMainSession = isOwnerRole(req.user!.role)
        && (!agentId || agentId === 'main')
        && (!clientSession || clientSession === 'main');
      if (agentId && agentId !== 'main') {
        // Same sub-agent session resolution as the WS path
        const agentPrefix = `agent:${agentId}:`;
        if (clientSession.startsWith(agentPrefix)) {
          sessionId = clientSession;
        } else {
          let sessionName: string;
          if (!clientSession) {
            sessionName = 'main';
          } else if (clientSession.startsWith('new-')) {
            sessionName = `portal-${clientSession}`;
          } else if (clientSession.startsWith('agent:')) {
            const parts = clientSession.split(':');
            sessionName = parts.length >= 3 ? parts.slice(2).join(':') : 'main';
          } else {
            sessionName = clientSession;
          }
          sessionId = `agent:${agentId}:${sessionName}`;
        }
      } else if (clientSession.startsWith('agent:')) {
        sessionId = clientSession;
      } else if (useCanonicalMainSession) {
        sessionId = 'agent:main:main';
      } else {
        sessionId = await provider.startSession(req.user!.userId, {
          metadata: clientSession ? { sessionSlug: clientSession } : undefined,
        });
      }
    } else {
      const reusableNativeSession = getOwnedNativeSession(provider.providerName, req.user!.userId, clientSession);
      if (!reusableNativeSession) {
        sessionId = await provider.startSession(req.user!.userId, {
          model: typeof requestedModel === 'string' && requestedModel.trim() ? normalizeRequestedModel(provider.providerName, requestedModel.trim()) : undefined,
          metadata: { requestedBy: req.user!.email },
        });
      } else {
        sessionId = reusableNativeSession.sessionId;
        if (typeof requestedModel === 'string' && requestedModel.trim()) {
          updateNativeSessionModel(provider.providerName, sessionId, normalizeRequestedModel(provider.providerName, requestedModel.trim()));
        }
      }
    }

    assertGatewaySessionAccess(sessionId, req.user!, { providerName: provider.providerName });

    if (requestedModel && provider.providerName === 'OPENCLAW') {
      if (!String(requestedModel).includes('/')) {
        res.status(400).json({ error: 'model must include provider prefix' });
        return;
      }
      try {
        await patchSessionModel(sessionId, requestedModel);
      } catch (err: any) {
        console.warn(`[gateway] Failed to patch session model: ${err.message}`);
      }
    }

    if (provider.providerName !== 'OPENCLAW') {
      const slashResult = await handleNativePortalSlashCommand({
        providerName: provider.providerName,
        providerDisplayName: provider.displayName,
        userId: req.user!.userId,
        userEmail: req.user!.email,
        sessionId,
        requestedModel: typeof requestedModel === 'string' ? requestedModel : undefined,
        message,
      });
      if (slashResult.handled) {
        res.json({
          response: slashResult.content || '',
          model: normalizeGatewayModelId(slashResult.metadata?.model)
            || normalizeGatewayModelId(loadNativeSession(provider.providerName, slashResult.sessionId)?.model)
            || null,
          provider: provider.providerName,
          provenance,
          sessionId: slashResult.sessionId,
          metadata: slashResult.metadata || {},
        });
        return;
      }
    }

    if (wantStream) {
      res.socket?.setNoDelay?.(true);
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache, no-transform');
      res.setHeader('Connection', 'keep-alive');
      res.setHeader('X-Accel-Buffering', 'no');
      res.setHeader('X-Content-Type-Options', 'nosniff');
      res.flushHeaders();

      const sseWrite = (data: string) => {
        res.write(data);
        if (typeof (res as any).flush === 'function') (res as any).flush();
      };
      sseWrite(`data: ${JSON.stringify({ type: 'session', sessionId, provenance, model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || null })}\n\n`);

      let sseAlive = true;
      let sseFinished = false;
      let gotRealStatus = false;
      let streamUnsub: (() => void) | null = null;
      let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
      const keepaliveTimer = setInterval(() => { if (sseAlive) try { sseWrite(': keepalive\n\n'); } catch { sseAlive = false; } }, 15000);
      const finishSse = () => {
        if (sseFinished) return;
        sseFinished = true;
        sseAlive = false;
        if (fallbackTimer) {
          clearTimeout(fallbackTimer);
          fallbackTimer = null;
        }
        clearInterval(keepaliveTimer);
        if (streamUnsub) {
          streamUnsub();
          streamUnsub = null;
        }
        try { sseWrite('data: [DONE]\n\n'); } catch {}
        res.end();
      };
      req.on('close', () => {
        sseAlive = false;
        if (fallbackTimer) {
          clearTimeout(fallbackTimer);
          fallbackTimer = null;
        }
        clearInterval(keepaliveTimer);
        if (streamUnsub) {
          streamUnsub();
          streamUnsub = null;
        }
      });

      fallbackTimer = setTimeout(() => {
        if (!gotRealStatus && sseAlive) try { sseWrite(`data: ${JSON.stringify({ type: 'status', content: `${provider.displayName} is thinking…` })}\n\n`); } catch { sseAlive = false; }
      }, 2000);

      const senderIdentity = req.user ? { label: req.user.email, userId: req.user.userId } : undefined;
      const streamStartedAtMs = Date.now();

      if (provider.providerName === 'OPENCLAW') {
        // Single-path SSE delivery for OpenClaw: the persistent gateway WS publishes
        // into StreamEventBus, and this SSE response forwards that bus directly.
        // Do not re-publish provider callbacks back into StreamEventBus, or the
        // HTTP send path will recursively amplify the same events.
        streamEventBus.startStream(sessionId, undefined, {
          provenance,
          model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
        });

        let sawTerminalEvent = false;
        let pendingStreamError: string | null = null;
        const deniedApprovalIds = new Set<string>();
        streamUnsub = streamEventBus.subscribe(sessionId, (evt: StreamEvent) => {
          if (!sseAlive || sseFinished) return;
          gotRealStatus = true;
          const runtimeEvt = evt as any;
          if (runtimeEvt.type === 'exec_approval' && runtimeEvt.approval) {
            const approval = runtimeEvt.approval as ExecApprovalRequest;
            if (!isElevatedRole(req.user?.role)) {
              if (approval?.id && !deniedApprovalIds.has(approval.id)) {
                deniedApprovalIds.add(approval.id);
                void denyExecApprovalForUnauthorizedUser(approval, req.user);
              }
              try {
                sseWrite(`data: ${JSON.stringify({
                  type: 'status',
                  content: 'Command approval is only available to portal admins. This request was denied automatically.',
                })}\n\n`);
              } catch {
                sseAlive = false;
              }
              return;
            }
          }
          if (evt.type === 'error') {
            pendingStreamError = typeof evt.content === 'string' && evt.content.trim()
              ? evt.content.trim()
              : 'Agent error';
            return;
          }
          try {
            sseWrite(`data: ${JSON.stringify(evt)}\n\n`);
          } catch {
            sseAlive = false;
            return;
          }
          if (evt.type === 'done') {
            sawTerminalEvent = true;
            finishSse();
          }
        });

        try {
          const result = await (provider as any).sendMessage(
            sessionId,
            message,
            (_chunk: string) => {},
            (_evt: { type: string; content?: string; [key: string]: any }) => {},
            (_approval: ExecApprovalRequest) => {},
            senderIdentity,
          );
          if (!sawTerminalEvent && sseAlive && !sseFinished) {
            sseWrite(`data: ${JSON.stringify({
              type: 'done',
              content: result.fullText,
              provenance,
              model: normalizeGatewayModelId(result.metadata?.model) || null,
              metadata: result.metadata || {},
            })}\n\n`);
            finishSse();
          }
        } catch (err: any) {
          const friendlyError = humanizeProviderError(provider.providerName, err?.message || String(err));
          const shouldAttemptRecovery = Boolean(pendingStreamError || (typeof requestedModel === 'string' && requestedModel.trim()));
          if (shouldAttemptRecovery) {
            const recovered = await recoverRecentOpenClawAssistantReply(sessionId, streamStartedAtMs);
            if (recovered && !sseFinished && sseAlive) {
              try {
                sseWrite(`data: ${JSON.stringify({
                  type: 'done',
                  content: recovered.content,
                  provenance,
                  model: recovered.model,
                  metadata: { recoveredAfterError: true },
                })}\n\n`);
              } catch {}
              finishSse();
              return;
            }
          }
          if (!sseFinished && sseAlive) {
            try { sseWrite(`data: ${JSON.stringify({ type: 'error', content: pendingStreamError || friendlyError })}\n\n`); } catch {}
          }
          finishSse();
        }
        return;
      }

      const onStatus = (evt: { type: string; content: string; [key: string]: any }) => {
        gotRealStatus = true;
        const runId = typeof evt.runId === 'string' ? evt.runId : undefined;
        if (evt.type === 'tool_start') {
          streamEventBus.startStream(sessionId, runId, {
            provenance,
            model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
          });
          streamEventBus.updateStreamPhase(sessionId, { phase: 'tool', toolName: typeof evt.toolName === 'string' ? evt.toolName : undefined, runId });
        } else if (evt.type === 'tool_end') {
          streamEventBus.startStream(sessionId, runId, {
            provenance,
            model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
          });
          streamEventBus.updateStreamPhase(sessionId, { phase: 'thinking', runId });
        } else if (evt.type === 'thinking' || evt.type === 'status') {
          streamEventBus.startStream(sessionId, runId, {
            provenance,
            model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
          });
          streamEventBus.updateStreamPhase(sessionId, { phase: 'thinking', runId });
        } else if (evt.type === 'run_resumed') {
          streamEventBus.startStream(sessionId, runId, {
            provenance,
            model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
          });
        }
        streamEventBus.publish(sessionId, evt as StreamEvent);
        if (sseAlive) try { sseWrite(`data: ${JSON.stringify(evt)}\n\n`); } catch { sseAlive = false; }
      };
      const onExecApproval = (approval: ExecApprovalRequest) => {
        if (sseAlive) try { sseWrite(`data: ${JSON.stringify({ type: 'exec_approval', approval })}\n\n`); } catch { sseAlive = false; }
      };
      streamEventBus.startStream(sessionId, undefined, {
        provenance,
        model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
      });

      try {
        const result = await (provider as any).sendMessage(
          sessionId,
          message,
          (chunk: string) => {
            streamEventBus.startStream(sessionId, undefined, {
              provenance,
              model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
            });
            streamEventBus.updateStreamPhase(sessionId, { phase: 'streaming' });
            streamEventBus.publish(sessionId, { type: 'text', content: chunk } as StreamEvent);
            if (sseAlive) try { sseWrite(`data: ${JSON.stringify({ type: 'text', content: chunk })}\n\n`); } catch { sseAlive = false; }
          },
          onStatus,
          onExecApproval,
          senderIdentity,
        );
        if (fallbackTimer) {
          clearTimeout(fallbackTimer);
          fallbackTimer = null;
        }
        clearInterval(keepaliveTimer);
        streamEventBus.publish(sessionId, {
          type: 'done',
          content: result.fullText,
          model: normalizeGatewayModelId(result.metadata?.model) || null,
        } as StreamEvent);
        streamEventBus.softClearStream(sessionId);
        if (sseAlive) {
          sseWrite(`data: ${JSON.stringify({
            type: 'done',
            content: result.fullText,
            provenance,
            model: normalizeGatewayModelId(result.metadata?.model) || null,
            metadata: result.metadata || {},
          })}\n\n`);
        }
        finishSse();
      } catch (err: any) {
        if (fallbackTimer) {
          clearTimeout(fallbackTimer);
          fallbackTimer = null;
        }
        clearInterval(keepaliveTimer);
        const friendlyError = humanizeProviderError(provider.providerName, err?.message || String(err));
        streamEventBus.publish(sessionId, { type: 'error', content: friendlyError } as StreamEvent);
        streamEventBus.clearStream(sessionId);
        if (sseAlive) {
          sseWrite(`data: ${JSON.stringify({ type: 'error', content: friendlyError })}\n\n`);
        }
        finishSse();
      }
      return;
    }

    // Non-streaming
    const senderIdentity = req.user ? { label: req.user.email, userId: req.user.userId } : undefined;
    const nonStreamingStartedAtMs = Date.now();
    try {
      const result = await (provider as any).sendMessage(sessionId, message, undefined, undefined, undefined, senderIdentity);
      const resolvedSessionId = typeof result?.metadata?.resolvedSessionId === 'string' && result.metadata.resolvedSessionId.trim()
        ? result.metadata.resolvedSessionId.trim()
        : sessionId;
      res.json({
        response: result.fullText,
        model: normalizeGatewayModelId(result.metadata?.model) || null,
        provider: provider.providerName,
        provenance,
        sessionId: resolvedSessionId,
      });
      return;
    } catch (sendErr: any) {
      if (provider.providerName === 'OPENCLAW' && typeof requestedModel === 'string' && requestedModel.trim()) {
        const recovered = await recoverRecentOpenClawAssistantReply(sessionId, nonStreamingStartedAtMs);
        if (recovered) {
          res.json({
            response: recovered.content,
            model: recovered.model,
            provider: provider.providerName,
            provenance,
            sessionId,
            recoveredAfterError: true,
          });
          return;
        }
      }
      throw sendErr;
    }
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 503;
    const friendlyError = status === 403
      ? 'Admin access required'
      : humanizeProviderError(normalizeProviderName(req.body?.provider), err?.message || String(err));
    res.status(status).json({ error: friendlyError, detail: err?.message || String(err) });
  }
});

/* GET /api/gateway/agents — list OpenClaw sub-agents */
router.get('/agents', authenticateToken, requireAdmin, async (_req: Request, res: Response) => {
  try {
    const raw = await listOpenClawAgentsForSelector();

    // Fetch sub-agent avatars from DB
    let subAgentAvatarMap: Record<string, string> = {};
    try {
      const { prisma } = await import('../config/database');
      const rows = await prisma.systemSetting.findMany({
        where: { key: { startsWith: 'appearance.subAgentAvatar.' } },
      });
      for (const row of rows) {
        const agentId = row.key.replace('appearance.subAgentAvatar.', '');
        if (agentId && row.value) subAgentAvatarMap[agentId] = row.value;
      }
    } catch {}

    // Filter out hidden/confusing OpenClaw agents from the portal selector.
    const hiddenAgentIds = new Set(['portal', 'opus', 'codex', 'claude', 'desktop']);
    const agents = raw
      .filter((a: any) => {
        const id = String(a.id || a.name || '');
        if (!id) return false;
        if (id.startsWith('portal-')) return false;
        if (hiddenAgentIds.has(id)) return false;
        return true;
      })
      .map((a: any) => {
        // identity may be an object { emoji, name } or a string
        const identity = typeof a.identity === 'object' && a.identity
          ? a.identity.emoji || undefined
          : (a.identity || a.emoji || undefined);
        const id = a.id || a.name;
        return {
          id,
          name: a.name || undefined,
          identity,
          model: normalizeGatewayModelId(a.model ?? a.defaultModel ?? a.currentModel),
          workspace: a.workspace || undefined,
          avatarUrl: subAgentAvatarMap[id] || undefined,
        };
      });
    res.json({ agents });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to list agents', detail: err.message });
  }
});

// GET /api/gateway/stream-status — check if a stream is active for a session
router.get('/stream-status', authenticateToken, async (req: Request, res: Response) => {
  const sessionKey = resolveOpenClawSessionKey(req.query.session as string, req.user);
  try {
    assertGatewaySessionAccess(sessionKey, req.user!);
  } catch (err: any) {
    res.status(403).json({ error: 'Admin access required', detail: err.message });
    return;
  }
  if (!sessionKey) {
    res.json({ active: false });
    return;
  }
  const info = streamEventBus.getStreamStatus(sessionKey);
  if (info) {
    // Long-running tool/research/codegen work can legitimately go quiet for a while.
    // Keep tool/maintenance snapshots alive much longer than text streaming so the UI
    // does not hide the rail mid-run just because the agent is busy.
    const lastEvent = (info as any).lastEventAt || info.startedAt;
    const hasRunningTool = Array.isArray(info.toolCalls) && info.toolCalls.some((toolCall: any) => toolCall?.status === 'running');
    const staleCutoffMs = info.phase === 'streaming'
      ? 180_000
      : (hasRunningTool || info.phase === 'tool' || info.compactionPhase === 'compacting')
        ? 15 * 60_000
        : 10 * 60_000;
    if (lastEvent && (Date.now() - lastEvent) > staleCutoffMs) {
      debugLog(`[stream-status] StreamEventBus has entry but lastEvent=${new Date(lastEvent).toISOString()} exceeded cutoff=${staleCutoffMs}ms for phase=${info.phase} — reporting inactive`);
      streamEventBus.clearStream(sessionKey);
      res.json({ active: false });
      return;
    }
    const content = info.phase === 'streaming'
      ? streamEventBus.getLatestText(sessionKey)
      : '';
    res.json({
      active: true,
      phase: info.phase,
      toolName: info.toolName || null,
      toolCalls: Array.isArray(info.toolCalls) ? info.toolCalls : [],
      statusText: info.statusText || null,
      provenance: info.provenance || null,
      model: info.model || null,
      compactionPhase: info.compactionPhase || 'idle',
      startedAt: info.startedAt,
      runId: info.runId || null,
      content: content || undefined,
      lastEventAt: lastEvent,
      staleAfterMs: staleCutoffMs,
    });
  } else {
    // StreamEventBus has no active stream — but the gateway might still be running
    // (e.g., after backend restart when PersistentGatewayWs hasn't forwarded events yet).
    // Query the gateway's session state as a fallback.
    // GUARD: only trust the gateway's chatState if there's been recent activity.
    // Stale chatState (from interrupted turns) causes the UI to show "thinking..." forever.
    try {
      const sessResult = await getSessionInfo(sessionKey);
      if (sessResult.ok && sessResult.data) {
        const sess = sessResult.data;
        const chatState = typeof sess.chatState === 'string' ? sess.chatState : '';
        if (chatState === 'streaming' || chatState === 'thinking' || chatState === 'tool') {
          // This fallback is only used when StreamEventBus has no live snapshot, for example
          // after a browser refresh while direct-gateway streaming is in progress. Be conservative:
          // stale gateway chatState otherwise leaves the UI stuck in a fake running state.
          const lastActivity = typeof sess.lastActivity === 'number' ? sess.lastActivity : 0;
          const staleCutoff = Date.now() - 30_000; // 30s
          if (lastActivity && lastActivity < staleCutoff) {
            debugLog(`[stream-status] Gateway reports chatState=${chatState} but lastActivity=${new Date(lastActivity).toISOString()} is stale for fallback mode — reporting inactive`);
            res.json({ active: false });
            return;
          }
          const latestMarker = getLatestMeaningfulConversationMarker(readSessionMessagesEnhancedForSessionKey(sessionKey, 20));
          if (latestMarker?.role === 'assistant' && (!lastActivity || latestMarker.timestamp >= lastActivity - 30_000)) {
            debugLog(`[stream-status] Gateway reports chatState=${chatState}, but latest durable meaningful message is assistant at ${new Date(latestMarker.timestamp).toISOString()} — reporting inactive`);
            res.json({ active: false });
            return;
          }
          debugLog(`[stream-status] StreamEventBus empty but gateway reports chatState=${chatState} with recent activity and latest durable turn is not assistant — reporting active`);
          res.json({
            active: true,
            phase: chatState === 'tool' ? 'tool' : chatState === 'streaming' ? 'streaming' : 'thinking',
            toolName: null,
            startedAt: lastActivity || Date.now(),
            lastEventAt: lastActivity || undefined,
            runId: typeof sess.runId === 'string' ? sess.runId : null,
          });
          return;
        }
      }
    } catch {
      // Gateway RPC failed — fall through to inactive
    }
    res.json({ active: false });
  }
});

router.post('/chat/abort', authenticateToken, requireApproved, async (req: Request, res: Response): Promise<void> => {
  const { session, runId } = req.body;
  const providerName = normalizeProviderName(req.body?.provider);
  const sessionKey = providerName === 'OPENCLAW'
    ? resolveOpenClawSessionKey(session, req.user)
    : String(session || '').trim();
  console.log(`[gateway] HTTP ABORT REQUEST: provider=${providerName} session=${sessionKey} runId=${runId || 'none'}`);
  try {
    assertGatewaySessionAccess(sessionKey, req.user!, { providerName });

    if (providerName !== 'OPENCLAW') {
      const provider = AgentRegistry.get(providerName);
      const aborted = await provider.abortActiveRun?.(sessionKey);
      res.json({ ok: aborted !== false, sessionKey, provider: providerName });
      return;
    }

    const payload: Record<string, string> = { sessionKey };
    if (runId) payload.runId = runId;
    const result = await gatewayRpcCall('chat.abort', payload);
    console.log(`[gateway] HTTP ABORT RESULT: ok=${result.ok} error=${result.error || 'none'}`);
    if (!result.ok) { res.status(500).json({ error: 'Abort failed', detail: result.error }); return; }
    streamEventBus.clearStream(sessionKey);
    res.json({ ok: true, sessionKey, provider: providerName });
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to abort', detail: err.message });
  }
});


router.post('/chat/inject', authenticateToken, requireApproved, async (req: Request, res: Response): Promise<void> => {
  const sessionKey = resolveOpenClawSessionKey(req.body?.session, req.user);
  const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
  if (!isElevatedRole(req.user!.role)) {
    res.status(403).json({ error: 'Admin access required' });
    return;
  }
  if (!text) {
    res.status(400).json({ error: 'text required' });
    return;
  }
  try {
    assertGatewaySessionAccess(sessionKey, req.user!);
    await injectChatMessage(sessionKey, text);
    res.json({ ok: true, sessionKey });
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to inject chat message', detail: err.message });
  }
});

router.post('/session-steer', authenticateToken, requireApproved, async (req: Request, res: Response): Promise<void> => {
  const sessionKey = resolveOpenClawSessionKey(req.body?.session, req.user);
  const message = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
  if (!message) {
    res.status(400).json({ error: 'message required' });
    return;
  }
  try {
    assertGatewaySessionAccess(sessionKey, req.user!);
    const result = await steerSessionMessage(sessionKey, message);
    res.json({ ok: true, sessionKey, ...result });
  } catch (err: any) {
    const status = err?.message === 'Admin access required' ? 403 : 500;
    res.status(status).json({ error: status === 403 ? 'Admin access required' : 'Failed to steer session', detail: err.message });
  }
});

router.post('/exec-approval/resolve', authenticateToken, requireAdmin, async (req: Request, res: Response): Promise<void> => {
  const { approvalId, decision } = req.body;
  if (!approvalId || typeof approvalId !== 'string') { res.status(400).json({ error: 'Missing approvalId' }); return; }
  if (!decision || !['allow-once', 'deny', 'allow-always'].includes(decision)) { res.status(400).json({ error: 'Invalid decision' }); return; }
  try {
    if (isPersistentWsConnected()) {
      const result = await sendApprovalDecision(approvalId, decision);
      if (result.ok) { res.json({ ok: true, approvalId, decision }); return; }
      console.warn(`[gateway] Persistent WS resolution failed: ${result.error}`);
    }
    const result = await resolveExecApproval(approvalId, decision);
    if (!result.ok) { res.status(404).json({ error: result.error || 'Failed' }); return; }
    res.json({ ok: true, approvalId, decision });
  } catch (err: any) {
    res.status(500).json({ error: 'Internal error', detail: err.message });
  }
});

// GET /api/gateway/approvals/stream — SSE for exec approval events (kept as fallback)
router.get('/approvals/stream', authenticateToken, requireAdmin, (req: Request, res: Response) => {
  res.socket?.setNoDelay?.(true);
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.flushHeaders();

  let alive = true;
  const sseWrite = (data: string) => { if (!alive) return; try { res.write(data); if (typeof (res as any).flush === 'function') (res as any).flush(); } catch { alive = false; } };
  sseWrite(`data: ${JSON.stringify({ type: 'connected', persistentWsConnected: isPersistentWsConnected() })}\n\n`);
  const keepaliveTimer = setInterval(() => { if (alive) sseWrite(': keepalive\n\n'); }, 15000);
  const unsubReq = onApprovalRequest((a: PersistentApprovalRequest) => sseWrite(`data: ${JSON.stringify({ type: 'exec_approval_requested', approval: a })}\n\n`));
  const unsubRes = onApprovalResolved((r: ExecApprovalResolved) => sseWrite(`data: ${JSON.stringify({ type: 'exec_approval_resolved', resolved: r })}\n\n`));
  req.on('close', () => { alive = false; clearInterval(keepaliveTimer); unsubReq(); unsubRes(); });
});


/* ═══════════════════════════════════════════════════════════════════════════
 * BROWSER ↔ PORTAL PERSISTENT WEBSOCKET
 * ═══════════════════════════════════════════════════════════════════════════ */

function parseCookiesWs(cookieHeader: string): Record<string, string> {
  const cookies: Record<string, string> = {};
  if (!cookieHeader) return cookies;
  cookieHeader.split(';').forEach(pair => {
    const idx = pair.indexOf('=');
    if (idx > 0) {
      cookies[pair.substring(0, idx).trim()] = decodeURIComponent(pair.substring(idx + 1).trim());
    }
  });
  return cookies;
}

// Track active portal WS clients for approval broadcasting
const portalWsClients: Set<WebSocket> = new Set();

// Broadcast approval events to all portal WS clients
let approvalBroadcastInit = false;
function initApprovalWsBroadcast() {
  if (approvalBroadcastInit) return;
  approvalBroadcastInit = true;
  onApprovalRequest((approval: PersistentApprovalRequest) => {
    const msg = JSON.stringify({ type: 'exec_approval', approval });
    const sessionKey = approval?.request?.sessionKey;
    for (const c of portalWsClients) {
      if (c.readyState !== WebSocket.OPEN) continue;
      const user = (c as any).__portalUser as JwtPayload | undefined;
      if (!user || !isElevatedRole(user.role)) continue;
      if (sessionKey) {
        try {
          assertGatewaySessionAccess(sessionKey, user);
        } catch {
          continue;
        }
      }
      try { c.send(msg); } catch {}
    }
  });
  onApprovalResolved((resolved: ExecApprovalResolved) => {
    const msg = JSON.stringify({ type: 'exec_approval_resolved', resolved });
    for (const c of portalWsClients) {
      if (c.readyState === WebSocket.OPEN) try { c.send(msg); } catch {}
    }
  });
}

function wsSend(ws: WebSocket, data: any) {
  if (ws.readyState === WebSocket.OPEN) try { ws.send(JSON.stringify(data)); } catch {}
}

/* ─── Active stream tracking (via StreamEventBus) ─────────────────────── */
// Stream status is now managed by StreamEventBus (populated by PersistentGatewayWs).
// The per-message WS in handleWsSend also updates it for consistency.

/* ─── WS message handlers ─────────────────────────────────────────────── */

// Per-WS stream cleanup: maps sessionKey → unsubscribe function.
// Used by handleWsAbort to tear down the stream when the user aborts.
const wsStreamCleanups = new WeakMap<WebSocket, Map<string, () => void>>();

function registerWsStreamCleanup(ws: WebSocket, sessionKey: string, unsub: () => void): void {
  let map = wsStreamCleanups.get(ws);
  if (!map) { map = new Map(); wsStreamCleanups.set(ws, map); }
  // If there's an existing subscription for this session, clean it up first
  const existing = map.get(sessionKey);
  if (existing) existing();
  map.set(sessionKey, unsub);
}

function runWsStreamCleanup(ws: WebSocket, sessionKey: string): void {
  const map = wsStreamCleanups.get(ws);
  if (!map) return;
  const unsub = map.get(sessionKey);
  if (unsub) { unsub(); map.delete(sessionKey); }
}

function subscribeBackendToLiveSessionEvents(sessionKey: string): void {
  const key = typeof sessionKey === 'string' ? sessionKey.trim() : '';
  if (!key) return;
  void subscribeGatewaySessionMessages(key).catch((err: any) => {
    debugLog(`[gateway-ws] live session-message subscribe failed for ${key}: ${err?.message || err}`);
  });
}

function attachBrowserWsToSessionStream(params: {
  ws: WebSocket;
  sessionKey: string;
  streamInfo?: ReturnType<typeof streamEventBus.getTrackedStream> | null;
  sendResume?: boolean;
  keepSubscriptionAfterDone?: boolean;
  onEvent?: (evt: StreamEvent) => void;
  shouldForwardEvent?: (evt: StreamEvent) => boolean;
}): boolean {
  const {
    ws,
    sessionKey,
    streamInfo,
    sendResume = false,
    keepSubscriptionAfterDone = true,
    onEvent,
    shouldForwardEvent,
  } = params;

  const status = streamInfo ?? streamEventBus.getTrackedStream(sessionKey);
  if (!status) return false;

  if (sendResume) {
    if (status.active) {
      const latestText = status.phase === 'streaming'
        ? streamEventBus.getLatestText(sessionKey)
        : '';
      wsSend(ws, {
        type: 'stream_resume',
        sessionKey,
        phase: status.phase,
        toolName: status.toolName || null,
        toolCalls: Array.isArray(status.toolCalls) ? status.toolCalls : [],
        statusText: status.statusText || null,
        provenance: status.provenance || null,
        model: status.model || null,
        compactionPhase: status.compactionPhase || 'idle',
        runId: status.runId || null,
        content: latestText || undefined,
      });
    } else {
      wsSend(ws, { type: 'stream_ended', sessionKey });
    }
  }

  let unsubscribed = false;
  const unsub = streamEventBus.subscribe(sessionKey, (evt: StreamEvent) => {
    onEvent?.(evt);
    if (shouldForwardEvent?.(evt) === false) {
      return;
    }
    const activeStream = streamEventBus.getTrackedStream(sessionKey);
    const runId = typeof evt.runId === 'string' && evt.runId.trim()
      ? evt.runId.trim()
      : (typeof activeStream?.runId === 'string' && activeStream.runId.trim() ? activeStream.runId.trim() : undefined);
    wsSend(ws, { ...evt, sessionKey, ...(runId ? { runId } : {}) });
    if (evt.type === 'error') {
      runWsStreamCleanup(ws, sessionKey);
      return;
    }
    if (evt.type === 'done' && !keepSubscriptionAfterDone) {
      runWsStreamCleanup(ws, sessionKey);
    }
  });

  const onClose = () => runWsStreamCleanup(ws, sessionKey);
  const cleanup = () => {
    if (unsubscribed) return;
    unsubscribed = true;
    ws.removeListener('close', onClose);
    unsub();
  };

  ws.once('close', onClose);
  registerWsStreamCleanup(ws, sessionKey, cleanup);
  return true;
}

async function handleWsHistory(ws: WebSocket, msg: any, user: JwtPayload) {
  const sessionKey = resolveOpenClawSessionKey(msg.session, user);
  const providerName = msg.provider as AgentProviderName | undefined;
  const requestId = msg.requestId; // For client-side correlation

  try {
    assertGatewaySessionAccess(sessionKey, user, { providerName });
    if (!providerName || providerName === 'OPENCLAW') subscribeBackendToLiveSessionEvents(sessionKey);
    // Try provider abstraction for non-OpenClaw providers only.
    if (providerName && providerName !== 'OPENCLAW') {
      try {
        const provider = AgentRegistry.get(providerName);
        const messages = await provider.getHistory(sessionKey);
        wsSend(ws, { type: 'history', messages, sessionId: sessionKey, requestId });
        return;
      } catch {}
    }

    // JSONL-based enhanced history with Gemini CLI import fallback
    const sessionsDir = resolveSessionsDir(sessionKey);
    const fileId = resolveSessionFileId(sessionKey, sessionsDir);
    const sessionId = fileId || sessionKey;
    const messages = readSessionMessagesEnhancedForSessionKey(sessionKey, msg.limit || 200, sessionsDir);
    wsSend(ws, { type: 'history', messages, sessionId, requestId });

    // After sending history, check if there's an active stream on this session.
    // If so, send a stream_resume event and subscribe to StreamEventBus.
    attachBrowserWsToSessionStream({
      ws,
      sessionKey,
      sendResume: true,
      // Keep subscription alive after done so resumed runs continue forwarding.
      keepSubscriptionAfterDone: true,
    });
  } catch (err: any) {
    wsSend(ws, { type: 'error', content: err?.message === 'Admin access required' ? 'Admin access required' : `History failed: ${err.message}`, requestId });
  }
}

async function handleWsSend(ws: WebSocket, msg: any, user: JwtPayload) {
  const { message, session = 'main', provider: providerName, model: requestedModel, agentId } = msg;
  if (!message) { wsSend(ws, { type: 'error', content: 'message required' }); return; }

  let streamKeepalive: ReturnType<typeof setInterval> | null = null;
  let resolvedSessionId: string | null = null;
  let providerNameForError: AgentProviderName = normalizeProviderName(providerName);

  try {
    const provider = providerName
      ? AgentRegistry.get(providerName as AgentProviderName)
      : AgentRegistry.getDefault();
    providerNameForError = provider.providerName;
    const provenance = PROVENANCE[provider.providerName] || `via ${provider.displayName}`;
    const isOpenClawProvider = provider.providerName === 'OPENCLAW';

    const clientSession = typeof session === 'string' && session.trim().length > 0 ? session.trim() : '';
    let sessionId: string;
    if (isOpenClawProvider) {
      const useCanonicalMainSession = isOwnerRole(user.role)
        && (!agentId || agentId === 'main')
        && (!clientSession || clientSession === 'main');
      if (agentId && agentId !== 'main') {
        // If clientSession is already a fully-qualified agent: key for this agent, reuse it directly.
        // This prevents the cascading session bug where e.g. 'agent:parity:main' gets wrapped
        // into 'agent:parity:agent:parity:main' on subsequent messages.
        const agentPrefix = `agent:${agentId}:`;
        if (clientSession.startsWith(agentPrefix)) {
          sessionId = clientSession;
        } else {
          // Extract just the session name, stripping any stale agent: prefix from a different agent
          let sessionName: string;
          if (!clientSession) {
            sessionName = 'main';
          } else if (clientSession.startsWith('new-')) {
            sessionName = `portal-${clientSession}`;
          } else if (clientSession.startsWith('agent:')) {
            // Session key from a different agent — extract the trailing name part
            const parts = clientSession.split(':');
            sessionName = parts.length >= 3 ? parts.slice(2).join(':') : 'main';
          } else {
            sessionName = clientSession;
          }
          sessionId = `agent:${agentId}:${sessionName}`;
        }
      } else if (clientSession.startsWith('agent:')) {
        sessionId = clientSession;
      } else if (useCanonicalMainSession) {
        sessionId = 'agent:main:main';
      } else {
        sessionId = await provider.startSession(user.userId, {
          metadata: clientSession ? { sessionSlug: clientSession } : undefined,
        });
      }
    } else {
      const reusableNativeSession = getOwnedNativeSession(provider.providerName, user.userId, clientSession);
      if (!reusableNativeSession) {
        sessionId = await provider.startSession(user.userId, {
          model: typeof requestedModel === 'string' && requestedModel.trim() ? normalizeRequestedModel(provider.providerName, requestedModel.trim()) : undefined,
          metadata: { requestedBy: user.email },
        });
      } else {
        sessionId = reusableNativeSession.sessionId;
        if (typeof requestedModel === 'string' && requestedModel.trim()) {
          updateNativeSessionModel(provider.providerName, sessionId, normalizeRequestedModel(provider.providerName, requestedModel.trim()));
        }
      }
    }

    assertGatewaySessionAccess(sessionId, user, { providerName: provider.providerName });

    if (requestedModel && isOpenClawProvider) {
      try {
        if (!requestedModel.includes('/')) {
          console.warn(`[gateway-ws] Rejecting bare model name without provider prefix: "${requestedModel}". Select a fully-qualified model ID.`);
          wsSend(ws, { type: 'error', content: `Invalid model "${requestedModel}": must include provider prefix (e.g. openai-codex/gpt-5.4). Please reselect your model.` });
          return;
        }
        await patchSessionModel(sessionId, requestedModel);
      } catch (err: any) {
        console.warn(`[gateway-ws] Failed to patch model: ${err.message}`);
      }
    }

    if (!isOpenClawProvider) {
      const slashResult = await handleNativePortalSlashCommand({
        providerName: provider.providerName,
        providerDisplayName: provider.displayName,
        userId: user.userId,
        userEmail: user.email,
        sessionId,
        requestedModel: typeof requestedModel === 'string' ? requestedModel : undefined,
        message,
      });
      if (slashResult.handled) {
        resolvedSessionId = slashResult.sessionId;
        wsSend(ws, {
          type: 'session',
          sessionId: slashResult.sessionId,
          provenance,
          model: normalizeGatewayModelId(slashResult.metadata?.model)
            || normalizeGatewayModelId(loadNativeSession(provider.providerName, slashResult.sessionId)?.model)
            || null,
        });
        wsSend(ws, {
          type: 'done',
          content: slashResult.content || '',
          provenance,
          model: normalizeGatewayModelId(slashResult.metadata?.model)
            || normalizeGatewayModelId(loadNativeSession(provider.providerName, slashResult.sessionId)?.model)
            || null,
          metadata: slashResult.metadata || {},
        });
        return;
      }
    }

    resolvedSessionId = sessionId;
    wsSend(ws, {
      type: 'session',
      sessionId,
      provenance,
      model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || null,
    });

    let gotRealStatus = false;
    const fallbackTimer = setTimeout(() => {
      if (!gotRealStatus) wsSend(ws, { type: 'status', content: `${provider.displayName} is thinking…` });
    }, 2000);

    streamKeepalive = setInterval(() => {
      wsSend(ws, { type: 'keepalive', ts: Date.now() });
    }, 10000);

    if (isOpenClawProvider) {
      subscribeBackendToLiveSessionEvents(sessionId);
      // ── Single-path streaming via StreamEventBus ──────────────────────
      // OpenClawProvider.sendMessage() sends chat.send via the persistent WS
      // and internally subscribes to StreamEventBus for its own resolution.
      //
      // We subscribe to the SAME StreamEventBus to forward events to the browser.
      // To avoid double-processing, the provider's internal subscription fires
      // callbacks (onChunk/onStatus) which we intentionally leave as no-ops here.
      // The bus subscription below is the SOLE path to the browser.
      //
      // The provider's sendMessage() returns when the bus publishes 'done' or 'error'.
      // Our subscription below also sees the same 'done'/'error' and cleans up.
      // Because both are subscribing to the SAME event, the order doesn't matter —
      // the 'done' event is emitted once from PersistentGatewayWs, and both
      // subscribers see it in the same publish() call.

      streamEventBus.startStream(sessionId, undefined, {
        provenance,
        model: normalizeGatewayModelId(typeof requestedModel === 'string' ? requestedModel : '') || undefined,
      });

      // Subscribe to StreamEventBus for this session.
      // IMPORTANT: We do NOT unsubscribe on 'done'. The agent may yield to a
      // sub-agent (sessions_yield), which causes a chat.final → 'done', but then
      // the agent resumes with a NEW runId when the sub-agent completes. If we
      // unsubscribe on 'done', the resumed run's events are never forwarded.
      // The subscription stays alive until the browser WS closes.
      const deniedApprovalIds = new Set<string>();
      attachBrowserWsToSessionStream({
        ws,
        sessionKey: sessionId,
        keepSubscriptionAfterDone: true,
        onEvent: (evt: StreamEvent) => {
        gotRealStatus = true;
        const runtimeEvt = evt as any;
        if (runtimeEvt.type === 'exec_approval' && runtimeEvt.approval && !isElevatedRole(user.role)) {
          const approval = runtimeEvt.approval as ExecApprovalRequest;
          if (approval?.id && !deniedApprovalIds.has(approval.id)) {
            deniedApprovalIds.add(approval.id);
            void denyExecApprovalForUnauthorizedUser(approval, user);
          }
          wsSend(ws, {
            type: 'status',
            content: 'Command approval is only available to portal admins. This request was denied automatically.',
            sessionKey: sessionId,
          });
          return;
        }
        if (evt.type === 'done') {
          // Stop keepalive during idle gap, but do NOT unsub — agent may resume
          if (streamKeepalive) { clearInterval(streamKeepalive); streamKeepalive = null; }
        }
        if (evt.type === 'run_resumed') {
          // Agent resumed after sub-agent — restart keepalive
          if (!streamKeepalive) {
            streamKeepalive = setInterval(() => {
              wsSend(ws, { type: 'keepalive', ts: Date.now() });
            }, 10000);
          }
          debugLog(`[handleWsSend] run_resumed detected for ${sessionId} — keepalive restarted`);
        }
        if (evt.type === 'error') {
          // Hard error — clean up fully
          if (streamKeepalive) { clearInterval(streamKeepalive); streamKeepalive = null; }
        }
        },
        shouldForwardEvent: (evt: StreamEvent) => {
          const runtimeEvt = evt as any;
          return !(runtimeEvt.type === 'exec_approval' && !isElevatedRole(user.role));
        },
      });

      // No-op callbacks — all events go through StreamEventBus above
      const onChunk = (_chunk: string) => {};
      const onStatus = (_evt: { type: string; content?: string; [key: string]: any }) => {};
      const onExecApproval = (_approval: ExecApprovalRequest) => {};
      const senderIdentity = { label: user.email, userId: user.userId };

      try {
        await (provider as any).sendMessage(
          sessionId, message, onChunk, onStatus, onExecApproval, senderIdentity,
        );
        // Promise resolved — bus already published 'done' and our subscriber handled it.
        // Just clean up the fallback timer.
        clearTimeout(fallbackTimer);
      } catch (sendErr: unknown) {
        clearTimeout(fallbackTimer);
        // The provider rejects if chat.send fails or on timeout.
        // The bus subscriber may already have forwarded an 'error' event.
        // Clean up anything remaining.
        if (streamKeepalive) { clearInterval(streamKeepalive); streamKeepalive = null; }
        runWsStreamCleanup(ws, sessionId);
        throw sendErr;
      }
      return;
    }

    // ── Non-OpenClaw providers: direct callbacks ───────────────────────
    const onChunk = (chunk: string) => {
      if (chunk) wsSend(ws, { type: 'text', content: chunk });
    };
    const onStatus = (evt: { type: string; content?: string; [key: string]: any }) => {
      gotRealStatus = true;
      if (evt?.type === 'exec_approval' && evt.approval) {
        wsSend(ws, { type: 'exec_approval', approval: evt.approval });
        return;
      }
      const eventType = evt?.type || 'status';
      wsSend(ws, { ...evt, type: eventType });
    };
    const onExecApproval = (approval: any) => {
      wsSend(ws, { type: 'exec_approval', approval });
    };

    try {
      const result = await (provider as any).sendMessage(
        sessionId,
        message,
        onChunk,
        onStatus,
        onExecApproval,
        { label: user.email, userId: user.userId },
      );
      clearTimeout(fallbackTimer);
      if (streamKeepalive) clearInterval(streamKeepalive);
      wsSend(ws, { type: 'done', content: result.fullText, provenance, metadata: result.metadata });
    } catch (sendErr: unknown) {
      clearTimeout(fallbackTimer);
      throw sendErr;
    }
  } catch (err: unknown) {
    if (streamKeepalive) clearInterval(streamKeepalive);
    if (resolvedSessionId) streamEventBus.clearStream(resolvedSessionId);
    if (err instanceof AgentAbortError) {
      wsSend(ws, { type: 'abort_result', ok: true, sessionKey: resolvedSessionId || String(session || 'main') });
      return;
    }
    const errMsg = err instanceof Error ? err.message : String(err);
    wsSend(ws, { type: 'error', content: humanizeProviderError(providerNameForError, errMsg) });
  }
}

async function handleWsAbort(ws: WebSocket, msg: any, user?: JwtPayload) {
  const providerName = normalizeProviderName(msg.provider);
  const sessionKey = providerName === 'OPENCLAW'
    ? resolveOpenClawSessionKey(msg.session, user)
    : String(msg.session || '').trim();
  console.log(`[gateway] ABORT REQUEST: provider=${providerName} session=${sessionKey} runId=${msg.runId || 'none'}`);
  try {
    if (user) assertGatewaySessionAccess(sessionKey, user, { providerName });

    if (providerName !== 'OPENCLAW') {
      const provider = AgentRegistry.get(providerName);
      const aborted = await provider.abortActiveRun?.(sessionKey);
      wsSend(ws, { type: 'abort_result', ok: aborted !== false, sessionKey, provider: providerName });
      return;
    }

    const payload: Record<string, string> = { sessionKey };
    if (msg.runId) payload.runId = msg.runId;
    const result = await gatewayRpcCall('chat.abort', payload);
    console.log(`[gateway] ABORT RESULT: ok=${result.ok} error=${result.error || 'none'}`);

    // Tear down the stream subscription for this WS + session.
    // Without this, the subscription stays alive (for sub-agent resume),
    // and any subsequent gateway events for this session (heartbeat, system
    // event, etc.) would re-trigger the frontend into streaming state.
    runWsStreamCleanup(ws, sessionKey);
    streamEventBus.clearStream(sessionKey);

    wsSend(ws, { type: 'abort_result', ok: result.ok, sessionKey, provider: providerName });
  } catch (err: any) {
    wsSend(ws, { type: 'abort_result', ok: false, error: err.message });
  }
}


async function handleWsInject(ws: WebSocket, msg: any, user?: JwtPayload) {
  const sessionKey = resolveOpenClawSessionKey(msg.session, user);
  const text = typeof msg.text === 'string' ? msg.text.trim() : '';
  if (!user || !isElevatedRole(user.role)) {
    wsSend(ws, { type: 'inject_result', ok: false, error: 'Admin access required' });
    return;
  }
  if (!text) {
    wsSend(ws, { type: 'inject_result', ok: false, error: 'text required' });
    return;
  }
  try {
    assertGatewaySessionAccess(sessionKey, user);
    await injectChatMessage(sessionKey, text);
    wsSend(ws, { type: 'inject_result', ok: true, sessionKey });
  } catch (err: any) {
    wsSend(ws, { type: 'inject_result', ok: false, error: err.message });
  }
}

async function handleWsExecApproval(ws: WebSocket, msg: any, user?: JwtPayload) {
  if (!user || !isElevatedRole(user.role)) {
    wsSend(ws, { type: 'approval_result', ok: false, error: 'Admin access required' });
    return;
  }

  const { approvalId, decision } = msg;
  if (!approvalId || !['allow-once', 'deny', 'allow-always'].includes(decision)) {
    wsSend(ws, { type: 'approval_result', ok: false, error: 'Invalid params' });
    return;
  }
  try {
    if (isPersistentWsConnected()) {
      const result = await sendApprovalDecision(approvalId, decision);
      if (result.ok) { wsSend(ws, { type: 'approval_result', ok: true, approvalId, decision }); return; }
    }
    const result = await resolveExecApproval(approvalId, decision);
    wsSend(ws, { type: 'approval_result', ok: result.ok, approvalId, decision, error: result.ok ? undefined : result.error });
  } catch (err: any) {
    wsSend(ws, { type: 'approval_result', ok: false, approvalId, error: err.message });
  }
}

/**
 * Handle browser reconnect request.
 * When a browser WS reconnects after a disconnect, it sends { type: 'reconnect', session }
 * to re-subscribe to an active stream.
 */
async function denyExecApprovalForUnauthorizedUser(approval: ExecApprovalRequest, user?: JwtPayload): Promise<void> {
  if (!approval?.id || !user || isElevatedRole(user.role)) return;
  try {
    const result = await sendApprovalDecision(approval.id, 'deny');
    if (!result.ok) {
      console.warn(`[gateway] Failed to auto-deny exec approval ${approval.id} for unauthorized user ${user.userId}: ${result.error || 'unknown error'}`);
    }
  } catch (err: any) {
    console.warn(`[gateway] Failed to auto-deny exec approval ${approval.id} for unauthorized user ${user.userId}: ${err?.message || String(err)}`);
  }
}

function handleWsReconnect(ws: WebSocket, msg: { session?: string }, user?: JwtPayload): void {
  const sessionKey = resolveOpenClawSessionKey(msg.session, user);
  if (!sessionKey) {
    wsSend(ws, { type: 'error', content: 'reconnect requires session' });
    return;
  }

  try {
    if (user) assertGatewaySessionAccess(sessionKey, user);
    subscribeBackendToLiveSessionEvents(sessionKey);
  } catch (err: any) {
    wsSend(ws, { type: 'error', content: err?.message === 'Admin access required' ? 'Admin access required' : `Reconnect failed: ${err.message}` });
    return;
  }

  const streamInfo = streamEventBus.getTrackedStream(sessionKey);
  if (!streamInfo) {
    wsSend(ws, { type: 'stream_ended' });
    return;
  }

  if (!streamInfo.active) {
    runWsStreamCleanup(ws, sessionKey);
    wsSend(ws, { type: 'stream_ended', sessionKey });
    debugLog(`[gateway-ws] Ignoring reconnect for dormant stream: ${sessionKey}`);
    return;
  }

  attachBrowserWsToSessionStream({
    ws,
    sessionKey,
    streamInfo,
    sendResume: true,
    keepSubscriptionAfterDone: true,
    onEvent: (evt: StreamEvent) => {
      if (evt.type === 'text') debugLog(`[Gateway] RECONNECT→browser TEXT: len=${(evt.content||'').length} "${(evt.content||'').substring(0, 40)}..."`);
    },
  });
  debugLog(`[gateway-ws] Client reconnected to active stream: ${sessionKey}`);
}

/* ─── WS connection handler ────────────────────────────────────────────── */

function handlePortalWsConnection(ws: WebSocket, user: JwtPayload) {
  (ws as any).__portalUser = user;
  portalWsClients.add(ws);
  debugLog(`[gateway-ws] Client connected: ${user.email}`);

  const pingTimer = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) try { ws.ping(); } catch {}
  }, 15000);

  // Subscribe to global StreamEventBus events.
  // This serves two purposes:
  // 1. Forward compaction events even when no per-session subscriber is active
  // 2. After backend restart, forward ALL stream events for active sessions
  //    before the user re-subscribes via a new send/reconnect request.
  //    Without this, events from PersistentGatewayWs are received but never
  //    reach the browser (no per-session subscriber exists yet).
  const unsubGlobal = streamEventBus.subscribeGlobal((sessionKey, evt) => {
    try {
      assertGatewaySessionAccess(sessionKey, user);
    } catch {
      return;
    }

    recordMaintenanceHistoryMarker(sessionKey, evt);

    // If per-session subscribers exist, they handle normal forwarding. Maintenance
    // events are deliberately also sent through the global path so the composer
    // rail and durable history marker update even when the active stream
    // subscription is mid-transition or attached to a different handler.
    const hasSubs = streamEventBus.hasSubscribers(sessionKey);
    const isMaintenanceEvent = evt.type === 'compaction_start'
      || evt.type === 'compaction_end'
      || evt.maintenanceKind === 'maintenance';
    if (hasSubs && !isMaintenanceEvent) {
      return;
    }

    // No per-session subscriber: forward the event directly.
    // This covers maintenance events AND regular stream events that arrive
    // after a backend restart (before the user re-subscribes).
    if (ws.readyState === WebSocket.OPEN) {
      wsSend(ws, { ...evt, sessionKey });
    }
  });

  ws.on('message', async (raw: Buffer | string) => {
    let msg: any;
    try { msg = JSON.parse(raw.toString()); } catch { wsSend(ws, { type: 'error', content: 'Invalid JSON' }); return; }

    switch (msg.type) {
      case 'history': await handleWsHistory(ws, msg, user); break;
      case 'send':    await handleWsSend(ws, msg, user);    break;
      case 'abort':   await handleWsAbort(ws, msg, user);   break;
      case 'inject':  await handleWsInject(ws, msg, user);  break;
      case 'exec_approval_resolve': await handleWsExecApproval(ws, msg, user); break;
      case 'reconnect': handleWsReconnect(ws, msg, user);   break;
      default: wsSend(ws, { type: 'error', content: `Unknown type: ${msg.type}` });
    }
  });

  ws.on('close', () => {
    clearInterval(pingTimer);
    unsubGlobal();
    portalWsClients.delete(ws);
    debugLog(`[gateway-ws] Client disconnected: ${user.email}`);
  });
  ws.on('error', (err: Error) => console.error(`[gateway-ws] Error (${user.email}):`, err.message));

  wsSend(ws, { type: 'connected' });
}

/* ─── WS Server setup (called from server.ts) ─────────────────────────── */

let portalWss: WebSocketServer | null = null;
let directWss: WebSocketServer | null = null;

// Per-user connection tracking for direct proxy WebSocket
const directUserConnections = new Map<string, number>();
const MAX_DIRECT_WS_PER_USER = 5;

// Allowlist of gateway methods that the direct proxy can forward
// Device identity for direct WS proxy — loaded once, reused for all proxy connections
// Use the SAME device keys as PersistentGatewayWs — the gateway allows
// multiple connections from the same device with different instanceIds.
// Using separate (unpaired) keys was the P4.5b blocker: "pairing required".
const DIRECT_PROXY_DEVICE_KEYS = getOrCreateDeviceKeys();
const DIRECT_PROXY_CLIENT_ID = 'gateway-client';
const DIRECT_PROXY_CLIENT_MODE = 'backend';
const DIRECT_PROXY_ROLE = 'operator';
const DIRECT_PROXY_BASE_SCOPES = ['operator.read', 'operator.write'];
const DIRECT_PROXY_PROTOCOL = 3;

function getDirectProxyScopes(user: JwtPayload): string[] {
  if (isElevatedRole(user.role)) {
    return [...DIRECT_PROXY_BASE_SCOPES, 'operator.approvals', 'operator.admin'];
  }
  return [...DIRECT_PROXY_BASE_SCOPES];
}

const ALLOWED_GATEWAY_METHODS = new Set([
  'connect',
  'chat.send',
  'chat.abort',
  'chat.history',
  'sessions.steer',
  'sessions.subscribe',
  'sessions.messages.subscribe',
]);

function isDirectGatewayMethodAllowed(method: unknown, user: JwtPayload): boolean {
  if (typeof method !== 'string') return false;
  if (ALLOWED_GATEWAY_METHODS.has(method)) return true;
  if (method === 'chat.inject') return isElevatedRole(user.role);
  return false;
}

/**
 * Handle a direct WebSocket proxy connection.
 * This creates a transparent pipe between the browser and the OpenClaw gateway,
 * with one exception: 'connect' requests have the auth token injected server-side.
 */
function handleDirectProxyConnection(browserWs: WebSocket, user: JwtPayload) {
  const userId = user.userId;

  // Enforce per-user connection limit
  const currentCount = directUserConnections.get(userId) || 0;
  if (currentCount >= MAX_DIRECT_WS_PER_USER) {
    browserWs.close(4029, 'Too many connections');
    return;
  }
  directUserConnections.set(userId, currentCount + 1);

  const gatewayUrl = getOpenClawWsUrl();
  let gatewayWs: WebSocket | null = null;
  let browserClosed = false;
  let gatewayClosed = false;

  debugLog(`[gateway-direct] Creating proxy for user ${user.email} to ${gatewayUrl}`);

  try {
    gatewayWs = new WebSocket(gatewayUrl);
  } catch (err: any) {
    console.error('[gateway-direct] Failed to connect to gateway:', err.message);
    browserWs.close(1011, 'Gateway connection failed');
    return;
  }

  gatewayWs.on('open', () => {
    debugLog('[gateway-direct] Connected to gateway');
    // Send a connected event to the browser
    try {
      browserWs.send(JSON.stringify({ type: 'connected' }));
    } catch {}
  });

  // Track browser→gateway id mapping so we can convert string IDs back to numeric
  const idMap = new Map<string, number>(); // gateway string ID → browser numeric ID
  const requestMeta = new Map<string, { method?: string; sessionKey?: string; limit?: number }>();

  gatewayWs.on('message', (data: Buffer | string) => {
    if (browserClosed) return;
    // Convert response string IDs back to the numeric IDs the browser expects
    try {
      const str = data.toString();
      const msg = JSON.parse(str);

      // VERBOSE DIAGNOSTIC LOGGING
      if (msg.type === 'event') {
        if (msg.event === 'chat') {
          const state = msg.payload?.state;
          const content = msg.payload?.message?.content;
          const textBlocks = Array.isArray(content) ? content.filter((b: any) => b.type === 'text') : [];
          const thinkBlocks = Array.isArray(content) ? content.filter((b: any) => b.type === 'thinking') : [];
          const textLen = textBlocks.reduce((a: number, b: any) => a + (b.text || '').length, 0);
          const thinkLen = thinkBlocks.reduce((a: number, b: any) => a + (b.thinking || b.text || '').length, 0);
          const textPreview = textBlocks.map((b: any) => (b.text || '').substring(0, 60)).join('|');
          console.log(`[DIAG] chat state=${state} textBlocks=${textBlocks.length} textLen=${textLen} thinkBlocks=${thinkBlocks.length} thinkLen=${thinkLen} runId=${msg.payload?.runId||'-'} preview="${textPreview}"`);
        } else if (msg.event === 'agent') {
          const stream = msg.payload?.stream;
          const data = msg.payload?.data;
          console.log(`[DIAG] agent stream=${stream} phase=${data?.phase||'-'} name=${data?.name||'-'} toolCallId=${data?.toolCallId||'-'}`);
        } else {
          console.log(`[DIAG] event=${msg.event}`);
        }
      }

      if (msg.type === 'res' && typeof msg.id === 'string' && idMap.has(msg.id)) {
        const gatewayId = msg.id;
        const meta = requestMeta.get(gatewayId);
        if (msg.ok && meta?.method === 'chat.history' && meta.sessionKey) {
          msg.payload = augmentDirectHistoryPayload(msg.payload, meta.sessionKey, meta.limit || 200);
        }
        requestMeta.delete(gatewayId);
        msg.id = idMap.get(gatewayId)!;
        idMap.delete(gatewayId);
        browserWs.send(JSON.stringify(msg));
        return;
      }
      browserWs.send(data);
    } catch (err: any) {
      // If JSON parse fails, forward raw
      try { browserWs.send(data); } catch {}
    }
  });

  gatewayWs.on('close', (code: number, reason: Buffer) => {
    gatewayClosed = true;
    debugLog(`[gateway-direct] Gateway closed: ${code} ${reason?.toString()}`);
    if (!browserClosed) {
      try {
        browserWs.close(code, reason?.toString() || 'Gateway disconnected');
      } catch {}
    }
  });

  gatewayWs.on('error', (err: Error) => {
    console.error('[gateway-direct] Gateway error:', err.message);
    if (!browserClosed) {
      try {
        browserWs.close(1011, 'Gateway error');
      } catch {}
    }
  });

  browserWs.on('message', (data: Buffer | string) => {
    if (gatewayClosed || !gatewayWs) return;

    // Parse the message to check if it's a 'connect' request
    let frame: any;
    try {
      frame = JSON.parse(data.toString());
    } catch {
      // Not JSON — pass through unchanged (shouldn't happen with JSON-RPC)
      try {
        gatewayWs.send(data);
      } catch {}
      return;
    }

    // Intercept 'connect' requests — build the full signed connect frame server-side
    // The gateway requires auth token + signed device identity, both must be injected
    if (frame.type === 'req' && frame.method === 'connect') {
      debugLog('[gateway-direct] Intercepting connect request to build full signed connect frame');
      const nonce = frame.params?.nonce;
      const directProxyScopes = getDirectProxyScopes(user);
      const fullParams: Record<string, unknown> = {
        auth: { token: getGatewayToken() },
        client: {
          id: DIRECT_PROXY_CLIENT_ID,
          mode: DIRECT_PROXY_CLIENT_MODE,
          version: '1.0.0',
          displayName: `Portal (${user.email})`,
          platform: 'linux',
          instanceId: `portal-direct-${user.userId.substring(0, 8)}`,
        },
        device: buildSignedDevice({
          keys: DIRECT_PROXY_DEVICE_KEYS,
          clientId: DIRECT_PROXY_CLIENT_ID,
          clientMode: DIRECT_PROXY_CLIENT_MODE,
          role: DIRECT_PROXY_ROLE,
          scopes: directProxyScopes,
          token: getGatewayToken(),
          nonce,
        }),
        role: DIRECT_PROXY_ROLE,
        scopes: directProxyScopes,
        caps: ['tool-events'],
        minProtocol: DIRECT_PROXY_PROTOCOL,
        maxProtocol: DIRECT_PROXY_PROTOCOL,
      };

      const stringId = String(frame.id);
      if (typeof frame.id === 'number') {
        idMap.set(stringId, frame.id);
      }
      requestMeta.set(stringId, {
        method: 'connect',
      });

      const fullFrame = {
        type: 'req',
        id: stringId,
        method: 'connect',
        params: fullParams,
      };

      const serialized = JSON.stringify(fullFrame);
      debugLog(`[gateway-direct] CONNECT frame forwarding: readyState=${gatewayWs.readyState} bufferedAmount=${gatewayWs.bufferedAmount} id=${stringId} client=${DIRECT_PROXY_CLIENT_ID} scopeCount=${directProxyScopes.length}`);
      try {
        gatewayWs.send(serialized, (err) => {
          if (err) {
            console.error('[gateway-direct] Send callback error:', err.message);
          } else {
            debugLog(`[gateway-direct] CONNECT frame forwarded: bufferedAmount=${gatewayWs?.bufferedAmount} id=${stringId}`);
          }
        });
      } catch (err: any) {
        console.error('[gateway-direct] Failed to send signed connect:', err.message);
      }
      return;
    }

    // Enforce method allowlist — reject anything not explicitly allowed for this user.
    // chat.inject stays admin-only to match the portal WS/HTTP injection paths.
    if (frame.type === 'req' && frame.method && !isDirectGatewayMethodAllowed(frame.method, user)) {
      browserWs.send(JSON.stringify({
        type: 'res',
        id: frame.id,
        ok: false,
        error: { code: 'METHOD_NOT_ALLOWED', message: `Method '${frame.method}' is not allowed` }
      }));
      return;
    }

    // Filter out non-standard frame types that the gateway doesn't understand
    // (ping, reconnect, etc. — these are client-side keepalive/session management)
    if (frame.type !== 'req') {
      // Silently drop non-request frames — gateway only accepts 'req' type
      debugLog(`[gateway-direct] Dropping non-req frame type=${frame.type}`);
      return;
    }

    // Pass through request frames — coerce id to string (gateway requires string IDs)
    if (typeof frame.id === 'number') {
      const numericId = frame.id;
      const stringId = String(frame.id);
      frame.id = stringId;
      idMap.set(stringId, numericId);
      requestMeta.set(stringId, {
        method: frame.method,
        sessionKey: typeof frame.params?.sessionKey === 'string'
          ? frame.params.sessionKey
          : (typeof frame.params?.session === 'string' ? frame.params.session : undefined),
        limit: typeof frame.params?.limit === 'number' && Number.isFinite(frame.params.limit)
          ? frame.params.limit
          : undefined,
      });
      try {
        gatewayWs.send(JSON.stringify(frame));
      } catch (err: any) {
        requestMeta.delete(stringId);
        debugLog('[gateway-direct] Failed to forward to gateway:', err.message);
      }
    } else {
      const stringId = typeof frame.id === 'string' ? frame.id : undefined;
      if (stringId) {
        requestMeta.set(stringId, {
          method: frame.method,
          sessionKey: typeof frame.params?.sessionKey === 'string'
            ? frame.params.sessionKey
            : (typeof frame.params?.session === 'string' ? frame.params.session : undefined),
          limit: typeof frame.params?.limit === 'number' && Number.isFinite(frame.params.limit)
            ? frame.params.limit
            : undefined,
        });
      }
      try {
        gatewayWs.send(JSON.stringify(frame));
      } catch (err: any) {
        if (stringId) requestMeta.delete(stringId);
        debugLog('[gateway-direct] Failed to forward to gateway:', err.message);
      }
    }
  });

  browserWs.on('close', (code: number, reason: Buffer) => {
    browserClosed = true;
    debugLog(`[gateway-direct] Browser closed: ${code} ${reason?.toString()}`);

    // Decrement per-user connection count
    const count = directUserConnections.get(userId) || 0;
    if (count <= 1) directUserConnections.delete(userId);
    else directUserConnections.set(userId, count - 1);

    if (!gatewayClosed && gatewayWs) {
      try {
        gatewayWs.close(code, reason?.toString() || 'Browser disconnected');
      } catch {}
    }
  });

  browserWs.on('error', (err: Error) => {
    console.error('[gateway-direct] Browser error:', err.message);
    if (!gatewayClosed && gatewayWs) {
      try {
        gatewayWs.close(1011, 'Browser error');
      } catch {}
    }
  });

  // Ping/pong keepalive
  const pingTimer = setInterval(() => {
    if (!browserClosed && browserWs.readyState === WebSocket.OPEN) {
      try { browserWs.ping(); } catch {}
    }
    if (!gatewayClosed && gatewayWs?.readyState === WebSocket.OPEN) {
      try { gatewayWs.ping(); } catch {}
    }
  }, 30000);

  browserWs.on('close', () => clearInterval(pingTimer));
}

/**
 * Attach the portal WebSocket server to the HTTP server.
 * Call this from server.ts after creating httpServer.
 * Handles upgrade requests on /api/gateway/ws and /api/gateway/direct.
 */
export function attachPortalWebSocket(httpServer: HttpServer) {
  portalWss = new WebSocketServer({ noServer: true });
  initApprovalWsBroadcast();

  portalWss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
    // User already verified in upgrade handler
    const user = (req as any).__portalUser as JwtPayload;
    if (!user) {
      ws.close(4001, 'Unauthorized');
      return;
    }
    handlePortalWsConnection(ws, user);
  });

  // Initialize the direct proxy WebSocket server
  directWss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });

  directWss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
    const user = (req as any).__portalUser as JwtPayload;
    if (!user) {
      ws.close(4001, 'Unauthorized');
      return;
    }
    handleDirectProxyConnection(ws, user);
  });

  // Register upgrade handler for both /api/gateway/ws and /api/gateway/direct
  httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
    const url = req.url || '';
    const isPortalWs = url.startsWith('/api/gateway/ws');
    const isDirectProxy = url.startsWith('/api/gateway/direct');

    if (!isPortalWs && !isDirectProxy) return; // Let other upgrade handlers proceed

    const origin = req.headers.origin;
    if (!isAllowedWebSocketOrigin(origin)) {
      socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
      socket.destroy();
      return;
    }

    // Authenticate via cookie
    const cookies = parseCookiesWs(req.headers.cookie || '');
    const token = cookies.accessToken;
    if (!token) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }
    const user = verifyAccessToken(token);
    if (!user) {
      socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
      socket.destroy();
      return;
    }

    prisma.user.findUnique({
      where: { id: user.userId },
      select: { id: true, email: true, role: true, accountStatus: true, isActive: true, sandboxEnabled: true },
    } as any).then((dbUser) => {
      if (!dbUser || !canUseInteractivePortal(dbUser.role, (dbUser as any).accountStatus, dbUser.isActive)) {
        socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
        socket.destroy();
        return;
      }

      (req as any).__portalUser = {
        userId: dbUser.id,
        email: dbUser.email,
        role: dbUser.role,
        accountStatus: (dbUser as any).accountStatus,
        sandboxEnabled: !!(dbUser as any).sandboxEnabled,
      };

      // Route to the appropriate WebSocket server
      if (isDirectProxy) {
        directWss!.handleUpgrade(req, socket, head, (ws: any) => {
          directWss!.emit('connection', ws, req);
        });
      } else {
        portalWss!.handleUpgrade(req, socket, head, (ws: any) => {
          portalWss!.emit('connection', ws, req);
        });
      }
    }).catch(() => {
      socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
      socket.destroy();
    });
  });

  debugLog('[gateway-ws] Portal WebSocket server attached on /api/gateway/ws and /api/gateway/direct');
}

export default router;
function normalizeRequestedModel(providerName: AgentProviderName, rawModel: string): string {
  const model = String(rawModel || '').trim();
  if (!model) return '';
  if (providerName === 'OPENCLAW' || providerName === 'OLLAMA') return normalizePortalModelId(model);
  if (providerName === 'GEMINI') {
    const normalized = normalizePortalModelId(model).replace(/^models\//, '');
    if (normalized.startsWith('google-gemini-cli/')) return normalized.slice('google-gemini-cli/'.length);
    if (normalized.startsWith('google/')) return normalized.slice('google/'.length);
    return normalized;
  }

  const parts = model.split('/').filter(Boolean);
  if (parts.length < 2) return model;

  const lower = model.toLowerCase();
  if (providerName === 'CLAUDE_CODE' && (lower.startsWith('anthropic/') || lower.startsWith('claude/'))) {
    return parts.slice(1).join('/');
  }
  if (providerName === 'CODEX' && (lower.startsWith('openai-codex/') || lower.startsWith('openai/'))) {
    return parts.slice(1).join('/');
  }
  return model;
}
